Compare commits

...

2 commits

Author SHA1 Message Date
bee2d265d7 bad vscode, bad
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-26 21:37:04 +01:00
fd8c865a27 version management and some other stuff 2023-11-26 21:36:51 +01:00
24 changed files with 441 additions and 46 deletions

3
.gitignore vendored
View file

@ -1,3 +1,4 @@
/target /target
/dist /dist
.env .env
.vscode

131
src/git/history.rs Normal file
View file

@ -0,0 +1,131 @@
use anyhow::Result;
use git2::DiffLine;
use serde::Serialize;
use std::path::Path;
use time::{
format_description::well_known::{Iso8601, Rfc2822},
OffsetDateTime,
};
use super::ThreadSafeRepository;
#[derive(Serialize, Clone)]
pub struct LineChange {
pub op: char,
pub old_line: Option<u32>,
pub new_line: Option<u32>,
pub content: String,
}
#[derive(Serialize, Clone)]
pub struct CommitInfo {
pub oid: String,
pub message: String,
pub author: String,
pub date: String,
pub changes: Vec<LineChange>,
}
impl ThreadSafeRepository {
pub fn get_history(&self, path: &Path) -> Result<Vec<CommitInfo>> {
let repository = self.repository()?;
let mut revwalk = repository.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(git2::Sort::TIME | git2::Sort::REVERSE)?;
let mut history = Vec::new();
let mut last_blob = None;
for oid in revwalk {
// Get commit
let oid = oid?;
let commit = repository.find_commit(oid)?;
let tree = commit.tree()?;
// Check if file changed in this commit
let entry = tree.get_path(path)?;
if last_blob == Some(entry.id()) {
continue;
}
// Get changes
let current = repository.find_blob(entry.id())?;
let old = last_blob.and_then(|id| repository.find_blob(id).ok());
let mut changes = vec![];
repository.diff_blobs(
old.as_ref(),
None,
Some(&current),
None,
None,
None,
None,
None,
Some(&mut |_, _, line| {
changes.push(line.into());
true
}),
)?;
// Write new blob id to compare against
last_blob = Some(entry.id());
history.push(CommitInfo {
oid: commit.id().to_string(),
message: commit.message().unwrap_or_default().to_string(),
author: commit.author().to_string(),
date: OffsetDateTime::from_unix_timestamp(commit.time().seconds())
.unwrap_or_else(|_| OffsetDateTime::now_utc())
.format(&Iso8601::DEFAULT)
.unwrap_or_default(),
changes,
});
}
Ok(history)
}
pub fn get_file_at_commit(&self, path: &Path, oid: &str) -> Result<String> {
// lol codewhisperer autocompleted all this, have fun
let repository = self.repository()?;
let commit = repository.find_commit(oid.parse()?)?;
let tree = commit.tree()?;
let entry = tree.get_path(path)?;
let blob = repository.find_blob(entry.id())?;
Ok(String::from_utf8_lossy(blob.content()).into_owned())
}
}
impl From<DiffLine<'_>> for LineChange {
fn from(value: DiffLine<'_>) -> Self {
Self {
op: value.origin(),
old_line: value.old_lineno(),
new_line: value.new_lineno(),
content: String::from_utf8_lossy(value.content()).into_owned(),
}
}
}
impl CommitInfo {
pub fn diff(&self) -> Vec<String> {
let mut ordered = self.changes.clone();
ordered.sort_by(|a, b| {
let line_a = a.old_line.or_else(|| a.new_line).unwrap_or_default();
let line_b = b.old_line.or_else(|| b.new_line).unwrap_or_default();
line_a.cmp(&line_b)
});
ordered
.iter()
.map(|change| format!("{} {}", change.op, change.content))
.collect()
}
pub fn date_human(&self) -> String {
OffsetDateTime::parse(&self.date, &Iso8601::DEFAULT)
.ok()
.and_then(|date| date.format(&Rfc2822).ok())
.unwrap_or_else(|| self.date.clone())
}
}

View file

