2 Commits

Author SHA1 Message Date
GW_MC
ec81d3228b fix clippy warnings
Some checks failed
Test / test-frontend (pull_request) Successful in 38s
Test / frontend-build (pull_request) Successful in 40s
Verify / verify-generated-code (pull_request) Successful in 9m2s
Verify / verify-openapi-spec (pull_request) Successful in 8m43s
Verify / verify-frontend-api-client (pull_request) Successful in 18s
Test / test (pull_request) Failing after 8m56s
Test / lint (pull_request) Successful in 1m9s
2025-12-19 10:25:55 +08:00
GW_MC
8111aaf672 feat: enhance health check with application state and initialization status 2025-12-19 10:25:22 +08:00
17 changed files with 158 additions and 22 deletions

11
.vscode/settings.json vendored
View File

@@ -1,3 +1,12 @@
{
"cSpell.words": ["YANPM"]
"cSpell.words": ["chrono", "jsonwebtoken", "oneshot", "utoipa", "YANPM"],
"sqltools.useNodeRuntime": true,
"sqltools.connections": [
{
"previewLimit": 50,
"driver": "SQLite",
"database": "${workspaceFolder:yet-another-nginx-proxy-manager}/apps/container/generated/sqlite/sqlite.db",
"name": "YANPM"
}
]
}

View File

