Files
archify-vscode-ext-sync/crates/server/src/main.rs
T
root-at-skic 5c43b90ef8 Archify Sync server, Dockerfile and Helm chart
Rust server (axum, yrs, sqlx/PostgreSQL): live shared models over the y-websocket protocol, versions, browser sign-in with PKCE, invites, single sign-on through OIDC, metrics. Docker image on distroless, compose file, and a Helm chart with one pod, optional bundled PostgreSQL and secrets from existing Secrets.
2026-11-06 12:06:59 +02:00

177 lines
6.1 KiB
Rust

//! Archify Sync server: live, shared Archify models.
mod api;
mod auth;
mod config;
mod doc;
mod error;
mod hub;
mod metrics;
mod oidc;
mod pages;
mod state;
use std::time::Duration;
use anyhow::Result;
use archify_sync_store::Store;
use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, post};
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
use crate::config::Config;
use crate::state::AppState;
#[tokio::main]
async fn main() -> Result<()> {
// The image has no shell or curl; `archify-sync-server healthcheck` is its probe.
if std::env::args().nth(1).as_deref() == Some("healthcheck") {
return healthcheck().await;
}
// reqwest (single sign-on) and sqlx share rustls with the ring provider.
let _ = rustls::crypto::ring::default_provider().install_default();
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,tower_http=warn,sqlx=warn"));
if std::env::var("ARCHIFY_SYNC_LOG_JSON").is_ok_and(|v| v == "true") {
tracing_subscriber::fmt()
.with_env_filter(filter)
.json()
.init();
} else {
tracing_subscriber::fmt().with_env_filter(filter).init();
}
let config = Config::from_env()?;
let store = Store::connect(&config.database_url, config.database_max_connections).await?;
store.migrate().await?;
let listen = config.listen;
let state = AppState::new(config, store).await?;
let background = tokio::spawn(housekeeping(state.clone()));
if let Some(address) = state.config.metrics_listen {
let listener = tokio::net::TcpListener::bind(address).await?;
tracing::info!(listen = %address, "metrics listening");
let app = Router::new()
.route("/metrics", get(metrics::serve))
.with_state(state.clone());
tokio::spawn(async move { axum::serve(listener, app).await });
}
let listener = tokio::net::TcpListener::bind(listen).await?;
tracing::info!(%listen, version = env!("CARGO_PKG_VERSION"), "Archify Sync listening");
axum::serve(listener, router(state.clone()))
.with_graceful_shutdown(shutdown())
.await?;
background.abort();
state.hub.snapshot_all().await;
tracing::info!("stopped");
Ok(())
}
pub fn router(state: AppState) -> Router {
let v1 = Router::new()
.route("/info", get(api::info))
.route("/setup", post(api::setup))
.route("/auth/token", post(api::token))
.route("/auth/logout", post(api::logout))
.route("/me", get(api::me))
.route("/spaces", get(api::list_spaces).post(api::create_space))
.route("/spaces/{space}", get(api::get_space))
.route("/spaces/{space}/members", get(api::members))
.route(
"/spaces/{space}/members/{account}",
delete(api::remove_member),
)
.route("/spaces/{space}/invites", post(api::create_invite))
.route("/invites/{token}/accept", post(api::accept_invite))
.route(
"/spaces/{space}/versions",
get(api::versions).post(api::publish),
)
.route(
"/spaces/{space}/versions/{version}/files",
get(api::version_files),
)
.route(
"/spaces/{space}/versions/{version}/restore",
post(api::restore),
)
.route("/spaces/{space}/sync", get(api::sync));
Router::new()
.nest("/v1", v1)
.route("/healthz", get(api::healthz))
.route("/readyz", get(api::readyz))
.route("/setup", get(pages::setup_page).post(pages::setup_submit))
.route(
"/authorize",
get(pages::authorize_page).post(pages::authorize_submit),
)
.route("/join/{token}", get(pages::join_page))
.route("/oidc/start", get(pages::oidc_start))
.route("/oidc/callback", get(pages::oidc_callback))
.layer(DefaultBodyLimit::max(1024 * 1024))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
/// Idle snapshots every few seconds; expired sessions once an hour.
async fn housekeeping(state: AppState) {
let mut tick = tokio::time::interval(Duration::from_secs(5));
let mut n: u64 = 0;
loop {
tick.tick().await;
state.hub.snapshot_idle_rooms().await;
n += 1;
if n.is_multiple_of(720) {
match state.store.delete_expired_refresh().await {
Ok(removed) if removed > 0 => tracing::info!(removed, "expired sessions removed"),
Ok(_) => {}
Err(error) => tracing::warn!(%error, "removing expired sessions failed"),
}
let rooms = state.hub.open_rooms().await;
tracing::debug!(rooms, "open rooms");
}
}
}
/// Asks the running server's `/healthz` on this machine; exits non-zero if it does not answer.
async fn healthcheck() -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listen: std::net::SocketAddr = std::env::var("ARCHIFY_SYNC_LISTEN")
.unwrap_or_else(|_| "0.0.0.0:8080".into())
.parse()?;
let target = std::net::SocketAddr::from(([127, 0, 0, 1], listen.port()));
let reply = tokio::time::timeout(Duration::from_secs(3), async {
let mut stream = tokio::net::TcpStream::connect(target).await?;
stream
.write_all(b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await?;
let mut reply = String::new();
stream.read_to_string(&mut reply).await?;
anyhow::Ok(reply)
})
.await??;
anyhow::ensure!(reply.starts_with("HTTP/1.1 200"), "unhealthy");
Ok(())
}
async fn shutdown() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let term = async {
if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
s.recv().await;
}
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = term => {} }
tracing::info!("shutting down; storing snapshots");
}