diff --git a/Cargo.lock b/Cargo.lock index 8b742d124..1972512c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4331,6 +4331,8 @@ dependencies = [ "tokio", "toml 1.1.2+spec-1.1.0", "tui-textarea", + "utoipa", + "uuid", "zip", ] @@ -5820,6 +5822,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap 2.13.1", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "uuid" version = "1.23.0" diff --git a/Cargo.toml b/Cargo.toml index e0d4d8483..4e4f58962 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,8 +37,6 @@ self-update = ["dep:self_update", "dep:semver"] tui = ["dep:ratatui", "dep:crossterm", "dep:tui-textarea"] webdav = [ "dep:dav-server", - "dep:axum", - "dep:tokio", "dep:bytes", "dep:futures", ] @@ -69,10 +67,14 @@ rustic_core = { version = "0.11.0", features = ["cli"] } jemallocator-global = { version = "0.3.2", optional = true } mimalloc = { version = "0.1.48", default-features = false, optional = true } +# uuid +uuid = { version = "1", features = ["v4"] } + # webdav -axum = { version = "0.8.8", optional = true } +axum = { version = "0.8.8" } dav-server = { version = "0.11.0", default-features = false, optional = true } -tokio = { version = "1", optional = true } +tokio = { version = "1" } +utoipa = { version = "5" } # tui crossterm = { version = "0.28", optional = true } diff --git a/docs/Readme.md b/docs/Readme.md index ab0c72411..c807f5f4f 100644 --- a/docs/Readme.md +++ b/docs/Readme.md @@ -1,3 +1,14 @@ # Documentation Our documentation can be found at: + +## Generate OpenAPI Schema + +You can generate the HTTP API OpenAPI schema directly from code and write it under +`docs/` with: + +```bash +cargo run -- serve --openapi-output docs/openapi.json +``` + +This command serializes `ApiDoc::openapi()` and exits without starting the server. diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 000000000..3b0c4fa2f --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,219 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "rustic-rs", + "description": "rustic - fast, encrypted, deduplicated backups powered by Rust\n", + "contact": { + "name": "the rustic-rs team" + }, + "license": { + "name": "Apache-2.0 OR MIT", + "identifier": "Apache-2.0 OR MIT" + }, + "version": "0.11.2" + }, + "paths": { + "/backup": { + "post": { + "tags": [ + "rustic-api" + ], + "summary": "Respond to /backup endpoint, used for starting backup jobs.", + "description": "Development note: test this with:\n\ncurl -i -X POST http://localhost:8080/backup -H \"Content-Type: application/json\" -d @tests/http-server/backup-request.json\n\n# Returns\n\nReturns BackupStartResponse in case of success", + "operationId": "start_backup", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupStartRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Backup job accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupStartResponse" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "409": { + "description": "A backup job is already running", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/backup/{job_id}": { + "get": { + "tags": [ + "rustic-api" + ], + "summary": "Get the status of a backup job.", + "description": "Development note: test this with:\n\ncurl -i -X GET http://localhost:8080/backup/", + "operationId": "get_backup_status", + "parameters": [ + { + "name": "job_id", + "in": "path", + "description": "Job identifier returned by POST /backup", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Job status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackupJobStatusResponse" + } + } + } + }, + "404": { + "description": "Job not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, + "/health": { + "get": { + "tags": [ + "rustic-api" + ], + "summary": "Respond to /health endpoint, used for health checks and testing connectivity to the API.", + "description": "Development note: test this with:\n\ncurl -i -X GET http://localhost:8080/health\n\n# Returns\n\nReturns \"ok\" if the API is running.", + "operationId": "health", + "responses": { + "200": { + "description": "API is running", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ApiErrorResponse": { + "type": "object", + "description": "API error payload.", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + }, + "BackupJobStatusResponse": { + "type": "object", + "description": "Response body for GET /backup/{job_id}.", + "required": [ + "job-id", + "status" + ], + "properties": { + "job-id": { + "type": "string", + "description": "The job identifier." + }, + "status": { + "$ref": "#/components/schemas/JobStatus", + "description": "Current status of the job." + } + } + }, + "BackupStartRequest": { + "type": "object", + "description": "Request payload for creating a backup job.", + "properties": { + "profile-name": { + "type": [ + "string", + "null" + ], + "description": "Optional profile name to define which sources to backup\nEquivalent to the --name CLI option of \"backup\" command." + } + } + }, + "BackupStartResponse": { + "type": "object", + "description": "API response returned when a backup job has been accepted.", + "required": [ + "job-id" + ], + "properties": { + "job-id": { + "type": "string", + "description": "Unique identifier for the backup job.\nPlease note that at this time rustic can only run one backup job at a time,\nso that there will be at most one active job_id.\nThis parameter allows the API to be extended in the future to support multiple concurrent jobs if needed." + } + } + }, + "JobStatus": { + "type": "string", + "description": "Status of a backup job.", + "enum": [ + "running", + "completed", + "failed" + ] + } + } + }, + "tags": [ + { + "name": "rustic-api", + "description": "Rustic HTTP API skeleton" + } + ] +} \ No newline at end of file diff --git a/src/commands.rs b/src/commands.rs index 265e27559..b4682f3ff 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -23,6 +23,7 @@ pub(crate) mod repair; pub(crate) mod repoinfo; pub(crate) mod restore; pub(crate) mod rewrite; +pub(crate) mod serve; pub(crate) mod self_update; pub(crate) mod show_config; pub(crate) mod snapshots; @@ -39,6 +40,7 @@ use std::sync::mpsc::channel; #[cfg(feature = "mount")] use crate::commands::mount::MountCmd; +use crate::commands::serve::ServeCmd; #[cfg(feature = "webdav")] use crate::commands::webdav::WebDavCmd; use crate::{ @@ -144,6 +146,9 @@ enum RusticCmd { /// Rewrite existing snapshot(s) Rewrite(Box), + /// Start the rustic HTTP API server + Serve(Box), + /// Repair a snapshot or the repository index Repair(Box), @@ -296,6 +301,7 @@ impl Configurable for EntryPoint { RusticCmd::Webdav(cmd) => cmd.override_config(config), #[cfg(feature = "mount")] RusticCmd::Mount(cmd) => cmd.override_config(config), + RusticCmd::Serve(cmd) => cmd.override_config(config), // subcommands that don't need special overrides use a catch all _ => Ok(config), diff --git a/src/commands/serve/api.rs b/src/commands/serve/api.rs new file mode 100644 index 000000000..6635eaaa4 --- /dev/null +++ b/src/commands/serve/api.rs @@ -0,0 +1,257 @@ +//! HTTP API skeleton for `serve` command. +//! +//! This module provides a utoipa-documented endpoint to trigger backups via HTTP. +//! The endpoint accepts a TOML profile payload so all options supported by file-based +//! configuration are also supported through the API. + +use std::{ + collections::HashMap, + fs, + net::SocketAddr, + sync::{Arc, Mutex}, +}; + +use anyhow::{Context, Result}; +use axum::{ + Json, Router, + extract::{Path as AxumPath, State}, + http::StatusCode, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; +use utoipa::{OpenApi, ToSchema}; +use uuid::Uuid; + +/// Status of a backup job. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum JobStatus { + /// The job is currently running. + Running, + /// The job completed successfully. + Completed, + /// The job terminated with an error. + Failed, +} + +/// Shared state for API handlers. +#[derive(Clone, Debug)] +pub struct ApiState { + /// In-memory map of job_id -> status for all submitted backup jobs. + pub jobs: Arc>>, +} + +impl Default for ApiState { + fn default() -> Self { + Self { + jobs: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +/// API response returned when a backup job has been accepted. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub struct BackupStartResponse { + /// Unique identifier for the backup job. + /// Please note that at this time rustic can only run one backup job at a time, + /// so that there will be at most one active job_id. + /// This parameter allows the API to be extended in the future to support multiple concurrent jobs if needed. + pub job_id: String, +} + +/// API error payload. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub struct ApiErrorResponse { + pub message: String, +} + +/// Request payload for creating a backup job. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub struct BackupStartRequest { + /// Optional profile name to define which sources to backup + /// Equivalent to the --name CLI option of "backup" command. + pub profile_name: Option, +} + +/// Response body for GET /backup/{job_id}. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub struct BackupJobStatusResponse { + /// The job identifier. + pub job_id: String, + /// Current status of the job. + pub status: JobStatus, +} + +#[derive(OpenApi)] +#[openapi( + paths(health, start_backup, get_backup_status), + components(schemas(BackupStartRequest, BackupStartResponse, BackupJobStatusResponse, JobStatus, ApiErrorResponse)), + tags( + (name = "rustic-api", description = "Rustic HTTP API skeleton") + ) +)] +pub struct ApiDoc; + +/// Write the generated OpenAPI schema to the given file path. +pub fn write_openapi_schema(path: &std::path::Path) -> Result<()> { + let schema = ApiDoc::openapi(); + let json = serde_json::to_string_pretty(&schema).context("serializing OpenAPI schema")?; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating schema output directory {}", parent.display()))?; + } + + fs::write(path, json) + .with_context(|| format!("writing OpenAPI schema to {}", path.display()))?; + + Ok(()) +} + +/// Build the HTTP router for the serve API. +pub fn router(state: ApiState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/backup", post(start_backup)) + .route("/backup/{job_id}", get(get_backup_status)) + .with_state(state) +} + +/// Start serving the HTTP API skeleton. +pub async fn serve(addr: SocketAddr, state: ApiState) -> Result<()> { + let app = router(state); + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("binding API socket on {addr}"))?; + axum::serve(listener, app) + .await + .context("serving HTTP API")?; + Ok(()) +} + + +/// Respond to /health endpoint, used for health checks and testing connectivity to the API. +/// +/// Development note: test this with: +/// +/// curl -i -X GET http://localhost:8080/health +/// +/// # Returns +/// +/// Returns "ok" if the API is running. +#[utoipa::path( + get, + path = "/health", + tag = "rustic-api", + responses( + (status = 200, description = "API is running", body = String) + ) +)] +async fn health() -> &'static str { + "ok" +} + +/// Respond to /backup endpoint, used for starting backup jobs. +/// +/// Development note: test this with: +/// +/// curl -i -X POST http://localhost:8080/backup -H "Content-Type: application/json" -d @tests/http-server/backup-request.json +/// +/// # Returns +/// +/// Returns BackupStartResponse in case of success +#[utoipa::path( + post, + path = "/backup", + tag = "rustic-api", + request_body = BackupStartRequest, + responses( + (status = 202, description = "Backup job accepted", body = BackupStartResponse), + (status = 400, description = "Invalid request", body = ApiErrorResponse), + (status = 409, description = "A backup job is already running", body = ApiErrorResponse), + (status = 500, description = "Internal error", body = ApiErrorResponse) + ) +)] +async fn start_backup( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + // TODO: kick off a new backup job here. + // Note that we just need to start the backup job asynchronously and return + // a job_id immediately, without waiting for the backup to complete. + let _ = req; + + let mut jobs = state + .jobs + .lock() + .unwrap_or_else(|e| e.into_inner()); + + // Enforce single-job constraint: reject if any job is still Running. + if let Some(active_id) = jobs + .iter() + .find_map(|(id, s)| (*s == JobStatus::Running).then(|| id.clone())) + { + return Err(api_error( + StatusCode::CONFLICT, + &format!("backup job '{active_id}' is already running"), + )); + } + + let job_id = Uuid::new_v4().to_string(); + let _ = jobs.insert(job_id.clone(), JobStatus::Running); + + Ok(( + StatusCode::ACCEPTED, + Json(BackupStartResponse { job_id }), + )) +} + +/// Get the status of a backup job. +/// +/// Development note: test this with: +/// +/// curl -i -X GET http://localhost:8080/backup/ +#[utoipa::path( + get, + path = "/backup/{job_id}", + tag = "rustic-api", + params( + ("job_id" = String, Path, description = "Job identifier returned by POST /backup") + ), + responses( + (status = 200, description = "Job status", body = BackupJobStatusResponse), + (status = 404, description = "Job not found", body = ApiErrorResponse) + ) +)] +async fn get_backup_status( + State(state): State, + AxumPath(job_id): AxumPath, +) -> Result, (StatusCode, Json)> { + let jobs = state + .jobs + .lock() + .unwrap_or_else(|e| e.into_inner()); + match jobs.get(&job_id) { + Some(status) => Ok(Json(BackupJobStatusResponse { + job_id, + status: status.clone(), + })), + None => Err(api_error( + StatusCode::NOT_FOUND, + &format!("job '{job_id}' not found"), + )), + } +} + +fn api_error(status: StatusCode, message: &str) -> (StatusCode, Json) { + ( + status, + Json(ApiErrorResponse { + message: message.to_string(), + }), + ) +} diff --git a/src/commands/serve/mod.rs b/src/commands/serve/mod.rs new file mode 100644 index 000000000..d12ad31cd --- /dev/null +++ b/src/commands/serve/mod.rs @@ -0,0 +1,78 @@ +//! `serve` subcommand + +pub(crate) mod api; + +use std::{net::ToSocketAddrs, path::PathBuf}; + +use crate::{Application, RUSTIC_APP, RusticConfig, status_err}; + +use abscissa_core::{Command, FrameworkError, Runnable, Shutdown, config::Override}; +use anyhow::{Result, anyhow}; +use conflate::Merge; +use log::info; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)] +#[serde(default, rename_all = "kebab-case", deny_unknown_fields)] +pub struct ServeCmd { + /// Address to bind the HTTP API server to. [default: "localhost:8080"] + #[clap(long, value_name = "ADDRESS")] + #[merge(strategy=conflate::option::overwrite_none)] + address: Option, + + /// Generate OpenAPI schema to this file and exit, e.g. docs/openapi.json. + #[clap(long, value_name = "PATH")] + #[merge(strategy=conflate::option::overwrite_none)] + openapi_output: Option, +} + +impl Override for ServeCmd { + fn override_config(&self, mut config: RusticConfig) -> Result { + let mut self_config = self.clone(); + self_config.merge(config.serve); + config.serve = self_config; + Ok(config) + } +} + +impl Runnable for ServeCmd { + fn run(&self) { + if let Err(err) = self.inner_run() { + status_err!("{}", err); + RUSTIC_APP.shutdown(Shutdown::Crash); + } + } +} + +impl ServeCmd { + fn inner_run(&self) -> Result<()> { + if let Some(path) = &self.openapi_output { + api::write_openapi_schema(path)?; + info!("OpenAPI schema written to {}", path.display()); + return Ok(()); + } + + let config = RUSTIC_APP.config(); + + let addr = config + .serve + .address + .clone() + .unwrap_or_else(|| "localhost:8080".to_string()) + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow!("no address given"))?; + + info!("serving HTTP API on {addr}"); + + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async { + let state = api::ApiState::default(); + api::serve(addr, state).await + })?; + + Ok(()) + } +} diff --git a/src/config.rs b/src/config.rs index 5fdf34bde..6ec9d4b7a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -31,6 +31,7 @@ use toml::Value; #[cfg(feature = "mount")] use crate::commands::mount::MountCmd; +use crate::commands::serve::ServeCmd; #[cfg(feature = "webdav")] use crate::commands::webdav::WebDavCmd; @@ -83,6 +84,10 @@ pub struct RusticConfig { #[merge(skip)] pub mount: Option, + /// serve options + #[clap(skip)] + pub serve: ServeCmd, + /// webdav options #[cfg(feature = "webdav")] #[clap(skip)] diff --git a/test b/test deleted file mode 100644 index e93dfd32b..000000000 --- a/test +++ /dev/null @@ -1 +0,0 @@ -test-output diff --git a/tests/http-server/backup-request.json b/tests/http-server/backup-request.json new file mode 100644 index 000000000..d25737ac4 --- /dev/null +++ b/tests/http-server/backup-request.json @@ -0,0 +1,3 @@ +{ +"profile-name": "test" +}