@@ -17,6 +17,7 @@ use crate::{
authentication::{AuthenticationServiceImpl, strategies::password::PasswordStrategy},
user::UserServiceImpl,
},
server_state::ServerStateService,
settings::SettingsService,
},
tasks,
@@ -145,6 +146,7 @@ fn get_app_state(
AppState {
database_connection: db_connection.clone(),
service: Arc::new(AppService {
server_state: Arc::new(ServerStateService::new(db_connection.clone())),
settings: Arc::new(SettingsService::new(db_connection.clone())),
auth_state: routes::AuthState {
strategy: routes::AuthStrategy {

View File

@@ -15,16 +15,15 @@ use crate::{
authentication::{AuthenticationService, strategies::password::PasswordStrategy},
user::UserService,
},
server_state::ServerStateStore,
settings::SettingsStore,
},
};
#[derive(Clone)]
pub struct AppState {
// TODO: remove dead_code allowances when fields are used
#[allow(dead_code)]
pub database_connection: Arc<DatabaseConnection>,
// TODO: remove dead_code allowances when fields are used
#[allow(dead_code)]
pub service: Arc<AppService>,
}
@@ -44,6 +43,7 @@ pub struct AppService {
pub settings: ServiceState<dyn SettingsStore>,
pub auth_state: AuthState,
pub user: ServiceState<dyn UserService>,
pub server_state: ServiceState<dyn ServerStateStore>,
}
pub fn get_root_router(state: impl Into<Arc<AppState>>) -> Router {

View File

@@ -13,7 +13,7 @@ use axum::{Router, response::IntoResponse, routing::any};
pub fn get_api_router(state: Arc<AppState>) -> Router {
Router::new()
.nest("/health", health::get_health_router())
.nest("/health", health::get_health_router(state.clone()))
.merge(auth::get_basic_auth_router(state.clone()))
.merge(restricted::get_restricted_router(state.clone()))
// explicit fallback for unmatched API routes

View File

@@ -3,10 +3,7 @@ pub mod login;
use std::sync::Arc;
use axum::{
Router,
routing::{get, post},
};
use axum::{Router, routing::post};
use crate::routes::AppState;

View File

@@ -92,7 +92,7 @@ pub async fn login(State(state): State<Arc<AppState>>, Json(payload): Json<Value
Ok(resp) => resp,
Err(e) => {
error!("Error building response: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR).into_response();
(StatusCode::INTERNAL_SERVER_ERROR).into_response()
}
}
}

View File

@@ -5,8 +5,13 @@ use std::sync::Arc;
use axum::{Router, routing::get};
pub fn get_health_router() -> Router {
use crate::routes::{AppState, api::health::state::AppStateWithHealth};
pub fn get_health_router(app_state: Arc<AppState>) -> Router {
Router::new()
.route("/info", get(info::get_health_info))
.with_state(Arc::new(state::HealthState::default()))
.with_state(Arc::new(AppStateWithHealth {
app_state: app_state.clone(),
health_state: Arc::new(state::HealthState::default()),
}))
}

View File

@@ -3,8 +3,9 @@ use std::sync::Arc;
use axum::{Json, extract::State, http::StatusCode};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::error;
use crate::routes::api::{health::state::HealthState, openapi::tag::HEALTH_TAG};
use crate::routes::api::{health::state::AppStateWithHealth, openapi::tag::HEALTH_TAG};
const STATUS_HEALTHY: &str = "healthy";
const STATUS_UNHEALTHY: &str = "unhealthy";
@@ -20,6 +21,8 @@ pub struct HealthInfo {
pub up_since: DateTime<Utc>,
/// List of error messages if unhealthy
pub errors: Option<Vec<String>>,
/// Is initialized
pub is_initialized: bool,
}
/// Health check endpoint
@@ -35,12 +38,23 @@ pub struct HealthInfo {
tag = HEALTH_TAG,
)]
pub async fn get_health_info(
State(state): State<Arc<HealthState>>,
State(app_state_with_health): State<Arc<AppStateWithHealth>>,
) -> (StatusCode, Json<HealthInfo>) {
#[allow(unused_mut)]
let mut errors = vec![];
let is_healthy = errors.is_empty();
let health_state = &app_state_with_health.health_state;
let app_state = &app_state_with_health.app_state;
let is_initialized = match app_state.service.server_state.is_server_initialized().await {
Ok(initialized) => initialized,
Err(err) => {
errors.push("Failed to determine if server is initialized".to_string());
error!("Error checking server initialization status: {}", err);
false
}
};
(
if is_healthy {
@@ -55,14 +69,29 @@ pub async fn get_health_info(
STATUS_UNHEALTHY.into()
},
version: env!("CARGO_PKG_VERSION").into(),
up_since: *state.get_start_at(),
up_since: *health_state.get_start_at(),
errors: if is_healthy { None } else { Some(errors) },
is_initialized,
}),
)
}
#[cfg(test)]
mod test {
use crate::{
routes::{AppState, api::health::state::HealthState},
services::{
auth::{
authentication::{
AuthenticationServiceImpl, strategies::password::PasswordStrategy,
},
user::UserServiceImpl,
},
server_state::ServerStateService,
settings::SettingsService,
},
};
use super::*;
use axum::body::to_bytes;
use axum::{
@@ -70,14 +99,38 @@ mod test {
body::Body,
http::{Request, StatusCode},
};
use sea_orm::MockDatabase;
use tower::ServiceExt;
#[tokio::test]
async fn test_get_health_info() {
let health_state = Arc::new(HealthState::default());
let db = MockDatabase::new(sea_orm::DatabaseBackend::Sqlite)
.append_query_results(vec![Vec::<sea_orm::MockRow>::new()])
.into_connection();
let db = Arc::new(db);
let app_state = Arc::new(AppState {
database_connection: db.clone(),
service: Arc::new(crate::routes::AppService {
settings: Arc::new(SettingsService::new(db.clone())),
auth_state: crate::routes::AuthState {
strategy: crate::routes::AuthStrategy {
password: Arc::new(PasswordStrategy::new(db.clone())),
},
authentication: Arc::new(AuthenticationServiceImpl::new(None)),
},
user: Arc::new(UserServiceImpl::new(db.clone())),
server_state: Arc::new(ServerStateService::new(db.clone())),
}),
});
let app = Router::new()
.route("/info", axum::routing::get(get_health_info))
.with_state(health_state);
.with_state(Arc::new(AppStateWithHealth {
app_state: app_state.clone(),
health_state: health_state.clone(),
}));
let response = app
.oneshot(Request::builder().uri("/info").body(Body::empty()).unwrap())

View File

@@ -1,5 +1,14 @@
use std::sync::Arc;
use chrono::{DateTime, Utc};
use crate::routes::AppState;
pub struct AppStateWithHealth {
pub app_state: Arc<AppState>,
pub health_state: Arc<HealthState>,
}
pub struct HealthState {
start_at: DateTime<Utc>,
}

View File

@@ -1,6 +1,6 @@
use std::sync::Arc;
use axum::{Router, routing::get};
use axum::Router;
use crate::{middlewares::require_auth::require_auth, routes::AppState};

View File

@@ -1,2 +1,3 @@
pub mod auth;
pub mod server_state;
pub mod settings;

View File

@@ -14,6 +14,7 @@ use tokio::sync::RwLock;
use crate::errors::service_error::ServiceError;
// Number of requests between invalidation cache cleanups
#[allow(dead_code)] // TODO: remove when used
const INVALIDATE_CACHE_CLEANUP_INTERVAL_REQUESTS: usize = 100; // Cleanup every 100 for invalidation checks
#[derive(Serialize, Deserialize, Clone)]
@@ -38,10 +39,15 @@ pub trait AuthenticationService: Send + Sync {
token: &str,
target_sub: Option<String>,
) -> Result<Option<Claims>, ServiceError>;
#[allow(dead_code)] // TODO: remove when used
async fn parse_jwt(&self, token: &str) -> Result<Claims, ServiceError>;
#[allow(dead_code)] // TODO: remove when used
async fn invalidate_jwt(&self, token: &str) -> Result<(), ServiceError>;
#[allow(dead_code)] // TODO: remove when used
async fn refresh_jwt(&self, token: &str, duration_secs: u64) -> Result<String, ServiceError>;
#[allow(dead_code)] // TODO: remove when used
async fn logout(&self, token: &str) -> Result<(), ServiceError>;
#[allow(dead_code)] // TODO: remove when used
async fn cleanup_invalidation_cache(&self);
}
@@ -54,7 +60,9 @@ struct InvalidationEntry {
pub struct AuthenticationServiceImpl {
secret: String,
#[allow(dead_code)] // TODO: remove when used
invalidation_cache: Arc<RwLock<HashSet<InvalidationEntry>>>,
#[allow(dead_code)] // TODO: remove when used
cache_cleanup_counter: Arc<RwLock<usize>>,
}

View File

@@ -68,7 +68,7 @@ impl PasswordStrategy {
Ok(user.id)
}
#[allow(dead_code)] // TODO: remove when used
pub async fn revoke_identity(
&self,
user_id: Uuid,
@@ -126,7 +126,7 @@ impl PasswordStrategy {
Ok(())
}
#[allow(dead_code)] // TODO: remove when used
pub async fn update_password(
&self,
user_id: Uuid,
@@ -368,7 +368,7 @@ mod test {
user_id: Uuid::new_v4(),
email: None,
provider: PASSWORD_PROVIDER.to_string(),
password_hash: Some("somehash".to_string()),
password_hash: Some("some_hash".to_string()),
metadata: None,
is_revoked: false,
revoked_at: None,
@@ -413,7 +413,7 @@ mod test {
user_id,
email: None,
provider: PASSWORD_PROVIDER.to_string(),
password_hash: Some("oldhash".to_string()),
password_hash: Some("old_hash".to_string()),
metadata: None,
is_revoked: false,
revoked_at: None,
@@ -430,7 +430,7 @@ mod test {
user_id,
email: None,
provider: PASSWORD_PROVIDER.to_string(),
password_hash: Some("newhash".to_string()),
password_hash: Some("new_hash".to_string()),
metadata: None,
is_revoked: false,
revoked_at: None,

View File

@@ -17,11 +17,13 @@ pub trait UserService: Send + Sync {
user_id: Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<User, ServiceError>;
#[allow(dead_code)] // TODO: remove when used
async fn is_admin(
&self,
user_id: Uuid,
tx: Option<&mut DatabaseTransaction>,
) -> Result<bool, ServiceError>;
#[allow(dead_code)] // TODO: remove when used
async fn user_exists(
&self,
username: &str,
@@ -32,12 +34,14 @@ pub trait UserService: Send + Sync {
user: NewUser,
tx: Option<&mut DatabaseTransaction>,
) -> Result<User, ServiceError>;
#[allow(dead_code)] // TODO: remove when used
async fn update_user(
&self,
user_id: Uuid,
user: UpdateUser,
tx: Option<&mut DatabaseTransaction>,
) -> Result<User, ServiceError>;
#[allow(dead_code)] // TODO: remove when used
async fn delete_user(
&self,
user_id: Uuid,
@@ -47,7 +51,9 @@ pub trait UserService: Send + Sync {
pub struct User {
pub id: Uuid,
#[allow(dead_code)] // TODO: remove when used
pub username: String,
#[allow(dead_code)] // TODO: remove when used
pub is_admin: bool,
}
@@ -67,12 +73,16 @@ pub struct NewUser {
}
pub struct UpdateUser {
#[allow(dead_code)] // TODO: remove when used
pub username: Option<String>,
#[allow(dead_code)] // TODO: remove when used
pub is_admin: Option<bool>,
#[allow(dead_code)] // TODO: remove when used
pub is_active: Option<bool>,
}
impl UpdateUser {
#[allow(dead_code)] // TODO: remove when used
fn apply_to_active_model(&self, model: &mut UserActiveModel) {
if let Some(username) = &self.username {
model.name = ActiveValue::Set(username.clone());

View File

@@ -0,0 +1,36 @@
use std::sync::Arc;
use sea_orm::{DatabaseConnection, prelude::*};
use crate::errors::service_error::ServiceError;
#[async_trait::async_trait]
pub trait ServerStateStore: Send + Sync {
async fn is_server_initialized(&self) -> Result<bool, ServiceError>;
}
pub struct ServerStateService {
connection: Arc<DatabaseConnection>,
}
impl ServerStateService {
pub fn new(connection: Arc<DatabaseConnection>) -> Self {
Self { connection }
}
}
#[async_trait::async_trait]
impl ServerStateStore for ServerStateService {
async fn is_server_initialized(&self) -> Result<bool, ServiceError> {
// For example, check if any admin user exists to determine if the server is initialized
let admin_exists = database::generated::entities::user::Entity::find()
.filter(database::generated::entities::user::Column::IsAdmin.eq(true))
.filter(database::generated::entities::user::Column::IsActive.eq(true))
.one(&*self.connection)
.await
.map_err(ServiceError::from)?
.is_some();
Ok(admin_exists)
}
}

View File

@@ -135,7 +135,8 @@
"required": [
"status",
"version",
"up_since"
"up_since",
"is_initialized"
],
"properties": {
"errors": {
@@ -148,6 +149,10 @@
},
"description": "List of error messages if unhealthy"
},
"is_initialized": {
"type": "boolean",
"description": "Is initialized"
},
"status": {
"type": "string",
"description": "Health status: \"healthy\" or \"unhealthy\""

View File

@@ -3,6 +3,7 @@ export namespace Schemas {
export type AdminInitRequest = { password: string; setup_secret: string; username: string };
export type HealthInfo = {
errors?: (Array<string> | null) | undefined;
is_initialized: boolean;
status: string;
up_since: string;
version: string;