stack editing
This commit is contained in:
parent
53dd75c7b4
commit
50f2c97d0c
7 changed files with 219 additions and 31 deletions
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 %}
|
6
templates/stack/new-form.html
Normal file
6
templates/stack/new-form.html
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}New stack{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% endblock %}
|
Reference in a new issue