M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile

Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned.
kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015).
kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to
the contract schema, error type for all 25 codes, auth extractor and protocol
layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal
to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:25:35 +02:00
parent c277ee37c2
commit 06f48ef9e9
71 changed files with 9187 additions and 0 deletions
@@ -0,0 +1,11 @@
//! `kvitt-server bootstrap` (FR-1, AC-1.1 to AC-1.3). M3 owns this file; until then it prints
//! `not implemented in M2a` and exits 2.
use std::process::ExitCode;
use crate::config::Config;
pub async fn run(config: &Config) -> ExitCode {
let _ = config;
super::not_implemented("bootstrap")
}
@@ -0,0 +1,59 @@
//! `kvitt-server healthcheck`: `GET /health` on this host, exit 0 if 200, else 1 (SPEC §10.4, AC-20.2). Plain
//! HTTP/1.1 over a TCP stream, so the image needs no HTTP client library or curl.
use std::net::{Ipv4Addr, SocketAddr};
use std::process::ExitCode;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use crate::config::Config;
const TIMEOUT: Duration = Duration::from_secs(5);
/// The address the check calls: `127.0.0.1` on the port of `KVITT_BIND` (8080 by default).
pub fn target(config: &Config) -> SocketAddr {
SocketAddr::from((Ipv4Addr::LOCALHOST, config.bind.port()))
}
pub async fn run(config: &Config) -> ExitCode {
match tokio::time::timeout(TIMEOUT, status_of_health(target(config))).await {
Ok(Ok(200)) => ExitCode::SUCCESS,
Ok(Ok(status)) => {
eprintln!("healthcheck: GET /health returned {status}");
ExitCode::FAILURE
}
Ok(Err(e)) => {
eprintln!("healthcheck: {e}");
ExitCode::FAILURE
}
Err(_) => {
eprintln!("healthcheck: timed out");
ExitCode::FAILURE
}
}
}
/// The HTTP status of `GET /health` at `addr`.
pub async fn status_of_health(addr: SocketAddr) -> std::io::Result<u16> {
let mut stream = TcpStream::connect(addr).await?;
let request = format!(
"GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\nUser-Agent: kvitt-healthcheck\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;
let mut head = Vec::with_capacity(64);
let mut buf = [0u8; 256];
while !head.contains(&b'\n') {
let n = stream.read(&mut buf).await?;
if n == 0 {
break;
}
head.extend_from_slice(&buf[..n]);
}
let line = String::from_utf8_lossy(&head);
line.split_whitespace()
.nth(1)
.and_then(|s| s.parse::<u16>().ok())
.ok_or_else(|| std::io::Error::other("malformed HTTP status line"))
}
+183
View File
@@ -0,0 +1,183 @@
//! `kvitt-server` subcommands (SPEC §10.4, exhaustive): `serve`, `migrate` and `healthcheck` are implemented here
//! by M2a; `bootstrap` and `pair-device` belong to M3, `rotate-epoch` to M4, and until then print
//! `not implemented in M2a` and exit 2.
pub mod bootstrap;
pub mod healthcheck;
pub mod pair_device;
pub mod rotate_epoch;
use std::process::ExitCode;
use std::time::Duration;
use clap::{Parser, Subcommand};
use tokio::net::TcpListener;
use tokio::sync::Notify;
use crate::config::Config;
use crate::error::AppError;
use crate::{AppState, db, router, tasks};
/// How long `serve` lets in-flight requests finish after SIGTERM (SPEC §10.4).
pub const SHUTDOWN_DRAIN: Duration = Duration::from_secs(10);
/// Exit code of a subcommand that is not implemented yet.
pub const EXIT_NOT_IMPLEMENTED: u8 = 2;
#[derive(Debug, Parser)]
#[command(
name = "kvitt-server",
version,
about = "Kvitt sync server (SPEC §10.4)"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Run migrations, start the background tasks and the HTTP server.
Serve,
/// Create the admin_setup invite (FR-1). Exit 0 ok, 2 already initialized.
Bootstrap,
/// Create a device invite for an existing user (AC-1.6). Exit 0 ok, 3 unknown user.
PairDevice {
/// The user's id.
#[arg(long = "user", value_name = "USER_ID")]
user: String,
},
/// Set a new random sync_epoch (SPEC §10.9).
RotateEpoch,
/// GET /health on this host; exit 0 if 200, 1 otherwise.
Healthcheck,
/// Run migrations only.
Migrate,
}
/// Runs a subcommand to completion and returns the process exit code.
pub async fn run(command: Command, config: Config) -> ExitCode {
let result = match command {
Command::Serve => serve(config).await,
Command::Migrate => migrate(config).await,
Command::Healthcheck => return healthcheck::run(&config).await,
Command::Bootstrap => return bootstrap::run(&config).await,
Command::PairDevice { user } => return pair_device::run(&config, &user).await,
Command::RotateEpoch => return rotate_epoch::run(&config).await,
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
tracing::error!(error_code = error.code.as_str(), detail = %error.message, "command failed");
eprintln!("{error}");
ExitCode::FAILURE
}
}
}
/// Prints the M2a placeholder message and returns exit code 2.
pub fn not_implemented(command: &str) -> ExitCode {
eprintln!("{command}: not implemented in M2a");
ExitCode::from(EXIT_NOT_IMPLEMENTED)
}
/// Opens (creating if needed) the data directory and the database, and runs the migrations.
pub async fn open_database(config: &Config) -> Result<db::Db, AppError> {
std::fs::create_dir_all(&config.data_dir).map_err(|e| {
AppError::internal(format!("cannot create {}: {e}", config.data_dir.display()))
})?;
let pool = db::connect(&config.database_path()).await?;
db::migrate(&pool).await?;
Ok(pool)
}
async fn migrate(config: Config) -> Result<(), AppError> {
let pool = open_database(&config).await?;
pool.close().await;
tracing::info!("migrations applied");
Ok(())
}
async fn serve(config: Config) -> Result<(), AppError> {
let pool = open_database(&config).await?;
if !config.fcm_credentials.exists() {
tracing::warn!(
path = %config.fcm_credentials.display(),
"FCM credentials file missing: push is disabled"
);
}
let bind = config.bind;
let state = AppState::new(config, pool.clone());
tasks::start(state.clone()).await?;
let listener = TcpListener::bind(bind)
.await
.map_err(|e| AppError::internal(format!("cannot bind {bind}: {e}")))?;
tracing::info!(%bind, version = crate::SERVER_VERSION, "listening");
let shutdown = std::sync::Arc::new(Notify::new());
let graceful = {
let shutdown = shutdown.clone();
async move { shutdown.notified().await }
};
let server = axum::serve(listener, router::router(state)).with_graceful_shutdown(graceful);
let mut server = tokio::spawn(async move { server.await });
tokio::select! {
result = &mut server => {
return finish(result, &pool).await;
}
() = shutdown_signal() => {}
}
tracing::info!(drain_secs = SHUTDOWN_DRAIN.as_secs(), "shutting down");
shutdown.notify_one();
match tokio::time::timeout(SHUTDOWN_DRAIN, &mut server).await {
Ok(result) => finish(result, &pool).await,
Err(_) => {
tracing::warn!("in-flight requests did not finish within the drain period");
server.abort();
pool.close().await;
Ok(())
}
}
}
async fn finish(
result: Result<std::io::Result<()>, tokio::task::JoinError>,
pool: &db::Db,
) -> Result<(), AppError> {
pool.close().await;
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(AppError::internal(format!("server error: {e}"))),
Err(e) => Err(AppError::internal(format!("server task failed: {e}"))),
}
}
/// Resolves on SIGTERM or Ctrl-C.
async fn shutdown_signal() {
let ctrl_c = async {
if let Err(e) = tokio::signal::ctrl_c().await {
tracing::warn!(detail = %e, "cannot listen for Ctrl-C");
std::future::pending::<()>().await;
}
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut signal) => {
signal.recv().await;
}
Err(e) => {
tracing::warn!(detail = %e, "cannot listen for SIGTERM");
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {}
() = terminate => {}
}
}
@@ -0,0 +1,11 @@
//! `kvitt-server pair-device --user <user_id>` (AC-1.6). M3 owns this file; until then it prints
//! `not implemented in M2a` and exits 2.
use std::process::ExitCode;
use crate::config::Config;
pub async fn run(config: &Config, user_id: &str) -> ExitCode {
let _ = (config, user_id);
super::not_implemented("pair-device")
}
@@ -0,0 +1,11 @@
//! `kvitt-server rotate-epoch` (SPEC §10.9). M4 owns this file; until then it prints `not implemented in M2a` and
//! exits 2.
use std::process::ExitCode;
use crate::config::Config;
pub async fn run(config: &Config) -> ExitCode {
let _ = config;
super::not_implemented("rotate-epoch")
}