stack editing

This commit is contained in:
Hamcha 2023-11-23 01:38:39 +01:00
parent 53dd75c7b4
commit 50f2c97d0c
7 changed files with 219 additions and 31 deletions

View file

@ -45,7 +45,6 @@ pub struct AppState {
pub stack_dir: PathBuf, pub stack_dir: PathBuf,
pub arion_bin: PathBuf, pub arion_bin: PathBuf,
pub docker: Docker, pub docker: Docker,
pub gitconfig: GitConfig,
pub repository: ThreadSafeRepository, pub repository: ThreadSafeRepository,
} }
@ -73,13 +72,12 @@ async fn main() -> Result<()> {
// Make sure stack arg exists and is properly initialized // Make sure stack arg exists and is properly initialized
fs::create_dir_all(&args.stack_dir).await?; fs::create_dir_all(&args.stack_dir).await?;
let repository = ThreadSafeRepository::ensure_repository(&gitconfig, &args.stack_dir)?; let repository = ThreadSafeRepository::ensure_repository(gitconfig, &args.stack_dir)?;
let state = AppState { let state = AppState {
stack_dir: args.stack_dir, stack_dir: args.stack_dir,
arion_bin: args.arion_binary, arion_bin: args.arion_binary,
docker, docker,
gitconfig,
repository, repository,
}; };

View file

@ -1,14 +1,12 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use git2::{ErrorCode, IndexAddOption, Repository, Signature}; use git2::{ErrorCode, IndexAddOption, Repository, Signature};
use std::{ use std::path::{Path, PathBuf};
path::Path,
sync::{Arc, Mutex},
};
use tracing::info; use tracing::info;
#[derive(Clone)] #[derive(Clone)]
pub struct ThreadSafeRepository { pub struct ThreadSafeRepository {
pub inner: Arc<Mutex<Repository>>, pub path: PathBuf,
pub config: GitConfig,
} }
#[derive(Clone)] #[derive(Clone)]
@ -18,11 +16,14 @@ pub struct GitConfig {
} }
impl ThreadSafeRepository { impl ThreadSafeRepository {
pub fn ensure_repository(config: &GitConfig, path: &Path) -> Result<Self> { pub fn ensure_repository(config: GitConfig, path: &Path) -> Result<Self> {
let res = Repository::open(path); let res = Repository::open(path);
match res { match res {
Ok(repository) => Ok(repository.into()), Ok(_) => Ok(Self {
path: path.to_path_buf(),
config,
}),
Err(err) => match err.code() { Err(err) => match err.code() {
ErrorCode::NotFound => ThreadSafeRepository::create_repository(config, path), ErrorCode::NotFound => ThreadSafeRepository::create_repository(config, path),
_ => Err(anyhow!(err)), _ => Err(anyhow!(err)),
@ -30,7 +31,7 @@ impl ThreadSafeRepository {
} }
} }
fn create_repository(config: &GitConfig, path: &Path) -> Result<ThreadSafeRepository> { fn create_repository(config: GitConfig, path: &Path) -> Result<Self> {
// Create repository // Create repository
let repo = Repository::init(path)?; let repo = Repository::init(path)?;
@ -49,7 +50,7 @@ impl ThreadSafeRepository {
Some("HEAD"), Some("HEAD"),
&signature, &signature,
&signature, &signature,
"Commit message", "Initializing stack repository",
&tree, &tree,
&[], &[],
)?; )?;
@ -60,14 +61,41 @@ impl ThreadSafeRepository {
"Repository initialized with base commit" "Repository initialized with base commit"
); );
Ok(repo.into()) Ok(Self {
path: path.to_path_buf(),
config,
})
} }
}
impl From<Repository> for ThreadSafeRepository { fn repository(&self) -> Result<Repository, git2::Error> {
fn from(value: Repository) -> Self { Repository::open(&self.path)
Self {
inner: Arc::new(Mutex::new(value)),
} }
pub fn commit_file(&self, path: &Path, message: &str) -> Result<()> {
let repository = self.repository()?;
// Commit file
let mut index = repository.index()?;
index.add_path(path)?;
let oid = index.write_tree()?;
let tree = repository.find_tree(oid)?;
let head = repository.head()?;
// This prevents a nasty condition where the index goes all wack,
// but it's probably a mistake somewhere else
index.write()?;
let signature = Signature::now(&self.config.author_name, &self.config.author_email)?;
repository.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&[&head.peel_to_commit()?],
)?;
drop(tree);
Ok(())
} }
} }

View file

