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 arion_bin: PathBuf,
pub docker: Docker,
pub gitconfig: GitConfig,
pub repository: ThreadSafeRepository,
}
@ -73,13 +72,12 @@ async fn main() -> Result<()> {
// Make sure stack arg exists and is properly initialized
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 {
stack_dir: args.stack_dir,
arion_bin: args.arion_binary,
docker,
gitconfig,
repository,
};

View file

@ -1,14 +1,12 @@
use anyhow::{anyhow, Result};
use git2::{ErrorCode, IndexAddOption, Repository, Signature};
use std::{
path::Path,
sync::{Arc, Mutex},
};
use std::path::{Path, PathBuf};
use tracing::info;
#[derive(Clone)]
pub struct ThreadSafeRepository {
pub inner: Arc<Mutex<Repository>>,
pub path: PathBuf,
pub config: GitConfig,
}
#[derive(Clone)]
@ -18,11 +16,14 @@ pub struct GitConfig {
}
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);
match res {
Ok(repository) => Ok(repository.into()),
Ok(_) => Ok(Self {
path: path.to_path_buf(),
config,
}),
Err(err) => match err.code() {
ErrorCode::NotFound => ThreadSafeRepository::create_repository(config, path),
_ => 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
let repo = Repository::init(path)?;
@ -49,7 +50,7 @@ impl ThreadSafeRepository {
Some("HEAD"),
&signature,
&signature,
"Commit message",
"Initializing stack repository",
&tree,
&[],
)?;
@ -60,14 +61,41 @@ impl ThreadSafeRepository {
"Repository initialized with base commit"
);
Ok(repo.into())
Ok(Self {
path: path.to_path_buf(),
config,
})
}
}
impl From<Repository> for ThreadSafeRepository {
fn from(value: Repository) -> Self {
Self {
inner: Arc::new(Mutex::new(value)),
}
fn repository(&self) -> Result<Repository, git2::Error> {
Repository::open(&self.path)
}
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 bollard::{container::ListContainersOptions, service::ContainerSummary, Docker};
use serde::Serialize;
use std::{collections::HashMap, path::Path};
use tokio::fs::{read_dir, try_exists};
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use tokio::fs;
use xshell::Shell;
const COMPOSE_FILE: &str = "arion-compose.nix";
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>> {
@ -96,7 +102,7 @@ pub async fn list(base_dir: &Path, docker: &Docker) -> Result<NodeInfo> {
.map(|c| c.clone().into())
.collect();
let mut dirs = read_dir(base_dir).await?;
let mut dirs = fs::read_dir(base_dir).await?;
let mut stacks = vec![];
while let Some(dir) = dirs.next_entry().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 })
}
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(
base_dir: &Path,
stack_name: &str,

View file

@ -5,18 +5,23 @@ use crate::{
},
node::{
container::ContainerInfo,
stack::{command, get_compose, get_containers, StackCommand},
stack::{
command, commit_compose, get_compose, get_containers, write_compose, StackCommand,
},
},
AppState,
};
use askama::Template;
use askama_axum::IntoResponse;
use axum::{
extract::{Path, State},
http::StatusCode,
response::Redirect,
routing::{get, post},
Router,
Form, Router,
};
use futures_util::join;
use serde::Deserialize;
use serde_json::json;
#[derive(Template)]
@ -27,6 +32,10 @@ struct GetOneTemplate {
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 {
let (file_contents_res, containers_res) = join!(
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 {
($cmd: expr) => {
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> {
Router::new()
.route("/_/new", get(new_stack_page))
.route(
"/:stack",
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/down", post(stack_command!(StackCommand::Down)))
.route("/:stack/edit", post(edit_stack))
}

View file

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

View file

@ -36,7 +36,14 @@
</section>
<section class="editor">
<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>
</main>
@ -77,6 +84,42 @@
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 {
min-height: 50vh;
border: 3px solid #5958B1;
@ -108,8 +151,13 @@
fontFamily: "Iosevka Web",
fontSize: "12pt"
});
editor.getSession().setUseWrapMode(true);
editor.setKeyboardHandler("ace/keyboard/vim");
editor.session.setMode("ace/mode/nix");
document.getElementById("editor-form").addEventListener("formdata", (ev) => {
ev.formData.set("source", editor.getValue());
});
</script>
{% endblock %}

View file

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