WP-02: encrypted settings, SMTP mail, job runner and cron scheduler
AES-256-GCM secret storage keyed by MASTER_KEY, SMTP settings with test mail (lettre), persisted job runs with log and status, JobRunner with per-kind concurrency guard, 6-field cron schedules with defaults, scheduler loop. Settings and Jobs pages in the UI. FAKE_HOST mode for dev machines. Tests: 30 application, 8 infrastructure, 23 API, 16 Vitest, 6 Playwright. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
254
backend/Cargo.lock
generated
254
backend/Cargo.lock
generated
@ -2,6 +2,41 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
@ -38,6 +73,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"application",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"domain",
|
||||
@ -61,7 +97,7 @@ name = "application"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"cron",
|
||||
"domain",
|
||||
@ -186,6 +222,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@ -267,6 +309,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
@ -336,9 +388,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@ -405,6 +467,22 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@ -442,6 +520,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
@ -579,6 +663,16 @@ dependencies = [
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@ -644,6 +738,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@ -874,18 +979,30 @@ dependencies = [
|
||||
name = "infrastructure"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"domain",
|
||||
"jsonwebtoken",
|
||||
"lettre",
|
||||
"serde",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@ -909,7 +1026,7 @@ version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
@ -927,6 +1044,34 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hostname",
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"nom",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"url",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
@ -1041,6 +1186,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@ -1117,6 +1271,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@ -1163,7 +1323,7 @@ version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
@ -1221,6 +1381,18 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
@ -1263,6 +1435,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
@ -1380,6 +1558,41 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@ -1596,7 +1809,7 @@ version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
@ -1671,7 +1884,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@ -1715,7 +1928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
@ -1947,6 +2160,16 @@ dependencies = [
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
@ -2126,6 +2349,16 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@ -2262,6 +2495,15 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.6.1"
|
||||
|
||||
@ -13,6 +13,7 @@ domain.workspace = true
|
||||
application.workspace = true
|
||||
infrastructure.workspace = true
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
dotenvy.workspace = true
|
||||
|
||||
@ -5,6 +5,10 @@ use std::net::SocketAddr;
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub jwt_secret: String,
|
||||
/// 64 hex chars; encrypts secrets at rest.
|
||||
pub master_key: String,
|
||||
/// Use fake host adapters (dev machines without apt/kubectl).
|
||||
pub fake_host: bool,
|
||||
pub bind: SocketAddr,
|
||||
pub bootstrap_admin: Option<(String, String)>,
|
||||
pub cookie_secure: bool,
|
||||
@ -20,10 +24,14 @@ impl Config {
|
||||
jwt_secret.len() >= 32,
|
||||
"JWT_SECRET must be at least 32 characters"
|
||||
);
|
||||
let master_key = env("MASTER_KEY")
|
||||
.ok_or_else(|| anyhow::anyhow!("MASTER_KEY is required (64 hex characters)"))?;
|
||||
Ok(Self {
|
||||
database_url: env("DATABASE_URL")
|
||||
.unwrap_or_else(|| "sqlite://data/monitoring.db?mode=rwc".into()),
|
||||
jwt_secret,
|
||||
master_key,
|
||||
fake_host: env("FAKE_HOST").is_some_and(|v| v == "true" || v == "1"),
|
||||
bind: env("BIND")
|
||||
.unwrap_or_else(|| "127.0.0.1:8080".into())
|
||||
.parse()?,
|
||||
|
||||
@ -24,6 +24,8 @@ impl IntoResponse for ApiError {
|
||||
InvalidToken => (StatusCode::UNAUTHORIZED, "invalid_token"),
|
||||
InactiveUser => (StatusCode::FORBIDDEN, "inactive_user"),
|
||||
Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation"),
|
||||
Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
Unavailable(_) => (StatusCode::BAD_GATEWAY, "unavailable"),
|
||||
Storage(msg) => {
|
||||
tracing::error!("storage error: {msg}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
|
||||
|
||||
103
backend/crates/api/src/jobs.rs
Normal file
103
backend/crates/api/src/jobs.rs
Normal file
@ -0,0 +1,103 @@
|
||||
//! /api/jobs: run history and manual job runs.
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use domain::jobs::{JobKind, JobRun, JobStatus};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extract::{AdminUser, AuthUser};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list))
|
||||
.route("/kinds", get(kinds))
|
||||
.route("/run", post(run))
|
||||
.route("/{id}", get(get_one))
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct JobRunDto {
|
||||
pub id: Uuid,
|
||||
#[schema(value_type = String)]
|
||||
pub kind: JobKind,
|
||||
pub params: Option<String>,
|
||||
#[schema(value_type = String)]
|
||||
pub status: JobStatus,
|
||||
pub started_at: chrono::DateTime<chrono::Utc>,
|
||||
pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub log: String,
|
||||
pub triggered_by: String,
|
||||
}
|
||||
|
||||
impl From<JobRun> for JobRunDto {
|
||||
fn from(r: JobRun) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
kind: r.kind,
|
||||
params: r.params,
|
||||
status: r.status,
|
||||
started_at: r.started_at,
|
||||
finished_at: r.finished_at,
|
||||
log: r.log,
|
||||
triggered_by: r.triggered_by,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQuery {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct RunRequest {
|
||||
#[schema(value_type = String, example = "package_refresh")]
|
||||
pub kind: JobKind,
|
||||
pub params: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/jobs", tag = "jobs", security(("bearer" = [])), responses((status = 200, body = Vec<JobRunDto>)))]
|
||||
async fn list(
|
||||
State(state): State<AppState>,
|
||||
_: AuthUser,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<Vec<JobRunDto>>, ApiError> {
|
||||
Ok(Json(
|
||||
state
|
||||
.jobs
|
||||
.list(q.limit.unwrap_or(50).min(500))
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/jobs/kinds", tag = "jobs", security(("bearer" = [])), responses((status = 200, body = Vec<String>)))]
|
||||
async fn kinds(State(state): State<AppState>, _: AuthUser) -> Json<Vec<JobKind>> {
|
||||
Json(state.jobs.kinds())
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/jobs/{id}", tag = "jobs", security(("bearer" = [])), responses((status = 200, body = JobRunDto), (status = 404)))]
|
||||
async fn get_one(
|
||||
State(state): State<AppState>,
|
||||
_: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<JobRunDto>, ApiError> {
|
||||
Ok(Json(state.jobs.get(id).await?.into()))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/jobs/run", tag = "jobs", security(("bearer" = [])), request_body = RunRequest, responses((status = 202, body = JobRunDto), (status = 404), (status = 409)))]
|
||||
async fn run(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(admin): AdminUser,
|
||||
Json(req): Json<RunRequest>,
|
||||
) -> Result<(StatusCode, Json<JobRunDto>), ApiError> {
|
||||
let run = state.jobs.start(req.kind, req.params, &admin.email).await?;
|
||||
Ok((StatusCode::ACCEPTED, Json(run.into())))
|
||||
}
|
||||
@ -3,17 +3,22 @@ pub mod auth;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
pub mod jobs;
|
||||
pub mod openapi;
|
||||
pub mod rate_limit;
|
||||
pub mod settings;
|
||||
pub mod test_support;
|
||||
pub mod users;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{AuthService, UserService};
|
||||
use application::scheduler::Scheduler;
|
||||
use application::{AuthService, JobRunner, SettingsService, UserService};
|
||||
use axum::{routing::get, Json, Router};
|
||||
use domain::ports::Mailer;
|
||||
use infrastructure::{
|
||||
Argon2Hasher, DbPool, JwtIssuer, SqliteAuditLog, SqliteRefreshTokens, SqliteUsers,
|
||||
AesGcmCipher, Argon2Hasher, DbPool, JwtIssuer, LettreMailer, SqliteAuditLog, SqliteJobRuns,
|
||||
SqliteRefreshTokens, SqliteSettings, SqliteUsers,
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
@ -25,30 +30,63 @@ pub struct AppState {
|
||||
pub cfg: Config,
|
||||
pub auth: Arc<AuthService>,
|
||||
pub users: Arc<UserService>,
|
||||
pub settings: Arc<SettingsService>,
|
||||
pub jobs: Arc<JobRunner>,
|
||||
pub login_limiter: Arc<rate_limit::RateLimiter>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Wire the services on top of a connected database.
|
||||
pub fn new(cfg: Config, pool: DbPool) -> Self {
|
||||
pub fn new(cfg: Config, pool: DbPool) -> anyhow::Result<Self> {
|
||||
let fake = cfg.fake_host;
|
||||
Self::with_adapters(cfg, pool, Arc::new(LettreMailer), move |r| {
|
||||
if fake {
|
||||
test_support::register_test_jobs(r)
|
||||
} else {
|
||||
r
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Wiring with replaceable adapters (used by tests and the fake-host mode).
|
||||
pub fn with_adapters(
|
||||
cfg: Config,
|
||||
pool: DbPool,
|
||||
mailer: Arc<dyn Mailer>,
|
||||
register_jobs: impl FnOnce(JobRunner) -> JobRunner,
|
||||
) -> anyhow::Result<Self> {
|
||||
let users = Arc::new(SqliteUsers(pool.clone()));
|
||||
let hasher = Arc::new(Argon2Hasher);
|
||||
let auth = AuthService::new(
|
||||
users.clone(),
|
||||
Arc::new(SqliteRefreshTokens(pool.clone())),
|
||||
Arc::new(SqliteAuditLog(pool)),
|
||||
Arc::new(SqliteAuditLog(pool.clone())),
|
||||
hasher.clone(),
|
||||
Arc::new(JwtIssuer::new(&cfg.jwt_secret)),
|
||||
);
|
||||
Self {
|
||||
let cipher = Arc::new(AesGcmCipher::from_hex(&cfg.master_key)?);
|
||||
let settings = Arc::new(SettingsService::new(
|
||||
Arc::new(SqliteSettings(pool.clone())),
|
||||
cipher,
|
||||
mailer,
|
||||
));
|
||||
let jobs = Arc::new(register_jobs(JobRunner::new(Arc::new(SqliteJobRuns(pool)))));
|
||||
Ok(Self {
|
||||
cfg,
|
||||
auth: Arc::new(auth),
|
||||
users: Arc::new(UserService::new(users, hasher)),
|
||||
settings,
|
||||
jobs,
|
||||
login_limiter: Arc::new(rate_limit::RateLimiter::new(
|
||||
10,
|
||||
std::time::Duration::from_secs(60),
|
||||
)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the cron scheduler on the current runtime.
|
||||
pub fn start_scheduler(&self) {
|
||||
tokio::spawn(Scheduler::new(self.jobs.clone(), self.settings.clone()).run());
|
||||
}
|
||||
|
||||
pub async fn bootstrap(&self) -> anyhow::Result<()> {
|
||||
@ -69,6 +107,8 @@ pub fn build_app(state: AppState) -> Router {
|
||||
.route("/api/openapi.json", get(openapi::spec))
|
||||
.nest("/api/auth", auth::router())
|
||||
.nest("/api/users", users::router())
|
||||
.nest("/api/settings", settings::router())
|
||||
.nest("/api/jobs", jobs::router())
|
||||
.fallback_service(spa)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
|
||||
@ -17,8 +17,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let pool = infrastructure::connect(&cfg.database_url).await?;
|
||||
let state = AppState::new(cfg.clone(), pool);
|
||||
let state = AppState::new(cfg.clone(), pool)?;
|
||||
state.bootstrap().await?;
|
||||
state.start_scheduler();
|
||||
let listener = tokio::net::TcpListener::bind(cfg.bind).await?;
|
||||
tracing::info!("listening on http://{}", cfg.bind);
|
||||
let app = build_app(state).into_make_service_with_connect_info::<std::net::SocketAddr>();
|
||||
|
||||
@ -24,6 +24,8 @@ impl Modify for BearerAuth {
|
||||
paths(
|
||||
crate::auth::login, crate::auth::refresh, crate::auth::logout, crate::auth::me,
|
||||
crate::users::list, crate::users::create, crate::users::get_one, crate::users::update, crate::users::reset_password,
|
||||
crate::settings::get_smtp, crate::settings::put_smtp, crate::settings::test_smtp, crate::settings::list_schedules, crate::settings::put_schedule,
|
||||
crate::jobs::list, crate::jobs::kinds, crate::jobs::get_one, crate::jobs::run,
|
||||
),
|
||||
modifiers(&BearerAuth)
|
||||
)]
|
||||
|
||||
178
backend/crates/api/src/settings.rs
Normal file
178
backend/crates/api/src/settings.rs
Normal file
@ -0,0 +1,178 @@
|
||||
//! /api/settings: SMTP configuration and job schedules.
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use domain::jobs::JobKind;
|
||||
use domain::settings::{SmtpSecurity, SmtpSettings};
|
||||
use domain::DomainError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::extract::{AdminUser, AuthUser};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/smtp", get(get_smtp).put(put_smtp))
|
||||
.route("/smtp/test", post(test_smtp))
|
||||
.route("/schedules", get(list_schedules))
|
||||
.route("/schedules/{kind}", put(put_schedule))
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SmtpView {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
#[schema(value_type = String, example = "starttls")]
|
||||
pub security: SmtpSecurity,
|
||||
pub username: String,
|
||||
pub from: String,
|
||||
pub notify_to: Vec<String>,
|
||||
pub password_set: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SmtpResponse {
|
||||
pub configured: bool,
|
||||
pub smtp: Option<SmtpView>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SmtpRequest {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
#[schema(value_type = String, example = "starttls")]
|
||||
pub security: SmtpSecurity,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
/// Empty keeps the currently stored password.
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
pub from: String,
|
||||
#[serde(default)]
|
||||
pub notify_to: Vec<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/settings/smtp", tag = "settings", security(("bearer" = [])), responses((status = 200, body = SmtpResponse)))]
|
||||
async fn get_smtp(
|
||||
State(state): State<AppState>,
|
||||
_: AuthUser,
|
||||
) -> Result<Json<SmtpResponse>, ApiError> {
|
||||
let smtp = state.settings.smtp().await?.map(|s| SmtpView {
|
||||
host: s.host,
|
||||
port: s.port,
|
||||
security: s.security,
|
||||
username: s.username,
|
||||
from: s.from,
|
||||
notify_to: s.notify_to,
|
||||
password_set: !s.password.is_empty(),
|
||||
});
|
||||
Ok(Json(SmtpResponse {
|
||||
configured: smtp.is_some(),
|
||||
smtp,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(put, path = "/api/settings/smtp", tag = "settings", security(("bearer" = [])), request_body = SmtpRequest, responses((status = 204), (status = 422)))]
|
||||
async fn put_smtp(
|
||||
State(state): State<AppState>,
|
||||
_: AdminUser,
|
||||
Json(req): Json<SmtpRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let password = if req.password.is_empty() {
|
||||
state
|
||||
.settings
|
||||
.smtp()
|
||||
.await?
|
||||
.map(|s| s.password)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
req.password
|
||||
};
|
||||
state
|
||||
.settings
|
||||
.set_smtp(SmtpSettings {
|
||||
host: req.host.trim().into(),
|
||||
port: req.port,
|
||||
security: req.security,
|
||||
username: req.username,
|
||||
password,
|
||||
from: req.from.trim().into(),
|
||||
notify_to: req
|
||||
.notify_to
|
||||
.into_iter()
|
||||
.map(|a| a.trim().to_string())
|
||||
.filter(|a| !a.is_empty())
|
||||
.collect(),
|
||||
})
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct TestMailRequest {
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/settings/smtp/test", tag = "settings", security(("bearer" = [])), request_body = TestMailRequest, responses((status = 204), (status = 422), (status = 502)))]
|
||||
async fn test_smtp(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(admin): AdminUser,
|
||||
Json(req): Json<TestMailRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let body = format!(
|
||||
"This is a test mail from SoftVisor Monitoring, requested by {}.",
|
||||
admin.email
|
||||
);
|
||||
state
|
||||
.settings
|
||||
.send_mail(Some(vec![req.to]), "SoftVisor Monitoring test mail", &body)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ScheduleView {
|
||||
#[schema(value_type = String, example = "package_refresh")]
|
||||
pub kind: JobKind,
|
||||
pub cron: Option<String>,
|
||||
pub default_cron: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ScheduleRequest {
|
||||
pub cron: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/settings/schedules", tag = "settings", security(("bearer" = [])), responses((status = 200, body = Vec<ScheduleView>)))]
|
||||
async fn list_schedules(
|
||||
State(state): State<AppState>,
|
||||
_: AuthUser,
|
||||
) -> Result<Json<Vec<ScheduleView>>, ApiError> {
|
||||
let mut out = Vec::new();
|
||||
for kind in JobKind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| k.default_schedule().is_some())
|
||||
{
|
||||
out.push(ScheduleView {
|
||||
kind,
|
||||
cron: state.settings.schedule(kind).await?,
|
||||
default_cron: kind.default_schedule().map(String::from),
|
||||
});
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
#[utoipa::path(put, path = "/api/settings/schedules/{kind}", tag = "settings", security(("bearer" = [])), request_body = ScheduleRequest, responses((status = 204), (status = 404), (status = 422)))]
|
||||
async fn put_schedule(
|
||||
State(state): State<AppState>,
|
||||
_: AdminUser,
|
||||
Path(kind): Path<String>,
|
||||
Json(req): Json<ScheduleRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let kind = JobKind::parse(&kind).ok_or(DomainError::NotFound)?;
|
||||
state.settings.set_schedule(kind, req.cron).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@ -1,11 +1,22 @@
|
||||
//! Helpers to build a fully wired app on an in-memory database for integration tests.
|
||||
use crate::{build_app, AppState, Config};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::{JobHandler, JobLog, JobRunner};
|
||||
use async_trait::async_trait;
|
||||
use axum::Router;
|
||||
use domain::jobs::JobKind;
|
||||
use domain::ports::Mailer;
|
||||
use domain::settings::SmtpSettings;
|
||||
use domain::DomainError;
|
||||
|
||||
use crate::{build_app, AppState, Config};
|
||||
|
||||
pub fn test_config() -> Config {
|
||||
Config {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
jwt_secret: "test-secret-test-secret-test-secret-1234".into(),
|
||||
master_key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".into(),
|
||||
fake_host: true,
|
||||
bind: "127.0.0.1:0".parse().unwrap(),
|
||||
bootstrap_admin: None,
|
||||
cookie_secure: false,
|
||||
@ -13,6 +24,45 @@ pub fn test_config() -> Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mails "sent" by all test apps of this process.
|
||||
static SENT: Mutex<Vec<(Vec<String>, String, String)>> = Mutex::new(Vec::new());
|
||||
|
||||
pub struct RecordingMailer;
|
||||
|
||||
#[async_trait]
|
||||
impl Mailer for RecordingMailer {
|
||||
async fn send(
|
||||
&self,
|
||||
_: &SmtpSettings,
|
||||
to: &[String],
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
SENT.lock()
|
||||
.unwrap()
|
||||
.push((to.to_vec(), subject.into(), body.into()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sent_mails(_app: &Router) -> Vec<(Vec<String>, String, String)> {
|
||||
SENT.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub struct TestJob;
|
||||
|
||||
#[async_trait]
|
||||
impl JobHandler for TestJob {
|
||||
async fn run(&self, _params: Option<String>, log: &dyn JobLog) -> Result<(), String> {
|
||||
log.line("test handler ran").await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_test_jobs(runner: JobRunner) -> JobRunner {
|
||||
runner.register(JobKind::PackageRefresh, Arc::new(TestJob))
|
||||
}
|
||||
|
||||
pub async fn build_test_app() -> Router {
|
||||
build_test_app_with(test_config()).await
|
||||
}
|
||||
@ -29,7 +79,8 @@ async fn build_test_app_with(cfg: Config) -> Router {
|
||||
let pool = infrastructure::connect(&cfg.database_url)
|
||||
.await
|
||||
.expect("db");
|
||||
let state = AppState::new(cfg, pool);
|
||||
let state = AppState::with_adapters(cfg, pool, Arc::new(RecordingMailer), register_test_jobs)
|
||||
.expect("state");
|
||||
state.bootstrap().await.expect("bootstrap");
|
||||
build_app(state)
|
||||
}
|
||||
|
||||
@ -3,7 +3,8 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::jobs::{JobKind, JobRun};
|
||||
use chrono::Utc;
|
||||
use domain::jobs::{JobKind, JobRun, JobStatus};
|
||||
use domain::ports::JobRunRepository;
|
||||
use domain::DomainError;
|
||||
use uuid::Uuid;
|
||||
@ -24,6 +25,24 @@ pub struct JobRunner {
|
||||
handlers: HashMap<JobKind, Arc<dyn JobHandler>>,
|
||||
}
|
||||
|
||||
struct RepoLog {
|
||||
runs: Arc<dyn JobRunRepository>,
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl JobLog for RepoLog {
|
||||
async fn line(&self, text: &str) {
|
||||
if let Err(e) = self.runs.append_log(self.id, text).await {
|
||||
tracing_line(&format!("failed to append job log: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tracing_line(msg: &str) {
|
||||
eprintln!("{msg}");
|
||||
}
|
||||
|
||||
impl JobRunner {
|
||||
pub fn new(runs: Arc<dyn JobRunRepository>) -> Self {
|
||||
Self {
|
||||
@ -43,31 +62,88 @@ impl JobRunner {
|
||||
k
|
||||
}
|
||||
|
||||
async fn begin(
|
||||
&self,
|
||||
kind: JobKind,
|
||||
params: Option<String>,
|
||||
triggered_by: &str,
|
||||
) -> Result<(JobRun, Arc<dyn JobHandler>), DomainError> {
|
||||
let handler = self
|
||||
.handlers
|
||||
.get(&kind)
|
||||
.cloned()
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
if self.runs.find_running(kind).await?.is_some() {
|
||||
return Err(DomainError::Conflict(format!(
|
||||
"{} is already running",
|
||||
kind.as_str()
|
||||
)));
|
||||
}
|
||||
let run = JobRun {
|
||||
id: Uuid::new_v4(),
|
||||
kind,
|
||||
params,
|
||||
status: JobStatus::Running,
|
||||
started_at: Utc::now(),
|
||||
finished_at: None,
|
||||
log: String::new(),
|
||||
triggered_by: triggered_by.into(),
|
||||
};
|
||||
self.runs.insert(&run).await?;
|
||||
Ok((run, handler))
|
||||
}
|
||||
|
||||
async fn execute(runs: Arc<dyn JobRunRepository>, handler: Arc<dyn JobHandler>, run: &JobRun) {
|
||||
let log = RepoLog {
|
||||
runs: runs.clone(),
|
||||
id: run.id,
|
||||
};
|
||||
let status = match handler.run(run.params.clone(), &log).await {
|
||||
Ok(()) => JobStatus::Success,
|
||||
Err(e) => {
|
||||
log.line(&format!("ERROR: {e}")).await;
|
||||
JobStatus::Failed
|
||||
}
|
||||
};
|
||||
if let Err(e) = runs.finish(run.id, status).await {
|
||||
tracing_line(&format!("failed to finish job: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a job in the background. Fails with `Conflict` if the kind is already running.
|
||||
pub async fn start(
|
||||
&self,
|
||||
_kind: JobKind,
|
||||
_params: Option<String>,
|
||||
_triggered_by: &str,
|
||||
kind: JobKind,
|
||||
params: Option<String>,
|
||||
triggered_by: &str,
|
||||
) -> Result<JobRun, DomainError> {
|
||||
todo!()
|
||||
let (run, handler) = self.begin(kind, params, triggered_by).await?;
|
||||
let (runs, run_clone) = (self.runs.clone(), run.clone());
|
||||
tokio::spawn(async move { Self::execute(runs, handler, &run_clone).await });
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
/// Run a job and wait for it to finish (used by tests and the scheduler).
|
||||
pub async fn run_and_wait(
|
||||
&self,
|
||||
_kind: JobKind,
|
||||
_params: Option<String>,
|
||||
_triggered_by: &str,
|
||||
kind: JobKind,
|
||||
params: Option<String>,
|
||||
triggered_by: &str,
|
||||
) -> Result<JobRun, DomainError> {
|
||||
todo!()
|
||||
let (run, handler) = self.begin(kind, params, triggered_by).await?;
|
||||
Self::execute(self.runs.clone(), handler, &run).await;
|
||||
self.get(run.id).await
|
||||
}
|
||||
|
||||
pub async fn get(&self, _id: Uuid) -> Result<JobRun, DomainError> {
|
||||
todo!()
|
||||
pub async fn get(&self, id: Uuid) -> Result<JobRun, DomainError> {
|
||||
self.runs.get(id).await?.ok_or(DomainError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn list(&self, _limit: u32) -> Result<Vec<JobRun>, DomainError> {
|
||||
todo!()
|
||||
pub async fn list(&self, limit: u32) -> Result<Vec<JobRun>, DomainError> {
|
||||
self.runs.list(limit).await
|
||||
}
|
||||
|
||||
pub async fn last_finished(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError> {
|
||||
self.runs.last_finished(kind).await
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,30 +1,84 @@
|
||||
//! Cron scheduler: decides which scheduled job kinds are due.
|
||||
//! Cron scheduler: decides which scheduled job kinds are due and runs them.
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use cron::Schedule;
|
||||
use domain::jobs::JobKind;
|
||||
use domain::DomainError;
|
||||
|
||||
use crate::{JobRunner, SettingsService};
|
||||
|
||||
/// Validate a 6-field cron expression (seconds first).
|
||||
pub fn validate_cron(_expr: &str) -> Result<(), DomainError> {
|
||||
todo!()
|
||||
pub fn validate_cron(expr: &str) -> Result<(), DomainError> {
|
||||
Schedule::from_str(expr)
|
||||
.map(|_| ())
|
||||
.map_err(|e| DomainError::Validation(format!("invalid cron expression: {e}")))
|
||||
}
|
||||
|
||||
/// Next fire time strictly after `after`.
|
||||
pub fn next_fire(_expr: &str, _after: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
todo!()
|
||||
pub fn next_fire(expr: &str, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
Schedule::from_str(expr).ok()?.after(&after).next()
|
||||
}
|
||||
|
||||
/// A job is due if a fire time exists in `(last_run, now]`. With no last run, it is due
|
||||
/// if a fire time falls within the last `grace` seconds, so a fresh start does not
|
||||
/// immediately run every job.
|
||||
pub fn is_due(
|
||||
_expr: &str,
|
||||
_last_run: Option<DateTime<Utc>>,
|
||||
_now: DateTime<Utc>,
|
||||
_grace_secs: i64,
|
||||
expr: &str,
|
||||
last_run: Option<DateTime<Utc>>,
|
||||
now: DateTime<Utc>,
|
||||
grace_secs: i64,
|
||||
) -> bool {
|
||||
todo!()
|
||||
let since = last_run.unwrap_or(now - chrono::Duration::seconds(grace_secs));
|
||||
next_fire(expr, since).is_some_and(|t| t <= now)
|
||||
}
|
||||
|
||||
pub struct Scheduler {
|
||||
pub kinds: Vec<JobKind>,
|
||||
runner: Arc<JobRunner>,
|
||||
settings: Arc<SettingsService>,
|
||||
tick: Duration,
|
||||
}
|
||||
|
||||
impl Scheduler {
|
||||
pub fn new(runner: Arc<JobRunner>, settings: Arc<SettingsService>) -> Self {
|
||||
Self {
|
||||
runner,
|
||||
settings,
|
||||
tick: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass: start every registered kind that is due. Returns the kinds started.
|
||||
pub async fn tick_once(&self, now: DateTime<Utc>) -> Vec<JobKind> {
|
||||
let mut started = Vec::new();
|
||||
for kind in self.runner.kinds() {
|
||||
let Ok(Some(cron)) = self.settings.schedule(kind).await else {
|
||||
continue;
|
||||
};
|
||||
let last = self
|
||||
.runner
|
||||
.last_finished(kind)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|r| r.started_at);
|
||||
if is_due(&cron, last, now, self.tick.as_secs() as i64 * 2) {
|
||||
if self.runner.start(kind, None, "scheduler").await.is_ok() {
|
||||
started.push(kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
/// Run forever; meant to be spawned on the runtime.
|
||||
pub async fn run(self) {
|
||||
let mut interval = tokio::time::interval(self.tick);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
self.tick_once(Utc::now()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,11 @@ use std::sync::Arc;
|
||||
|
||||
use domain::jobs::JobKind;
|
||||
use domain::ports::{Cipher, Mailer, SettingsRepository};
|
||||
use domain::settings::SmtpSettings;
|
||||
use domain::settings::{SmtpSettings, KEY_SMTP, SECRET_KEYS};
|
||||
use domain::DomainError;
|
||||
|
||||
use crate::scheduler::validate_cron;
|
||||
|
||||
pub struct SettingsService {
|
||||
repo: Arc<dyn SettingsRepository>,
|
||||
cipher: Arc<dyn Cipher>,
|
||||
@ -17,7 +19,6 @@ impl SettingsService {
|
||||
cipher: Arc<dyn Cipher>,
|
||||
mailer: Arc<dyn Mailer>,
|
||||
) -> Self {
|
||||
let _ = (&repo, &cipher, &mailer);
|
||||
Self {
|
||||
repo,
|
||||
cipher,
|
||||
@ -25,34 +26,84 @@ impl SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn smtp(&self) -> Result<Option<SmtpSettings>, DomainError> {
|
||||
todo!()
|
||||
async fn get(&self, key: &str) -> Result<Option<String>, DomainError> {
|
||||
let Some(raw) = self.repo.get(key).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if SECRET_KEYS.contains(&key) {
|
||||
self.cipher.decrypt(&raw).map(Some)
|
||||
} else {
|
||||
Ok(Some(raw))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_smtp(&self, _smtp: SmtpSettings) -> Result<(), DomainError> {
|
||||
todo!()
|
||||
async fn set(&self, key: &str, value: &str) -> Result<(), DomainError> {
|
||||
let stored = if SECRET_KEYS.contains(&key) {
|
||||
self.cipher.encrypt(value)?
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
self.repo.set(key, &stored).await
|
||||
}
|
||||
|
||||
pub async fn smtp(&self) -> Result<Option<SmtpSettings>, DomainError> {
|
||||
match self.get(KEY_SMTP).await? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map(Some)
|
||||
.map_err(|e| DomainError::Storage(e.to_string())),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_smtp(&self, smtp: SmtpSettings) -> Result<(), DomainError> {
|
||||
smtp.validate()?;
|
||||
let json = serde_json::to_string(&smtp).map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||
self.set(KEY_SMTP, &json).await
|
||||
}
|
||||
|
||||
/// Send a mail to the configured recipients (or `to` if given) using the stored SMTP settings.
|
||||
pub async fn send_mail(
|
||||
&self,
|
||||
_to: Option<Vec<String>>,
|
||||
_subject: &str,
|
||||
_body: &str,
|
||||
to: Option<Vec<String>>,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
todo!()
|
||||
let smtp = self
|
||||
.smtp()
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::Validation("smtp is not configured".into()))?;
|
||||
let to = to.unwrap_or_else(|| smtp.notify_to.clone());
|
||||
if to.is_empty() {
|
||||
return Err(DomainError::Validation("no recipients configured".into()));
|
||||
}
|
||||
self.mailer.send(&smtp, &to, subject, body).await
|
||||
}
|
||||
|
||||
/// Cron expression (6 fields, seconds first) for a scheduled job kind, or None if disabled.
|
||||
pub async fn schedule(&self, _kind: JobKind) -> Result<Option<String>, DomainError> {
|
||||
todo!()
|
||||
pub async fn schedule(&self, kind: JobKind) -> Result<Option<String>, DomainError> {
|
||||
match self.get(&schedule_key(kind)).await? {
|
||||
Some(v) if v.is_empty() => Ok(None),
|
||||
Some(v) => Ok(Some(v)),
|
||||
None => Ok(kind.default_schedule().map(String::from)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_schedule(
|
||||
&self,
|
||||
_kind: JobKind,
|
||||
_cron: Option<String>,
|
||||
kind: JobKind,
|
||||
cron: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
todo!()
|
||||
let value = match cron.map(|c| c.trim().to_string()).filter(|c| !c.is_empty()) {
|
||||
Some(c) => {
|
||||
validate_cron(&c)?;
|
||||
c
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
self.set(&schedule_key(kind), &value).await
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_key(kind: JobKind) -> String {
|
||||
format!("schedule.{}", kind.as_str())
|
||||
}
|
||||
|
||||
@ -7,10 +7,13 @@ license.workspace = true
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
anyhow.workspace = true
|
||||
aes-gcm = "0.10"
|
||||
argon2.workspace = true
|
||||
base64.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-rustls-tls", "hostname"] }
|
||||
serde.workspace = true
|
||||
sqlx.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE job_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
params TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'success', 'failed')),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
log TEXT NOT NULL DEFAULT '',
|
||||
triggered_by TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX job_runs_kind_started ON job_runs(kind, started_at DESC);
|
||||
80
backend/crates/infrastructure/src/cipher.rs
Normal file
80
backend/crates/infrastructure/src/cipher.rs
Normal file
@ -0,0 +1,80 @@
|
||||
//! AES-256-GCM encryption for secrets at rest. Format: base64url(nonce || ciphertext).
|
||||
use aes_gcm::aead::{Aead, KeyInit, OsRng};
|
||||
use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use domain::ports::Cipher;
|
||||
use domain::DomainError;
|
||||
|
||||
pub struct AesGcmCipher(Aes256Gcm);
|
||||
|
||||
impl AesGcmCipher {
|
||||
/// `hex_key` is a 64-character hex string (32 bytes).
|
||||
pub fn from_hex(hex_key: &str) -> anyhow::Result<Self> {
|
||||
let bytes = hex_decode(hex_key)?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() == 32,
|
||||
"MASTER_KEY must be 32 bytes (64 hex characters)"
|
||||
);
|
||||
Ok(Self(Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&bytes))))
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_decode(s: &str) -> anyhow::Result<Vec<u8>> {
|
||||
anyhow::ensure!(s.len() % 2 == 0, "odd hex length");
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| Ok(u8::from_str_radix(&s[i..i + 2], 16)?))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Cipher for AesGcmCipher {
|
||||
fn encrypt(&self, plain: &str) -> Result<String, DomainError> {
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let ct = self
|
||||
.0
|
||||
.encrypt(&nonce, plain.as_bytes())
|
||||
.map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||
let mut out = nonce.to_vec();
|
||||
out.extend(ct);
|
||||
Ok(URL_SAFE_NO_PAD.encode(out))
|
||||
}
|
||||
|
||||
fn decrypt(&self, cipher_text: &str) -> Result<String, DomainError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cipher_text)
|
||||
.map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||
if bytes.len() < 12 {
|
||||
return Err(DomainError::Storage("cipher text too short".into()));
|
||||
}
|
||||
let (nonce, ct) = bytes.split_at(12);
|
||||
let plain = self
|
||||
.0
|
||||
.decrypt(Nonce::from_slice(nonce), ct)
|
||||
.map_err(|_| DomainError::Storage("decryption failed".into()))?;
|
||||
String::from_utf8(plain).map_err(|e| DomainError::Storage(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
||||
|
||||
#[test]
|
||||
fn roundtrip_and_tamper_detection() {
|
||||
let c = AesGcmCipher::from_hex(KEY).unwrap();
|
||||
let ct = c.encrypt("hello secret").unwrap();
|
||||
assert_ne!(
|
||||
c.encrypt("hello secret").unwrap(),
|
||||
ct,
|
||||
"nonce must be random"
|
||||
);
|
||||
assert_eq!(c.decrypt(&ct).unwrap(), "hello secret");
|
||||
let other = AesGcmCipher::from_hex(&KEY.replace('0', "f")).unwrap();
|
||||
assert!(other.decrypt(&ct).is_err());
|
||||
assert!(c.decrypt("AAAA").is_err());
|
||||
assert!(AesGcmCipher::from_hex("abcd").is_err());
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,14 @@
|
||||
//! Infrastructure layer: SQLite repositories, Argon2 hashing, JWT issuing.
|
||||
pub mod cipher;
|
||||
pub mod db;
|
||||
pub mod mail;
|
||||
pub mod password;
|
||||
pub mod sqlite;
|
||||
pub mod token;
|
||||
|
||||
pub use cipher::AesGcmCipher;
|
||||
pub use db::{connect, DbPool};
|
||||
pub use mail::LettreMailer;
|
||||
pub use password::Argon2Hasher;
|
||||
pub use sqlite::{SqliteAuditLog, SqliteRefreshTokens, SqliteUsers};
|
||||
pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers};
|
||||
pub use token::JwtIssuer;
|
||||
|
||||
62
backend/crates/infrastructure/src/mail.rs
Normal file
62
backend/crates/infrastructure/src/mail.rs
Normal file
@ -0,0 +1,62 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::Mailer;
|
||||
use domain::settings::{SmtpSecurity, SmtpSettings};
|
||||
use domain::DomainError;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LettreMailer;
|
||||
|
||||
#[async_trait]
|
||||
impl Mailer for LettreMailer {
|
||||
async fn send(
|
||||
&self,
|
||||
smtp: &SmtpSettings,
|
||||
to: &[String],
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let unavailable = |e: String| DomainError::Unavailable(format!("smtp: {e}"));
|
||||
let mut msg = Message::builder()
|
||||
.from(
|
||||
smtp.from
|
||||
.parse()
|
||||
.map_err(|e: lettre::address::AddressError| unavailable(e.to_string()))?,
|
||||
)
|
||||
.subject(subject);
|
||||
for r in to {
|
||||
msg = msg.to(r
|
||||
.parse()
|
||||
.map_err(|e: lettre::address::AddressError| unavailable(e.to_string()))?);
|
||||
}
|
||||
let msg = msg
|
||||
.body(body.to_string())
|
||||
.map_err(|e| unavailable(e.to_string()))?;
|
||||
|
||||
let mut builder = match smtp.security {
|
||||
SmtpSecurity::None => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&smtp.host)
|
||||
}
|
||||
SmtpSecurity::StartTls => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host)
|
||||
.map_err(|e| unavailable(e.to_string()))?
|
||||
}
|
||||
SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
|
||||
.map_err(|e| unavailable(e.to_string()))?,
|
||||
}
|
||||
.port(smtp.port);
|
||||
if !smtp.username.is_empty() {
|
||||
builder = builder.credentials(Credentials::new(
|
||||
smtp.username.clone(),
|
||||
smtp.password.clone(),
|
||||
));
|
||||
}
|
||||
builder
|
||||
.build()
|
||||
.send(msg)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| unavailable(e.to_string()))
|
||||
}
|
||||
}
|
||||
@ -280,3 +280,184 @@ mod tests {
|
||||
assert!(repo.find_by_hash("nope").await.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SqliteSettings(pub DbPool);
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::SettingsRepository for SqliteSettings {
|
||||
async fn get(&self, key: &str) -> Result<Option<String>, DomainError> {
|
||||
sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
|
||||
.bind(key)
|
||||
.fetch_optional(&self.0)
|
||||
.await
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn set(&self, key: &str, value: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(&self.0)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(storage)
|
||||
}
|
||||
}
|
||||
|
||||
use domain::jobs::{JobKind, JobRun, JobStatus};
|
||||
|
||||
fn job_from_row(r: &SqliteRow) -> JobRun {
|
||||
JobRun {
|
||||
id: r.get("id"),
|
||||
kind: JobKind::parse(r.get::<String, _>("kind").as_str())
|
||||
.unwrap_or(JobKind::PackageRefresh),
|
||||
params: r.get("params"),
|
||||
status: JobStatus::parse(r.get::<String, _>("status").as_str())
|
||||
.unwrap_or(JobStatus::Failed),
|
||||
started_at: parse_ts(r.get::<String, _>("started_at").as_str()),
|
||||
finished_at: r
|
||||
.get::<Option<String>, _>("finished_at")
|
||||
.as_deref()
|
||||
.map(parse_ts),
|
||||
log: r.get("log"),
|
||||
triggered_by: r.get("triggered_by"),
|
||||
}
|
||||
}
|
||||
|
||||
const JOB_COLS: &str = "id, kind, params, status, started_at, finished_at, log, triggered_by";
|
||||
|
||||
pub struct SqliteJobRuns(pub DbPool);
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::JobRunRepository for SqliteJobRuns {
|
||||
async fn insert(&self, run: &JobRun) -> Result<(), DomainError> {
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO job_runs ({JOB_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
))
|
||||
.bind(run.id)
|
||||
.bind(run.kind.as_str())
|
||||
.bind(&run.params)
|
||||
.bind(run.status.as_str())
|
||||
.bind(run.started_at.to_rfc3339())
|
||||
.bind(run.finished_at.map(|t| t.to_rfc3339()))
|
||||
.bind(&run.log)
|
||||
.bind(&run.triggered_by)
|
||||
.execute(&self.0)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn append_log(&self, id: Uuid, line: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE job_runs SET log = log || ? || char(10) WHERE id = ?")
|
||||
.bind(line)
|
||||
.bind(id)
|
||||
.execute(&self.0)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn finish(&self, id: Uuid, status: JobStatus) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE job_runs SET status = ?, finished_at = ? WHERE id = ?")
|
||||
.bind(status.as_str())
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(id)
|
||||
.execute(&self.0)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn get(&self, id: Uuid) -> Result<Option<JobRun>, DomainError> {
|
||||
sqlx::query(&format!("SELECT {JOB_COLS} FROM job_runs WHERE id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&self.0)
|
||||
.await
|
||||
.map(|r| r.as_ref().map(job_from_row))
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn list(&self, limit: u32) -> Result<Vec<JobRun>, DomainError> {
|
||||
sqlx::query(&format!(
|
||||
"SELECT {JOB_COLS} FROM job_runs ORDER BY started_at DESC LIMIT ?"
|
||||
))
|
||||
.bind(limit)
|
||||
.fetch_all(&self.0)
|
||||
.await
|
||||
.map(|rows| rows.iter().map(job_from_row).collect())
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn find_running(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError> {
|
||||
sqlx::query(&format!(
|
||||
"SELECT {JOB_COLS} FROM job_runs WHERE kind = ? AND status = 'running' LIMIT 1"
|
||||
))
|
||||
.bind(kind.as_str())
|
||||
.fetch_optional(&self.0)
|
||||
.await
|
||||
.map(|r| r.as_ref().map(job_from_row))
|
||||
.map_err(storage)
|
||||
}
|
||||
async fn last_finished(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError> {
|
||||
sqlx::query(&format!("SELECT {JOB_COLS} FROM job_runs WHERE kind = ? AND status != 'running' ORDER BY started_at DESC LIMIT 1"))
|
||||
.bind(kind.as_str())
|
||||
.fetch_optional(&self.0)
|
||||
.await
|
||||
.map(|r| r.as_ref().map(job_from_row))
|
||||
.map_err(storage)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod job_tests {
|
||||
use super::*;
|
||||
use domain::ports::{JobRunRepository, SettingsRepository};
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_upsert() {
|
||||
let pool = crate::connect("sqlite::memory:").await.unwrap();
|
||||
let s = SqliteSettings(pool);
|
||||
assert_eq!(s.get("k").await.unwrap(), None);
|
||||
s.set("k", "1").await.unwrap();
|
||||
s.set("k", "2").await.unwrap();
|
||||
assert_eq!(s.get("k").await.unwrap().as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_runs_log_finish_and_queries() {
|
||||
let pool = crate::connect("sqlite::memory:").await.unwrap();
|
||||
let repo = SqliteJobRuns(pool);
|
||||
let run = JobRun {
|
||||
id: Uuid::new_v4(),
|
||||
kind: JobKind::PackageRefresh,
|
||||
params: None,
|
||||
status: JobStatus::Running,
|
||||
started_at: Utc::now(),
|
||||
finished_at: None,
|
||||
log: String::new(),
|
||||
triggered_by: "test".into(),
|
||||
};
|
||||
repo.insert(&run).await.unwrap();
|
||||
assert!(repo
|
||||
.find_running(JobKind::PackageRefresh)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
repo.append_log(run.id, "a").await.unwrap();
|
||||
repo.append_log(run.id, "b").await.unwrap();
|
||||
repo.finish(run.id, JobStatus::Success).await.unwrap();
|
||||
let got = repo.get(run.id).await.unwrap().unwrap();
|
||||
assert_eq!(got.log, "a\nb\n");
|
||||
assert_eq!(got.status, JobStatus::Success);
|
||||
assert!(got.finished_at.is_some());
|
||||
assert!(repo
|
||||
.find_running(JobKind::PackageRefresh)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
repo.last_finished(JobKind::PackageRefresh)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id,
|
||||
run.id
|
||||
);
|
||||
assert_eq!(repo.list(10).await.unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user