@ -1,15 +1,21 @@
use super::{container::ContainerInfo, error::StackError, nix::parse_arion_compose}; use super::{
container::ContainerInfo, error::StackError, git::ThreadSafeRepository,
nix::parse_arion_compose,
};
use crate::http::error::Result; use crate::http::error::Result;
use bollard::{container::ListContainersOptions, service::ContainerSummary, Docker}; use bollard::{container::ListContainersOptions, service::ContainerSummary, Docker};
use serde::Serialize; use serde::Serialize;
use std::{collections::HashMap, path::Path}; use std::{
use tokio::fs::{read_dir, try_exists}; collections::HashMap,
path::{Path, PathBuf},
};
use tokio::fs;
use xshell::Shell; use xshell::Shell;
const COMPOSE_FILE: &str = "arion-compose.nix"; const COMPOSE_FILE: &str = "arion-compose.nix";
async fn is_stack(dir: &Path) -> Result<bool> { async fn is_stack(dir: &Path) -> Result<bool> {
Ok(try_exists(dir.join(COMPOSE_FILE)).await?) Ok(fs::try_exists(dir.join(COMPOSE_FILE)).await?)
} }
pub async fn get_containers(docker: &Docker, stack_name: &str) -> Result<Vec<ContainerSummary>> { pub async fn get_containers(docker: &Docker, stack_name: &str) -> Result<Vec<ContainerSummary>> {
@ -96,7 +102,7 @@ pub async fn list(base_dir: &Path, docker: &Docker) -> Result<NodeInfo> {
.map(|c| c.clone().into()) .map(|c| c.clone().into())
.collect(); .collect();
let mut dirs = read_dir(base_dir).await?; let mut dirs = fs::read_dir(base_dir).await?;
let mut stacks = vec![]; let mut stacks = vec![];
while let Some(dir) = dirs.next_entry().await? { while let Some(dir) = dirs.next_entry().await? {
let meta = dir.metadata().await?; let meta = dir.metadata().await?;
@ -127,6 +133,25 @@ pub async fn list(base_dir: &Path, docker: &Docker) -> Result<NodeInfo> {
Ok(NodeInfo { stacks, containers }) Ok(NodeInfo { stacks, containers })
} }
pub async fn write_compose(base_dir: &Path, stack_name: &str, contents: &str) -> Result<()> {
let dir = base_dir.join(stack_name);
if !is_stack(&dir).await? {
return Err(StackError::NotFound.into());
}
Ok(fs::write(dir.join(COMPOSE_FILE), contents).await?)
}
pub fn commit_compose(
repository: ThreadSafeRepository,
stack_name: &str,
message: &str,
) -> Result<()> {
let compose_path = format!("{}/{}", stack_name, COMPOSE_FILE);
repository.commit_file(&PathBuf::from(compose_path), message)?;
Ok(())
}
pub async fn command( pub async fn command(
base_dir: &Path, base_dir: &Path,
stack_name: &str, stack_name: &str,

View file

@ -5,18 +5,23 @@ use crate::{
}, },
node::{ node::{
container::ContainerInfo, container::ContainerInfo,
stack::{command, get_compose, get_containers, StackCommand}, stack::{
command, commit_compose, get_compose, get_containers, write_compose, StackCommand,
},
}, },
AppState, AppState,
}; };
use askama::Template; use askama::Template;
use askama_axum::IntoResponse;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode,
response::Redirect, response::Redirect,
routing::{get, post}, routing::{get, post},
Router, Form, Router,
}; };
use futures_util::join; use futures_util::join;
use serde::Deserialize;
use serde_json::json; use serde_json::json;
#[derive(Template)] #[derive(Template)]
@ -27,6 +32,10 @@ struct GetOneTemplate {
containers: Vec<ContainerInfo>, containers: Vec<ContainerInfo>,
} }
#[derive(Template)]
#[template(path = "stack/new-form.html")]
struct CreateTemplate {}
async fn get_one(Path(stack_name): Path<String>, State(state): State<AppState>) -> HandlerResponse { async fn get_one(Path(stack_name): Path<String>, State(state): State<AppState>) -> HandlerResponse {
let (file_contents_res, containers_res) = join!( let (file_contents_res, containers_res) = join!(
get_compose(&state.stack_dir, &stack_name), get_compose(&state.stack_dir, &stack_name),
@ -50,6 +59,56 @@ async fn get_one(Path(stack_name): Path<String>, State(state): State<AppState>)
) )
} }
async fn new_stack_page() -> impl IntoResponse {
CreateTemplate {}
}
#[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",
});
};
// Cleanup source (like line endings)
let source = form.source.replace("\r\n", "\n");
// 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("./")) as Result<Redirect, AppError>
}
macro_rules! stack_command { macro_rules! stack_command {
($cmd: expr) => { ($cmd: expr) => {
move |Path(stack_name): Path<String>, State(state): State<AppState>| async move { move |Path(stack_name): Path<String>, State(state): State<AppState>| async move {
@ -61,6 +120,7 @@ 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( .route(
"/:stack", "/:stack",
get(|Path(stack_name): Path<String>| async move { get(|Path(stack_name): Path<String>| async move {
@ -75,4 +135,5 @@ pub(super) fn router() -> Router<AppState> {
) )
.route("/:stack/stop", post(stack_command!(StackCommand::Stop))) .route("/:stack/stop", post(stack_command!(StackCommand::Stop)))
.route("/:stack/down", post(stack_command!(StackCommand::Down))) .route("/:stack/down", post(stack_command!(StackCommand::Down)))
.route("/:stack/edit", post(edit_stack))
} }

View file

@ -6,7 +6,7 @@
<main> <main>
<section class="system-info"> <section class="system-info">
<article> <article>
<table> <table class="full-width">
<tbody> <tbody>
<tr> <tr>
<th>Hostname</th> <th>Hostname</th>
@ -50,7 +50,7 @@
</article> </article>
<article> <article>
<table> <table class="full-width">
<tbody> <tbody>
{% for disk in system.disks %} {% for disk in system.disks %}
@ -73,7 +73,8 @@
</article> </article>
</section> </section>
<section class="stack-list"> <section class="stack-list">
<h2>Stacks</h2> <h2>Stacks <form action="/stack/_/new"><button>+</button></form>
</h2>
<table class="table full-width"> <table class="table full-width">
<thead> <thead>
<tr> <tr>
@ -134,6 +135,21 @@
</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;
@ -162,7 +178,7 @@
padding: 4px 8px; padding: 4px 8px;
&.status { &.status {
width: 8px; width: 6px;
padding: 0; padding: 0;
background-color: #E5484D; background-color: #E5484D;
@ -178,6 +194,7 @@
grid-area: sys; grid-area: sys;
display: flex; display: flex;
gap: 1rem; gap: 1rem;
flex-direction: column;
& th { & th {
font-weight: bold; font-weight: bold;
@ -202,6 +219,11 @@
} }
@media (min-width: 1000px) { @media (min-width: 1000px) {
.system-info {
flex-direction: row;
justify-content: space-between;
}
main { main {
display: grid; display: grid;
gap: 1rem; gap: 1rem;

View file

@ -36,7 +36,14 @@
</section> </section>
<section class="editor"> <section class="editor">
<h2>Editor</h2> <h2>Editor</h2>
<textarea id="editor">{{file_contents}}</textarea> <form method="POST" action="./edit" id="editor-form">
<textarea name="source" id="editor">{{file_contents}}</textarea>
<div class="row">
<input style="flex:1" name="commit_message" type="text"
placeholder="What did you change?" />
<button class="wide" type="submit">Deploy</button>
</div>
</form>
</section> </section>
</main> </main>
@ -77,6 +84,42 @@
color: #E796F3; color: #E796F3;
} }
.editor form {
display: flex;
flex-direction: column;
gap: 0.5ch;
& .row {
display: flex;
gap: 0.5ch;
& input,
& button {
font-size: 13pt;
padding: 6px 10px;
&.wide {
padding: 6px 20px;
}
}
}
}
input,
textarea {
border: 1px solid #5958B1;
border-radius: 3px;
background-color: var(--bg-raised);
color: var(--text);
padding: 4px;
}
#editor {
border-width: 3px;
min-height: 50vh;
}
.ace_editor { .ace_editor {
min-height: 50vh; min-height: 50vh;
border: 3px solid #5958B1; border: 3px solid #5958B1;
@ -108,8 +151,13 @@
fontFamily: "Iosevka Web", fontFamily: "Iosevka Web",
fontSize: "12pt" fontSize: "12pt"
}); });
editor.getSession().setUseWrapMode(true);
editor.setKeyboardHandler("ace/keyboard/vim"); editor.setKeyboardHandler("ace/keyboard/vim");
editor.session.setMode("ace/mode/nix"); editor.session.setMode("ace/mode/nix");
document.getElementById("editor-form").addEventListener("formdata", (ev) => {
ev.formData.set("source", editor.getValue());
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}New stack{% endblock %}
{% block content %}
{% endblock %}