mod files;
mod records;
mod runs;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context as _, bail};
use axum::body::Body;
use axum::extract::{Path as UrlPath, Query, Request, State};
use axum::http::{HeaderMap, Method, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get, post};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{Value, json};
use tracing::info;
use runs::{Runs, StartRequest};
pub const TOKEN_ENV: &str = "TAKTWERK_SERVE_TOKEN";
const MAX_FILE: u64 = 1 << 20;
const DEFAULT_POINTS: usize = 4000;
pub(crate) fn unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
struct Workspace {
root: PathBuf,
token: String,
exe: PathBuf,
runs: Runs,
}
pub fn serve(dir: &Path, listen: &str) -> anyhow::Result<ExitCode> {
let root = dir
.canonicalize()
.with_context(|| format!("folder {}", dir.display()))?;
if !root.is_dir() {
bail!("{} is not a folder", root.display());
}
let addr: SocketAddr = listen
.parse()
.with_context(|| format!("--listen `{listen}`: expected an address and port"))?;
if !addr.ip().is_loopback() {
bail!("--listen `{listen}`: serve listens on loopback only");
}
let token = match std::env::var(TOKEN_ENV) {
Ok(t) if !t.trim().is_empty() => t.trim().to_owned(),
_ => taktwerk_http::token::generate().context("token")?,
};
let exe = std::env::current_exe().context("the taktwerk executable")?;
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("taktwerk-serve")
.enable_all()
.build()
.context("cannot start the runtime")?;
rt.block_on(async move {
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("listen {addr}"))?;
let bound = listener.local_addr()?;
let ws = Arc::new(Workspace {
runs: Runs::new(exe.clone(), root.clone()),
root,
token,
exe,
});
println!("taktwerk workbench: http://{bound}/#token={}", ws.token);
info!(folder = %ws.root.display(), %bound, ui = taktwerk_http::ui::available(), "serving");
let app = router(Arc::clone(&ws));
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
axum::serve(listener, app)
.with_graceful_shutdown(async move {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
})
.await?;
for run in ws.runs.all() {
ws.runs.stop(&run).await;
}
Ok::<_, anyhow::Error>(())
})?;
Ok(ExitCode::SUCCESS)
}
struct ApiError(StatusCode, String);
impl ApiError {
fn bad(msg: impl Into<String>) -> Self {
Self(StatusCode::BAD_REQUEST, msg.into())
}
fn not_found(msg: impl Into<String>) -> Self {
Self(StatusCode::NOT_FOUND, msg.into())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.0, Json(json!({ "error": self.1 }))).into_response()
}
}
impl Workspace {
fn authorize(&self, headers: &HeaderMap) -> Result<(), ApiError> {
let given = headers
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(taktwerk_http::token::bearer);
match given {
Some(g) if taktwerk_http::token::matches(&self.token, g) => Ok(()),
_ => Err(ApiError(
StatusCode::UNAUTHORIZED,
"this needs `Authorization: Bearer <token>`".into(),
)),
}
}
fn file(&self, rel: &str) -> Result<PathBuf, ApiError> {
files::resolve(&self.root, rel)
.ok_or_else(|| ApiError::not_found(format!("`{rel}` is not in the folder")))
}
}
fn router(ws: Arc<Workspace>) -> Router {
Router::new()
.route("/api/info", get(info_handler))
.route("/api/files", get(list_files))
.route("/api/file", get(read_file))
.route("/api/check", get(check))
.route("/api/records", get(list_records))
.route("/api/records/run", get(record))
.route("/api/records/series", get(series))
.route("/api/campaigns", get(campaigns))
.route("/api/runs", get(list_runs).post(start_run))
.route("/api/runs/{id}", get(run_detail))
.route("/api/runs/{id}/stop", post(stop_run))
.route("/api/runs/{id}/live/{*rest}", any(live))
.fallback(taktwerk_http::ui::serve)
.with_state(ws)
}
async fn info_handler(State(ws): State<Arc<Workspace>>) -> Json<Value> {
let name = ws
.root
.file_name()
.map(|n| n.to_string_lossy().into_owned());
Json(json!({
"kind": "workspace",
"taktwerk": env!("CARGO_PKG_VERSION"),
"root": ws.root.display().to_string(),
"name": name,
}))
}
async fn list_files(State(ws): State<Arc<Workspace>>) -> Json<Value> {
let root = ws.root.clone();
let found = tokio::task::spawn_blocking(move || files::scan(&root))
.await
.unwrap_or_default();
Json(json!(found))
}
#[derive(Deserialize)]
struct PathQuery {
path: String,
}
async fn read_file(
State(ws): State<Arc<Workspace>>,
Query(q): Query<PathQuery>,
) -> Result<Json<Value>, ApiError> {
let path = ws.file(&q.path)?;
let meta = std::fs::metadata(&path).map_err(|e| ApiError::not_found(e.to_string()))?;
if !meta.is_file() || meta.len() > MAX_FILE {
return Err(ApiError::bad(format!(
"`{}` is not a file of at most 1 MiB",
q.path
)));
}
let text = std::fs::read_to_string(&path).map_err(|e| ApiError::bad(e.to_string()))?;
Ok(Json(json!({ "path": q.path, "text": text })))
}
#[derive(Deserialize)]
struct ProjectQuery {
project: String,
}
async fn check(
State(ws): State<Arc<Workspace>>,
Query(q): Query<ProjectQuery>,
) -> Result<Json<Value>, ApiError> {
let path = ws.file(&q.project)?;
let out = tokio::process::Command::new(&ws.exe)
.arg("check")
.arg(&path)
.arg("--json")
.current_dir(&ws.root)
.output()
.await
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
serde_json::from_slice(&out.stdout).map(Json).map_err(|_| {
ApiError(
StatusCode::INTERNAL_SERVER_ERROR,
format!("check failed: {}", String::from_utf8_lossy(&out.stderr)),
)
})
}
async fn list_records(State(ws): State<Arc<Workspace>>) -> Json<Value> {
let root = ws.root.clone();
let list = tokio::task::spawn_blocking(move || records::list(&root))
.await
.unwrap_or_default();
Json(json!(list))
}
async fn record(
State(ws): State<Arc<Workspace>>,
Query(q): Query<PathQuery>,
) -> Result<Json<Value>, ApiError> {
let dir = ws.file(&q.path)?;
let run = taktwerk_record::read_run_file(&dir).map_err(|e| ApiError::bad(e.to_string()))?;
let events = taktwerk_record::read_events(&dir).map_err(|e| ApiError::bad(e.to_string()))?;
Ok(Json(
json!({ "path": q.path, "run": run, "events": events }),
))
}
#[derive(Deserialize)]
struct SeriesQuery {
path: String,
columns: String,
max_points: Option<usize>,
}
async fn series(
State(ws): State<Arc<Workspace>>,
Query(q): Query<SeriesQuery>,
) -> Result<Json<Value>, ApiError> {
let dir = ws.file(&q.path)?;
let names: Vec<String> = q
.columns
.split(',')
.map(str::trim)
.filter(|c| !c.is_empty())
.map(str::to_owned)
.collect();
let max = q.max_points.unwrap_or(DEFAULT_POINTS).clamp(4, 200_000);
let result = tokio::task::spawn_blocking(move || {
let run = taktwerk_record::read_run_file(&dir)?;
let cols: Vec<&str> = names.iter().map(String::as_str).collect();
let s = taktwerk_record::read_series(&dir, &run, &cols)?;
let rows = s.t_engine.len();
let s = records::decimate(s, max);
Ok::<_, taktwerk_record::ReadError>(json!({
"rows": rows,
"decimated": s.t_engine.len() < rows,
"t_engine": s.t_engine,
"cycle": s.cycle,
"columns": names,
"values": s.columns,
}))
})
.await
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
result.map(Json).map_err(|e| ApiError::bad(e.to_string()))
}
async fn campaigns(State(ws): State<Arc<Workspace>>) -> Json<Value> {
let root = ws.root.clone();
let list = tokio::task::spawn_blocking(move || records::campaigns(&root))
.await
.unwrap_or_default();
Json(json!(list))
}
async fn list_runs(State(ws): State<Arc<Workspace>>) -> Json<Value> {
Json(Value::Array(
ws.runs.all().iter().map(|r| r.to_json(false)).collect(),
))
}
async fn start_run(
State(ws): State<Arc<Workspace>>,
headers: HeaderMap,
Json(req): Json<StartRequest>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
ws.authorize(&headers)?;
let run = ws.runs.start(&req).map_err(ApiError::bad)?;
Ok((StatusCode::CREATED, Json(run.to_json(false))))
}
fn find(ws: &Workspace, id: u32) -> Result<Arc<runs::Launched>, ApiError> {
ws.runs
.get(id)
.ok_or_else(|| ApiError::not_found(format!("no run {id}")))
}
async fn run_detail(
State(ws): State<Arc<Workspace>>,
UrlPath(id): UrlPath<u32>,
) -> Result<Json<Value>, ApiError> {
Ok(Json(find(&ws, id)?.to_json(true)))
}
async fn stop_run(
State(ws): State<Arc<Workspace>>,
UrlPath(id): UrlPath<u32>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
ws.authorize(&headers)?;
let run = find(&ws, id)?;
let ws2 = Arc::clone(&ws);
tokio::spawn(async move { ws2.runs.stop(&run).await });
Ok(StatusCode::ACCEPTED)
}
async fn live(
State(ws): State<Arc<Workspace>>,
UrlPath((id, rest)): UrlPath<(u32, String)>,
req: Request,
) -> Result<Response, ApiError> {
let run = find(&ws, id)?;
let method = req.method().clone();
if method != Method::GET {
ws.authorize(req.headers())?;
}
let path = match req.uri().query() {
Some(q) => format!("{rest}?{q}"),
None => rest,
};
let body = Body::new(req.into_body());
Ok(run.proxy(method, &path, body).await)
}