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:
@@ -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"))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user