@ -3,6 +3,8 @@ use git2::{ErrorCode, Index, IndexAddOption, Oid, Repository, Signature};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::info; use tracing::info;
pub mod history;
#[derive(Clone)] #[derive(Clone)]
pub struct ThreadSafeRepository { pub struct ThreadSafeRepository {
pub path: PathBuf, pub path: PathBuf,

View file

@ -1,6 +1,6 @@
use super::{ use super::{
accept::{parse_accept, ResponseType}, accept::{parse_accept, ResponseType},
error::{AppError, ErrorInfo}, error::{ErrorInfo, Result},
}; };
use askama::Template; use askama::Template;
use askama_axum::IntoResponse; use askama_axum::IntoResponse;
@ -12,7 +12,7 @@ use axum::{
}; };
use serde_json::json; use serde_json::json;
pub type HandlerResponse = Result<askama_axum::Response, AppError>; pub type HandlerResponse = Result<askama_axum::Response>;
struct Response { struct Response {
html: String, html: String,

View file

@ -1,16 +1,18 @@
use crate::git::{GitConfig, ThreadSafeRepository};
use crate::http::response::response_interceptor; use crate::http::response::response_interceptor;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use axum::middleware::from_fn; use axum::middleware::from_fn;
use bollard::Docker; use bollard::Docker;
use clap::Parser; use clap::Parser;
use node::git::{GitConfig, ThreadSafeRepository};
use std::{net::SocketAddr, path::PathBuf}; use std::{net::SocketAddr, path::PathBuf};
use tokio::fs; use tokio::fs;
mod git;
mod http; mod http;
mod nix; mod nix;
mod node; mod node;
mod route; mod route;
mod stack;
/// GitOps+WebUI for arion-based stacks /// GitOps+WebUI for arion-based stacks
#[derive(Parser, Debug)] #[derive(Parser, Debug)]

View file

@ -1,6 +1,4 @@
pub mod container; pub mod container;
pub mod error; pub mod error;
pub mod git;
pub mod nix; pub mod nix;
pub mod stack;
pub mod system; pub mod system;

View file

@ -1,6 +1,6 @@
use crate::{ use crate::{
http::error::Result,
node::container::{kill, remove, restart, start, stop}, node::container::{kill, remove, restart, start, stop},
route::AppError,
AppState, AppState,
}; };
use axum::{ use axum::{
@ -17,7 +17,7 @@ macro_rules! container_command {
($cmd: ident) => { ($cmd: ident) => {
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move { move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
$cmd(&state.docker, &cont_name).await?; $cmd(&state.docker, &cont_name).await?;
Ok(Redirect::to("./")) as Result<Redirect, AppError> Ok(Redirect::to("./")) as Result<Redirect>
} }
}; };
} }
@ -41,7 +41,7 @@ pub(super) fn router() -> Router<AppState> {
post( post(
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move { move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
remove(&state.docker, &cont_name).await?; remove(&state.docker, &cont_name).await?;
Ok(Redirect::to("/")) as Result<Redirect, AppError> Ok(Redirect::to("/")) as Result<Redirect>
}, },
), ),
) )

View file

@ -1,12 +1,8 @@
use crate::{ use crate::{
http::{ http::error::AppError,
error::AppError, http::response::{reply, HandlerResponse},
response::{reply, HandlerResponse}, node::system::{system_info, SystemInfo},
}, stack::{list, NodeInfo},
node::{
stack::{list, NodeInfo},
system::{system_info, SystemInfo},
},
AppState, AppState,
}; };
use askama::Template; use askama::Template;

View file

@ -1,11 +1,11 @@
use axum::{extract::State, http::StatusCode}; use axum::{extract::State, http::StatusCode};
use crate::{http::error::AppError, node::stack::compose, AppState}; use crate::{http::error::Result, stack::compose, AppState};
pub(super) async fn check_stack_file( pub(super) async fn check_stack_file(
State(state): State<AppState>, State(state): State<AppState>,
body: String, body: String,
) -> Result<StatusCode, AppError> { ) -> Result<StatusCode> {
compose::check(&state.arion_bin, &body).await?; compose::check(&state.arion_bin, &body).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View file

@ -4,8 +4,8 @@ use axum::{extract::State, response::Redirect, Form};
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
http::error::AppError, http::error::Result,
node::stack::{compose, operation}, stack::{compose, operation},
AppState, AppState,
}; };
@ -21,7 +21,7 @@ pub(super) struct CreateStackForm {
pub(super) async fn create_stack( pub(super) async fn create_stack(
State(state): State<AppState>, State(state): State<AppState>,
Form(form): Form<CreateStackForm>, Form(form): Form<CreateStackForm>,
) -> Result<Redirect, AppError> { ) -> Result<Redirect> {
// Make sure body is is ok // Make sure body is is ok
let name = compose::check(&state.arion_bin, &form.source).await?; let name = compose::check(&state.arion_bin, &form.source).await?;

View file

@ -5,7 +5,11 @@ use axum::{
response::Redirect, response::Redirect,
}; };
use crate::{http::error::AppError, node::stack::operation, AppState}; use crate::{
http::error::Result,
stack::{self, operation},
AppState,
};
#[derive(Template)] #[derive(Template)]
#[template(path = "stack/delete-one.html")] #[template(path = "stack/delete-one.html")]
@ -13,14 +17,21 @@ struct ConfirmDeleteTemplate {
stack_name: String, stack_name: String,
} }
pub(super) async fn confirm_deletion_page(Path(stack_name): Path<String>) -> impl IntoResponse { pub(super) async fn confirm_deletion_page(
ConfirmDeleteTemplate { stack_name } Path(stack_name): Path<String>,
State(state): State<AppState>,
) -> Result<impl IntoResponse> {
let project_id = stack::list::stack_name(&state.stack_dir, &stack_name).await?;
Ok(ConfirmDeleteTemplate {
stack_name: project_id,
})
} }
pub(super) async fn delete_stack( pub(super) async fn delete_stack(
Path(stack_name): Path<String>, Path(stack_name): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Redirect, AppError> { ) -> Result<Redirect> {
operation::remove( operation::remove(
&state.stack_dir, &state.stack_dir,
&state.arion_bin, &state.arion_bin,

View file

@ -6,7 +6,11 @@ use axum::{
}; };
use serde::Deserialize; use serde::Deserialize;
use crate::{http::error::AppError, node::stack::arion, node::stack::compose, AppState}; use crate::{
http::error::{AppError, Result},
stack::{arion, compose},
AppState,
};
#[derive(Deserialize)] #[derive(Deserialize)]
pub(super) struct EditStackForm { pub(super) struct EditStackForm {
@ -18,7 +22,7 @@ pub(super) async fn edit_stack(
Path(stack_name): Path<String>, Path(stack_name): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Form(form): Form<EditStackForm>, Form(form): Form<EditStackForm>,
) -> Result<Redirect, AppError> { ) -> Result<Redirect> {
let commit_message = if form.commit_message.trim().is_empty() { let commit_message = if form.commit_message.trim().is_empty() {
format!("Update {}", stack_name) format!("Update {}", stack_name)
} else { } else {
@ -39,20 +43,34 @@ pub(super) async fn edit_stack(
// Make sure file is ok // Make sure file is ok
compose::check(&state.arion_bin, &source).await?; compose::check(&state.arion_bin, &source).await?;
edit_stack_int(state, &stack_name, &source, &commit_message).await?;
Ok(Redirect::to("./"))
}
pub(super) async fn edit_stack_int(
state: AppState,
stack_name: &str,
source: &str,
commit_message: &str,
) -> Result<()> {
// Make sure file is ok
compose::check(&state.arion_bin, &source).await?;
// Write compose file // Write compose file
compose::write(&state.stack_dir, &stack_name, &source).await?; compose::write(&state.stack_dir, stack_name, source).await?;
// Git commit // Git commit
compose::commit(state.repository, &stack_name, &commit_message)?; compose::commit(state.repository, stack_name, commit_message)?;
// Update stack // Update stack
arion::command( arion::command(
&state.stack_dir, &state.stack_dir,
&stack_name, stack_name,
&state.arion_bin, &state.arion_bin,
arion::StackCommand::Start, arion::StackCommand::Start,
) )
.await?; .await?;
Ok(Redirect::to("./")) Ok(())
} }

View file

@ -0,0 +1,75 @@
use askama::Template;
use axum::{
extract::{Path, State},
response::Redirect,
Form,
};
use serde::Deserialize;
use serde_json::json;
use crate::{
git::history::CommitInfo,
http::error::Result,
http::response::{reply, HandlerResponse},
node::nix::parse_arion_compose,
stack::compose,
AppState,
};
use super::edit::edit_stack_int;
#[derive(Template)]
#[template(path = "stack/history.html")]
struct HistoryTemplate {
stack_name: String,
commits: Vec<CommitInfo>,
}
#[derive(Deserialize)]
pub(super) struct RestoreForm {
oid: String,
}
pub(super) async fn list(
Path(stack_name): Path<String>,
State(state): State<AppState>,
) -> HandlerResponse {
let compose_file = compose::get(&state.stack_dir, &stack_name).await?;
let info = parse_arion_compose(&compose_file)?;
let git_compose_path = compose::path(&stack_name);
let commits = state.repository.get_history(&git_compose_path)?;
let mut newest_first = commits.clone();
newest_first.reverse();
reply(
json!({
"commits": commits,
}),
HistoryTemplate {
stack_name: info.project,
commits: newest_first,
},
)
}
pub(super) async fn restore(
Path(stack_name): Path<String>,
State(state): State<AppState>,
Form(form): Form<RestoreForm>,
) -> Result<Redirect> {
let path = compose::path(&stack_name);
let source = state.repository.get_file_at_commit(&path, &form.oid)?;
let short_id = form.oid[..6].to_string();
edit_stack_int(
state,
&stack_name,
&source,
format!("Revert to {}", short_id).as_str(),
)
.await?;
Ok(Redirect::to(".."))
}

View file

@ -6,8 +6,8 @@ use axum::{
}; };
use crate::{ use crate::{
http::error::AppError, http::error::Result,
node::stack::arion::{command, StackCommand}, stack::arion::{command, StackCommand},
AppState, AppState,
}; };
@ -17,13 +17,14 @@ mod check;
mod create; mod create;
mod delete; mod delete;
mod edit; mod edit;
mod history;
mod read; mod read;
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 {
command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?; command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?;
Ok(Redirect::to("./")) as Result<Redirect, AppError> Ok(Redirect::to("./")) as Result<Redirect>
} }
}; };
} }
@ -54,5 +55,6 @@ pub(super) fn router() -> Router<AppState> {
"/:stack/delete", "/:stack/delete",
get(delete::confirm_deletion_page).post(delete::delete_stack), get(delete::confirm_deletion_page).post(delete::delete_stack),
) )
//.route("/:stack/history", get(get_stack_history)) .route("/:stack/history", get(history::list))
.route("/:stack/history/revert", post(history::restore))
} }

View file

@ -4,14 +4,14 @@ use serde_json::json;
use crate::{ use crate::{
http::response::{reply, HandlerResponse}, http::response::{reply, HandlerResponse},
node::{container::ContainerInfo, nix::parse_arion_compose, stack}, node::container::ContainerInfo,
AppState, node::nix::parse_arion_compose,
stack, AppState,
}; };
#[derive(Template)] #[derive(Template)]
#[template(path = "stack/get-one.html")] #[template(path = "stack/get-one.html")]
struct GetOneTemplate { struct GetOneTemplate {
stack_folder: String,
stack_name: String, stack_name: String,
file_contents: String, file_contents: String,
containers: Vec<ContainerInfo>, containers: Vec<ContainerInfo>,
@ -33,7 +33,6 @@ pub(super) async fn get_one(
"containers": containers, "containers": containers,
}), }),
GetOneTemplate { GetOneTemplate {
stack_folder: stack_name,
stack_name: info.project, stack_name: info.project,
file_contents, file_contents,
containers: containers.iter().map(|c| c.clone().into()).collect(), containers: containers.iter().map(|c| c.clone().into()).collect(),

View file

@ -6,8 +6,9 @@ use tempfile::tempdir;
use tokio::fs; use tokio::fs;
use crate::{ use crate::{
git::ThreadSafeRepository,
http::error::AppError, http::error::AppError,
node::{error::StackError, git::ThreadSafeRepository, nix::parse_arion_compose}, node::{error::StackError, nix::parse_arion_compose},
}; };
use super::{arion, utils, COMPOSE_FILE, PACKAGE_CONTENTS, PACKAGE_FILE}; use super::{arion, utils, COMPOSE_FILE, PACKAGE_CONTENTS, PACKAGE_FILE};
@ -32,8 +33,8 @@ pub async fn write(base_dir: &Path, stack_name: &str, contents: &str) -> Result<
} }
pub fn commit(repository: ThreadSafeRepository, stack_name: &str, message: &str) -> Result<()> { pub fn commit(repository: ThreadSafeRepository, stack_name: &str, message: &str) -> Result<()> {
let compose_path = format!("{}/{}", stack_name, COMPOSE_FILE); let compose_path = path(stack_name);
repository.commit_files(&[&PathBuf::from(compose_path)], message)?; repository.commit_files(&[&compose_path], message)?;
Ok(()) Ok(())
} }
@ -70,3 +71,8 @@ pub async fn check(arion_bin: &Path, source: &str) -> crate::http::error::Result
Ok(info.project) Ok(info.project)
} }
} }
#[inline(always)]
pub fn path(stack_name: &str) -> PathBuf {
format!("{}/{}", stack_name, COMPOSE_FILE).into()
}

View file

@ -93,3 +93,10 @@ pub async fn containers(docker: &Docker, stack_name: &str) -> Result<Vec<Contain
})) }))
.await?) .await?)
} }
// Shortcut function to get a stack name from directory
pub async fn stack_name(base_dir: &Path, folder: &str) -> Result<String> {
let compose_file = compose::get(base_dir, folder).await?;
let info = parse_arion_compose(&compose_file)?;
Ok(info.project)
}

View file

@ -1,6 +1,6 @@
use serde::Serialize; use serde::Serialize;
use super::container::ContainerInfo; use crate::node::container::ContainerInfo;
pub mod arion; pub mod arion;
pub mod compose; pub mod compose;

View file

@ -3,7 +3,7 @@ use futures_util::future::try_join;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tokio::fs; use tokio::fs;
use crate::node::git::ThreadSafeRepository; use crate::git::ThreadSafeRepository;
use super::{arion, COMPOSE_FILE, PACKAGE_CONTENTS, PACKAGE_FILE}; use super::{arion, COMPOSE_FILE, PACKAGE_CONTENTS, PACKAGE_FILE};

View file

@ -100,6 +100,13 @@ button {
color: var(--link-hover); color: var(--link-hover);
border-color: var(--link-hover); border-color: var(--link-hover);
} }
&:disabled {
background-color: #323035;
border-color: #3C393F;
color: #6F6D78;
cursor: not-allowed;
}
} }
table.table { table.table {

View file

@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}{{ title }} - staxman{% endblock %}</title> <title>{% block title %}{% endblock %} - staxman</title>
<link rel="preconnect" href="https://rsms.me/"> <link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"> <link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<link rel="stylesheet" href="https://iosevka-webfonts.github.io/iosevka/iosevka.css"> <link rel="stylesheet" href="https://iosevka-webfonts.github.io/iosevka/iosevka.css">

View file

@ -0,0 +1,140 @@
{% extends "base.html" %}
{% block title %}Stack history for {{stack_name}}{% endblock %}
{% block content %}
<main>
<header>
<h1>Stack history for <span class="stack-name">{{stack_name}}</span></h1>
<div id="page-actions">
</div>
</header>
<section class="versions">
{% for (i, commit) in commits.iter().enumerate() %}
<article>
<p>
<time datetime="{{commit.date}}">{{commit.date_human()}}</time>
<span class="commit_id">
{{commit.oid[..7]}}
</span>
</p>
<details open>
<summary title="{{commit.message}} ({{commit.author}})">
{{commit.message}}
</summary>
<code>
{% for line in commit.diff() %}
{% match line.chars().next() %}
{% when Some with ('+') %}
<pre class="add">{{line}}</pre>
{% when Some with ('-') %}
<pre class="del">{{line}}</pre>
{% when _ %}
<pre class="context">{{line}}</pre>
{% endmatch %}
{% endfor %}
</code>
</details>
<div class="actions">
{% if i == 0 %}
<button disabled>CURRENT</button>
{% else %}
<form method="POST" action="./history/revert">
<input type="hidden" name="oid" value="{{commit.oid}}" />
<button type="submit">Revert to this revision</button>
</form>
{% endif %}
</div>
</article>
{% endfor %}
</section>
</main>
<style scoped>
.stack-name {
color: var(--text-accent);
}
summary {
cursor: pointer;
}
.versions {
display: flex;
flex-direction: column;
gap: 1ch;
& article {
border: 1px solid var(--table-border-color);
background-color: var(--table-bg);
border-radius: 3px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 0.5ch;
& p {
font-size: 9pt;
margin: 0;
& .commit_id::before {
content: " - ";
}
}
& code {
font-size: 10pt;
background-color: var(--bg-raised);
display: flex;
flex-direction: column;
border-radius: 5px;
padding: 4px 6px;
margin-top: 0.5ch;
}
& pre {
&.context {
color: var(--button-color);
}
&.add {
color: var(--success-text);
}
&.del {
color: var(--danger-text);
}
}
}
}
.actions {
& button {
padding: 2px 6px;
font-size: 10pt;
}
}
header {
margin-bottom: 1rem;
}
</style>
<script type="module">
const actions = document.getElementById("page-actions");
const toggle_text = ["Collapse all", "Expand all"];
/** @type {HTMLDetailsElement[]} */
const versions = [...document.querySelectorAll(".versions details")];
let toggle = document.createElement("button");
toggle.type = "button";
toggle.appendChild(document.createTextNode(toggle_text[0]));
toggle.addEventListener("click", () => {
const index = toggle_text.indexOf(toggle.textContent);
versions.forEach(version => { version.open = index == 1 });
toggle.textContent = toggle_text[(index + 1) % 2];
});
actions.appendChild(toggle);
</script>
{% endblock %}