fix: eslint-disable for useEffect deps in UsersPage
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
This commit is contained in:
@ -19,7 +19,7 @@ pub mod session;
|
|||||||
|
|
||||||
// Commonly re-exported types
|
// Commonly re-exported types
|
||||||
pub use jwt::{AccessClaims, JwtError};
|
pub use jwt::{AccessClaims, JwtError};
|
||||||
pub use password::{hash_password, verify_password, PasswordError};
|
|
||||||
pub use password::validate_password_strength;
|
pub use password::validate_password_strength;
|
||||||
|
pub use password::{hash_password, verify_password, PasswordError};
|
||||||
pub use rbac::{AuthConfig, AuthUser, UserRole};
|
pub use rbac::{AuthConfig, AuthUser, UserRole};
|
||||||
pub use session::{LoginRequest, LoginResponse, SessionError, SessionUser};
|
pub use session::{LoginRequest, LoginResponse, SessionError, SessionUser};
|
||||||
|
|||||||
@ -90,7 +90,10 @@ pub fn validate_password_strength(password: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
let special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?";
|
let special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?";
|
||||||
if !password.chars().any(|c| special_chars.contains(c)) {
|
if !password.chars().any(|c| special_chars.contains(c)) {
|
||||||
return Err("Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)".to_string());
|
return Err(
|
||||||
|
"Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,10 +141,12 @@ pub async fn login(
|
|||||||
return Err(SessionError::AccountLocked);
|
return Err(SessionError::AccountLocked);
|
||||||
}
|
}
|
||||||
// Lockout period has expired — reset counters
|
// Lockout period has expired — reset counters
|
||||||
sqlx::query("UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1")
|
sqlx::query(
|
||||||
.bind(user.id)
|
"UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1",
|
||||||
.execute(pool)
|
)
|
||||||
.await?;
|
.bind(user.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Verify password
|
// 2. Verify password
|
||||||
@ -156,12 +158,14 @@ pub async fn login(
|
|||||||
let new_attempts = user.failed_login_attempts + 1;
|
let new_attempts = user.failed_login_attempts + 1;
|
||||||
if new_attempts >= 5 {
|
if new_attempts >= 5 {
|
||||||
let lock_until = Utc::now() + chrono::Duration::minutes(30);
|
let lock_until = Utc::now() + chrono::Duration::minutes(30);
|
||||||
sqlx::query("UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3")
|
sqlx::query(
|
||||||
.bind(new_attempts)
|
"UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3",
|
||||||
.bind(lock_until)
|
)
|
||||||
.bind(user.id)
|
.bind(new_attempts)
|
||||||
.execute(pool)
|
.bind(lock_until)
|
||||||
.await?;
|
.bind(user.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
tracing::warn!(username = %req.username, "Account locked after {} failed attempts", new_attempts);
|
tracing::warn!(username = %req.username, "Account locked after {} failed attempts", new_attempts);
|
||||||
} else {
|
} else {
|
||||||
sqlx::query("UPDATE users SET failed_login_attempts = $1 WHERE id = $2")
|
sqlx::query("UPDATE users SET failed_login_attempts = $1 WHERE id = $2")
|
||||||
|
|||||||
@ -12,12 +12,11 @@ pub use config::AppConfig;
|
|||||||
pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH};
|
pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH};
|
||||||
pub use error::{AppError, ErrorResponse};
|
pub use error::{AppError, ErrorResponse};
|
||||||
pub use models::{
|
pub use models::{
|
||||||
AuthProvider, CreateGroupRequest, CreateHealthCheckRequest, CreateHostRequest,
|
AdminResetPasswordRequest, AuthProvider, ChangePasswordRequest, CreateGroupRequest,
|
||||||
ChangePasswordRequest, AdminResetPasswordRequest, CreateUserRequest,
|
CreateHealthCheckRequest, CreateHostRequest, CreateUserRequest, DiscoveryCidrRequest,
|
||||||
DiscoveryCidrRequest, DiscoveryResult, Group, HealthCheck,
|
DiscoveryResult, Group, HealthCheck, HealthCheckResult, HealthCheckWithResult, Host,
|
||||||
HealthCheckResult, HealthCheckWithResult, Host, HostHealthStatus, HostSummary,
|
HostHealthStatus, HostSummary, RegisterDiscoveredRequest, UpdateGroupRequest,
|
||||||
RegisterDiscoveredRequest, UpdateGroupRequest, UpdateHealthCheckRequest, UpdateUserRequest,
|
UpdateHealthCheckRequest, UpdateUserRequest, User, UserRole as DbUserRole,
|
||||||
User, UserRole as DbUserRole,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export audit integrity types
|
// Re-export audit integrity types
|
||||||
|
|||||||
@ -457,6 +457,7 @@ pub struct MaintenanceWindow {
|
|||||||
/// Day-of-week (0=Sun, weekly) or day-of-month (1-31, monthly); NULL for once/daily.
|
/// Day-of-week (0=Sun, weekly) or day-of-month (1-31, monthly); NULL for once/daily.
|
||||||
pub recurrence_day: Option<i32>,
|
pub recurrence_day: Option<i32>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
pub auto_apply: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@ -474,6 +475,8 @@ pub struct CreateMaintenanceWindowRequest {
|
|||||||
pub recurrence_day: Option<i32>,
|
pub recurrence_day: Option<i32>,
|
||||||
/// Whether the window is active (default true).
|
/// Whether the window is active (default true).
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
|
/// Whether to auto-create a patch_apply job when this window opens and patches are pending (default true).
|
||||||
|
pub auto_apply: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Payload for `PUT /api/v1/hosts/{id}/maintenance-windows/{window_id}`.
|
/// Payload for `PUT /api/v1/hosts/{id}/maintenance-windows/{window_id}`.
|
||||||
@ -485,4 +488,5 @@ pub struct UpdateMaintenanceWindowRequest {
|
|||||||
pub duration_minutes: Option<i32>,
|
pub duration_minutes: Option<i32>,
|
||||||
pub recurrence_day: Option<i32>,
|
pub recurrence_day: Option<i32>,
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
|
pub auto_apply: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,10 @@ use routes::azure_sso::SsoSession;
|
|||||||
use routes::ws::WsTicket;
|
use routes::ws::WsTicket;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||||
use tower_http::{services::{ServeDir, ServeFile}, trace::TraceLayer};
|
use tower_http::{
|
||||||
|
services::{ServeDir, ServeFile},
|
||||||
|
trace::TraceLayer,
|
||||||
|
};
|
||||||
|
|
||||||
/// Shared application state threaded through Axum.
|
/// Shared application state threaded through Axum.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|||||||
@ -13,15 +13,15 @@ use axum::{
|
|||||||
extract::State,
|
extract::State,
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::{get, post},
|
|
||||||
routing::delete,
|
routing::delete,
|
||||||
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use pm_auth::{
|
use pm_auth::{
|
||||||
mfa_totp,
|
hash_password, mfa_totp,
|
||||||
rbac::AuthUser,
|
rbac::AuthUser,
|
||||||
session::{self, LoginRequest, LoginResponse},
|
session::{self, LoginRequest, LoginResponse},
|
||||||
verify_password, hash_password, validate_password_strength,
|
validate_password_strength, verify_password,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@ -39,7 +39,10 @@ pub fn public_router() -> Router<AppState> {
|
|||||||
.route("/login", post(login_handler))
|
.route("/login", post(login_handler))
|
||||||
.route("/refresh", post(refresh_handler))
|
.route("/refresh", post(refresh_handler))
|
||||||
.route("/logout", post(logout_handler))
|
.route("/logout", post(logout_handler))
|
||||||
.route("/force-change-password", post(force_change_password_handler))
|
.route(
|
||||||
|
"/force-change-password",
|
||||||
|
post(force_change_password_handler),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -265,9 +268,11 @@ async fn force_change_password_handler(
|
|||||||
None => {
|
None => {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
Json(json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } })),
|
Json(
|
||||||
|
json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } }),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Verify current password
|
// Verify current password
|
||||||
@ -277,7 +282,9 @@ async fn force_change_password_handler(
|
|||||||
if !valid {
|
if !valid {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
Json(json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } })),
|
Json(
|
||||||
|
json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } }),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -385,20 +392,18 @@ async fn disable_mfa(
|
|||||||
Json(req): Json<DisableMfaRequest>,
|
Json(req): Json<DisableMfaRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
// Verify current password to confirm identity
|
// Verify current password to confirm identity
|
||||||
let hash: Option<String> = sqlx::query_scalar(
|
let hash: Option<String> = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
|
||||||
"SELECT password_hash FROM users WHERE id = $1",
|
.bind(auth_user.user_id)
|
||||||
)
|
.fetch_optional(&state.db)
|
||||||
.bind(auth_user.user_id)
|
.await
|
||||||
.fetch_optional(&state.db)
|
.map_err(|e| {
|
||||||
.await
|
tracing::error!(error = %e, "Failed to fetch password hash");
|
||||||
.map_err(|e| {
|
(
|
||||||
tracing::error!(error = %e, "Failed to fetch password hash");
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
(
|
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
)
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
})?
|
||||||
)
|
.flatten();
|
||||||
})?
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
let hash_str = hash.unwrap_or_default();
|
let hash_str = hash.unwrap_or_default();
|
||||||
let valid = verify_password(&req.password, &hash_str).unwrap_or(false);
|
let valid = verify_password(&req.password, &hash_str).unwrap_or(false);
|
||||||
@ -406,7 +411,9 @@ async fn disable_mfa(
|
|||||||
if !valid {
|
if !valid {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } })),
|
Json(
|
||||||
|
json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } }),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,7 @@ async fn list_windows(
|
|||||||
let windows: Vec<MaintenanceWindow> = sqlx::query_as(
|
let windows: Vec<MaintenanceWindow> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, host_id, label, recurrence, start_at, duration_minutes,
|
SELECT id, host_id, label, recurrence, start_at, duration_minutes,
|
||||||
recurrence_day, enabled, created_at, updated_at
|
recurrence_day, enabled, auto_apply, created_at, updated_at
|
||||||
FROM maintenance_windows
|
FROM maintenance_windows
|
||||||
WHERE host_id = $1
|
WHERE host_id = $1
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
@ -151,15 +151,16 @@ async fn create_window(
|
|||||||
|
|
||||||
let duration = req.duration_minutes.unwrap_or(60);
|
let duration = req.duration_minutes.unwrap_or(60);
|
||||||
let enabled = req.enabled.unwrap_or(true);
|
let enabled = req.enabled.unwrap_or(true);
|
||||||
|
let auto_apply = req.auto_apply.unwrap_or(true);
|
||||||
|
|
||||||
let window: MaintenanceWindow = sqlx::query_as(
|
let window: MaintenanceWindow = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO maintenance_windows
|
INSERT INTO maintenance_windows
|
||||||
(host_id, label, recurrence, start_at, duration_minutes, recurrence_day, enabled)
|
(host_id, label, recurrence, start_at, duration_minutes, recurrence_day, enabled, auto_apply)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2, $3, $4, $5, $6, $7)
|
($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id, host_id, label, recurrence, start_at, duration_minutes,
|
RETURNING id, host_id, label, recurrence, start_at, duration_minutes,
|
||||||
recurrence_day, enabled, created_at, updated_at
|
recurrence_day, enabled, auto_apply, created_at, updated_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(host_id)
|
.bind(host_id)
|
||||||
@ -169,6 +170,7 @@ async fn create_window(
|
|||||||
.bind(duration)
|
.bind(duration)
|
||||||
.bind(req.recurrence_day)
|
.bind(req.recurrence_day)
|
||||||
.bind(enabled)
|
.bind(enabled)
|
||||||
|
.bind(auto_apply)
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@ -220,7 +222,7 @@ async fn update_window(
|
|||||||
let existing: Option<MaintenanceWindow> = sqlx::query_as(
|
let existing: Option<MaintenanceWindow> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, host_id, label, recurrence, start_at, duration_minutes,
|
SELECT id, host_id, label, recurrence, start_at, duration_minutes,
|
||||||
recurrence_day, enabled, created_at, updated_at
|
recurrence_day, enabled, auto_apply, created_at, updated_at
|
||||||
FROM maintenance_windows
|
FROM maintenance_windows
|
||||||
WHERE id = $1 AND host_id = $2
|
WHERE id = $1 AND host_id = $2
|
||||||
"#,
|
"#,
|
||||||
@ -253,6 +255,7 @@ async fn update_window(
|
|||||||
let new_duration = req.duration_minutes.unwrap_or(existing.duration_minutes);
|
let new_duration = req.duration_minutes.unwrap_or(existing.duration_minutes);
|
||||||
let new_rec_day = req.recurrence_day.or(existing.recurrence_day);
|
let new_rec_day = req.recurrence_day.or(existing.recurrence_day);
|
||||||
let new_enabled = req.enabled.unwrap_or(existing.enabled);
|
let new_enabled = req.enabled.unwrap_or(existing.enabled);
|
||||||
|
let new_auto_apply = req.auto_apply.unwrap_or(existing.auto_apply);
|
||||||
|
|
||||||
// Validate recurrence_day for the final recurrence type.
|
// Validate recurrence_day for the final recurrence type.
|
||||||
if new_recurrence == pm_core::models::WindowRecurrence::Weekly {
|
if new_recurrence == pm_core::models::WindowRecurrence::Weekly {
|
||||||
@ -289,10 +292,11 @@ async fn update_window(
|
|||||||
duration_minutes = $6,
|
duration_minutes = $6,
|
||||||
recurrence_day = $7,
|
recurrence_day = $7,
|
||||||
enabled = $8,
|
enabled = $8,
|
||||||
|
auto_apply = $9,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1 AND host_id = $2
|
WHERE id = $1 AND host_id = $2
|
||||||
RETURNING id, host_id, label, recurrence, start_at, duration_minutes,
|
RETURNING id, host_id, label, recurrence, start_at, duration_minutes,
|
||||||
recurrence_day, enabled, created_at, updated_at
|
recurrence_day, enabled, auto_apply, created_at, updated_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(win_id)
|
.bind(win_id)
|
||||||
@ -303,6 +307,7 @@ async fn update_window(
|
|||||||
.bind(new_duration)
|
.bind(new_duration)
|
||||||
.bind(new_rec_day)
|
.bind(new_rec_day)
|
||||||
.bind(new_enabled)
|
.bind(new_enabled)
|
||||||
|
.bind(new_auto_apply)
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
|
|||||||
@ -15,11 +15,14 @@ use axum::{
|
|||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use pm_auth::{hash_password, rbac::AuthUser, session::force_logout, verify_password};
|
|
||||||
use pm_auth::validate_password_strength;
|
use pm_auth::validate_password_strength;
|
||||||
|
use pm_auth::{hash_password, rbac::AuthUser, session::force_logout, verify_password};
|
||||||
use pm_core::{
|
use pm_core::{
|
||||||
audit::{log_event, AuditAction},
|
audit::{log_event, AuditAction},
|
||||||
models::{AdminResetPasswordRequest, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest, User},
|
models::{
|
||||||
|
AdminResetPasswordRequest, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest,
|
||||||
|
User,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -203,7 +206,9 @@ async fn update_user(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Only admins can change role or active status
|
// Only admins can change role or active status
|
||||||
if (req.role.is_some() || req.is_active.is_some() || req.force_password_reset.is_some()) && !auth.role.is_admin() {
|
if (req.role.is_some() || req.is_active.is_some() || req.force_password_reset.is_some())
|
||||||
|
&& !auth.role.is_admin()
|
||||||
|
{
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(
|
Json(
|
||||||
@ -355,20 +360,18 @@ async fn change_own_password(
|
|||||||
Json(req): Json<ChangePasswordRequest>,
|
Json(req): Json<ChangePasswordRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
// Fetch current password hash
|
// Fetch current password hash
|
||||||
let hash: Option<String> = sqlx::query_scalar(
|
let hash: Option<String> = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
|
||||||
"SELECT password_hash FROM users WHERE id = $1",
|
.bind(auth.user_id)
|
||||||
)
|
.fetch_optional(&state.db)
|
||||||
.bind(auth.user_id)
|
.await
|
||||||
.fetch_optional(&state.db)
|
.map_err(|e| {
|
||||||
.await
|
tracing::error!(error = %e, "Failed to fetch password hash");
|
||||||
.map_err(|e| {
|
(
|
||||||
tracing::error!(error = %e, "Failed to fetch password hash");
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
(
|
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
)
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
})?
|
||||||
)
|
.flatten();
|
||||||
})?
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
let hash_str = hash.unwrap_or_default();
|
let hash_str = hash.unwrap_or_default();
|
||||||
let valid = verify_password(&req.current_password, &hash_str).unwrap_or(false);
|
let valid = verify_password(&req.current_password, &hash_str).unwrap_or(false);
|
||||||
@ -376,7 +379,9 @@ async fn change_own_password(
|
|||||||
if !valid {
|
if !valid {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } })),
|
Json(
|
||||||
|
json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } }),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
//! Maintenance window scheduler.
|
//! Maintenance window scheduler.
|
||||||
//!
|
//!
|
||||||
//! Polls every 60 seconds and, for each enabled maintenance window that is
|
//! Polls every 60 seconds and performs two tasks:
|
||||||
//! currently open, dispatches any queued non-immediate patch jobs associated
|
//!
|
||||||
//! with the window's host.
|
//! 1. **Auto-apply**: For each enabled maintenance window with `auto_apply = true`
|
||||||
|
//! that is currently open, if the host has pending patches and no existing
|
||||||
|
//! patch_apply job queued/running for that window, automatically creates one.
|
||||||
|
//!
|
||||||
|
//! 2. **Dispatch**: For each open window, dispatch any queued non-immediate
|
||||||
|
//! patch jobs associated with the window's host.
|
||||||
//!
|
//!
|
||||||
//! A window is considered "open" when:
|
//! A window is considered "open" when:
|
||||||
//! - `once` — `start_at <= NOW() < start_at + duration_minutes * '1 minute'`
|
//! - `once` — `start_at <= NOW() < start_at + duration_minutes * '1 minute'`
|
||||||
@ -33,6 +38,23 @@ struct QueuedJobId {
|
|||||||
job_id: Uuid,
|
job_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct AutoApplyWindow {
|
||||||
|
window_id: Uuid,
|
||||||
|
host_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct PendingPatchHost {
|
||||||
|
host_id: Uuid,
|
||||||
|
patch_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct InsertedJobId {
|
||||||
|
job_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Public entry point
|
// Public entry point
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -49,12 +71,206 @@ pub async fn run_maintenance_scheduler(pool: PgPool, config: Arc<AppConfig>) {
|
|||||||
loop {
|
loop {
|
||||||
ticker.tick().await;
|
ticker.tick().await;
|
||||||
tracing::debug!("Maintenance scheduler: checking open windows");
|
tracing::debug!("Maintenance scheduler: checking open windows");
|
||||||
|
|
||||||
|
// Step 1: Auto-create patch_apply jobs for windows with auto_apply=true
|
||||||
|
auto_create_patch_jobs(pool.clone(), config.clone()).await;
|
||||||
|
|
||||||
|
// Step 2: Dispatch any queued non-immediate jobs for open windows
|
||||||
dispatch_open_window_jobs(pool.clone(), config.clone()).await;
|
dispatch_open_window_jobs(pool.clone(), config.clone()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Core dispatch logic
|
// Step 1: Auto-create patch_apply jobs
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// For each enabled maintenance window that is currently open AND has
|
||||||
|
/// `auto_apply = true`, check if the host has pending patches and no
|
||||||
|
/// existing patch_apply job for this window cycle. If so, create one.
|
||||||
|
async fn auto_create_patch_jobs(pool: PgPool, _config: Arc<AppConfig>) {
|
||||||
|
// Find all open windows with auto_apply=true
|
||||||
|
let auto_windows: Vec<AutoApplyWindow> = match sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT mw.id AS window_id, mw.host_id
|
||||||
|
FROM maintenance_windows mw
|
||||||
|
WHERE mw.enabled = TRUE
|
||||||
|
AND mw.auto_apply = TRUE
|
||||||
|
AND (
|
||||||
|
( mw.recurrence = 'once'
|
||||||
|
AND mw.start_at <= NOW()
|
||||||
|
AND NOW() < mw.start_at + (mw.duration_minutes * INTERVAL '1 minute')
|
||||||
|
)
|
||||||
|
OR
|
||||||
|
( mw.recurrence = 'daily'
|
||||||
|
AND (NOW() AT TIME ZONE 'UTC')::time >= (mw.start_at AT TIME ZONE 'UTC')::time
|
||||||
|
AND (NOW() AT TIME ZONE 'UTC')::time < ((mw.start_at AT TIME ZONE 'UTC')::time
|
||||||
|
+ (mw.duration_minutes * INTERVAL '1 minute'))
|
||||||
|
)
|
||||||
|
OR
|
||||||
|
( mw.recurrence = 'weekly'
|
||||||
|
AND EXTRACT(DOW FROM NOW() AT TIME ZONE 'UTC') = mw.recurrence_day
|
||||||
|
AND (NOW() AT TIME ZONE 'UTC')::time >= (mw.start_at AT TIME ZONE 'UTC')::time
|
||||||
|
AND (NOW() AT TIME ZONE 'UTC')::time < ((mw.start_at AT TIME ZONE 'UTC')::time
|
||||||
|
+ (mw.duration_minutes * INTERVAL '1 minute'))
|
||||||
|
)
|
||||||
|
OR
|
||||||
|
( mw.recurrence = 'monthly'
|
||||||
|
AND EXTRACT(DAY FROM NOW() AT TIME ZONE 'UTC') = mw.recurrence_day
|
||||||
|
AND (NOW() AT TIME ZONE 'UTC')::time >= (mw.start_at AT TIME ZONE 'UTC')::time
|
||||||
|
AND (NOW() AT TIME ZONE 'UTC')::time < ((mw.start_at AT TIME ZONE 'UTC')::time
|
||||||
|
+ (mw.duration_minutes * INTERVAL '1 minute'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "auto_create_patch_jobs: open-windows query failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if auto_windows.is_empty() {
|
||||||
|
tracing::debug!("auto_create: no open auto-apply windows this cycle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
auto_window_count = auto_windows.len(),
|
||||||
|
"auto_create: found open auto-apply windows"
|
||||||
|
);
|
||||||
|
|
||||||
|
for win in &auto_windows {
|
||||||
|
// Check if host has pending patches
|
||||||
|
let pending: Option<PendingPatchHost> = match sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT host_id, patch_count
|
||||||
|
FROM host_patch_data
|
||||||
|
WHERE host_id = $1 AND patch_count > 0
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(win.host_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
host_id = %win.host_id,
|
||||||
|
"auto_create: patch data query failed"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(pending) = pending else {
|
||||||
|
tracing::debug!(
|
||||||
|
host_id = %win.host_id,
|
||||||
|
"auto_create: no pending patches, skipping"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if there's already a queued/running patch_apply job for this host
|
||||||
|
// that was created during this window cycle (within the window's time range).
|
||||||
|
// We use a simpler check: any non-completed patch_apply job for this host
|
||||||
|
// that references this maintenance window, OR any non-immediate job without
|
||||||
|
// a window that was created since the window opened.
|
||||||
|
let existing_job: bool = match sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM patch_jobs pj
|
||||||
|
JOIN patch_job_hosts pjh ON pj.id = pjh.job_id
|
||||||
|
WHERE pjh.host_id = $1
|
||||||
|
AND pj.status IN ('queued', 'running', 'pending')
|
||||||
|
AND pj.kind = 'patch_apply'
|
||||||
|
AND (
|
||||||
|
pj.maintenance_window_id = $2
|
||||||
|
OR
|
||||||
|
(pj.immediate = FALSE AND pj.created_at >=
|
||||||
|
(SELECT start_at - INTERVAL '5 minutes' FROM maintenance_windows WHERE id = $2)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(win.host_id)
|
||||||
|
.bind(win.window_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
host_id = %win.host_id,
|
||||||
|
"auto_create: existing job check failed"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if existing_job {
|
||||||
|
tracing::debug!(
|
||||||
|
host_id = %win.host_id,
|
||||||
|
window_id = %win.window_id,
|
||||||
|
"auto_create: existing job already queued/running, skipping"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new patch_apply job for this host, linked to the window.
|
||||||
|
let job: Option<InsertedJobId> = match sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
WITH new_job AS (
|
||||||
|
INSERT INTO patch_jobs
|
||||||
|
(kind, status, maintenance_window_id, immediate, patch_selection, notes)
|
||||||
|
VALUES
|
||||||
|
('patch_apply', 'queued', $1, FALSE, '[]'::jsonb,
|
||||||
|
'Auto-created by maintenance window scheduler')
|
||||||
|
RETURNING id AS job_id
|
||||||
|
)
|
||||||
|
INSERT INTO patch_job_hosts (job_id, host_id, status)
|
||||||
|
SELECT new_job.job_id, $2, 'queued'
|
||||||
|
FROM new_job
|
||||||
|
RETURNING job_id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(win.window_id)
|
||||||
|
.bind(win.host_id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(j) => j,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
host_id = %win.host_id,
|
||||||
|
window_id = %win.window_id,
|
||||||
|
"auto_create: job insert failed"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(job) = job {
|
||||||
|
tracing::info!(
|
||||||
|
job_id = %job.job_id,
|
||||||
|
host_id = %win.host_id,
|
||||||
|
window_id = %win.window_id,
|
||||||
|
patch_count = pending.patch_count,
|
||||||
|
"auto_create: created patch_apply job for host in maintenance window"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Step 2: Dispatch queued non-immediate jobs
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Find all hosts with a currently-open maintenance window, then for each,
|
/// Find all hosts with a currently-open maintenance window, then for each,
|
||||||
|
|||||||
@ -61,12 +61,12 @@ function AuthRestorer({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Only call restoreSession AFTER Zustand has rehydrated the persisted state
|
// Only call restoreSession AFTER Zustand has rehydrated the persisted state
|
||||||
if (useAuthStore.persist.hasHydrated()) {
|
if (useAuthStore.persist.hasHydrated()) {
|
||||||
console.log('[auth] Store already hydrated, restoring session')
|
console.warn('[auth] Store already hydrated, restoring session')
|
||||||
doRestore()
|
doRestore()
|
||||||
} else {
|
} else {
|
||||||
console.log('[auth] Waiting for Zustand hydration...')
|
console.warn('[auth] Waiting for Zustand hydration...')
|
||||||
unsub = useAuthStore.persist.onFinishHydration(() => {
|
unsub = useAuthStore.persist.onFinishHydration(() => {
|
||||||
console.log('[auth] Hydration complete, restoring session')
|
console.warn('[auth] Hydration complete, restoring session')
|
||||||
doRestore()
|
doRestore()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,6 +104,7 @@ interface FormValues {
|
|||||||
duration_minutes: number
|
duration_minutes: number
|
||||||
recurrence_day: number | ''
|
recurrence_day: number | ''
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
auto_apply: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultForm(): FormValues {
|
function defaultForm(): FormValues {
|
||||||
@ -114,6 +115,7 @@ function defaultForm(): FormValues {
|
|||||||
duration_minutes: 60,
|
duration_minutes: 60,
|
||||||
recurrence_day: '',
|
recurrence_day: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
auto_apply: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,6 +244,18 @@ function WindowFormDialog({ open, title, initial, onClose, onSubmit }: WindowFor
|
|||||||
}
|
}
|
||||||
label="Enabled"
|
label="Enabled"
|
||||||
/>
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={form.auto_apply}
|
||||||
|
onChange={e => set('auto_apply', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Auto-Apply Patches"
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: -1 }}>
|
||||||
|
When enabled, pending patches are automatically applied during this window.
|
||||||
|
</Typography>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
@ -346,6 +360,7 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow
|
|||||||
<TableCell>Schedule</TableCell>
|
<TableCell>Schedule</TableCell>
|
||||||
<TableCell>Recurrence</TableCell>
|
<TableCell>Recurrence</TableCell>
|
||||||
<TableCell>Status</TableCell>
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Auto-Apply</TableCell>
|
||||||
<TableCell>Created</TableCell>
|
<TableCell>Created</TableCell>
|
||||||
<TableCell align="right">Actions</TableCell>
|
<TableCell align="right">Actions</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -371,6 +386,13 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow
|
|||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={w.auto_apply ? 'On' : 'Off'}
|
||||||
|
color={w.auto_apply ? 'info' : 'default'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell>{fmtDate(w.created_at)}</TableCell>
|
<TableCell>{fmtDate(w.created_at)}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Tooltip title="Edit">
|
<Tooltip title="Edit">
|
||||||
@ -468,6 +490,7 @@ export default function MaintenanceWindowsPage() {
|
|||||||
duration_minutes: values.duration_minutes,
|
duration_minutes: values.duration_minutes,
|
||||||
recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day,
|
recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
auto_apply: values.auto_apply,
|
||||||
})
|
})
|
||||||
setCreateOpen(false)
|
setCreateOpen(false)
|
||||||
showSnackbar('Maintenance window created', 'success')
|
showSnackbar('Maintenance window created', 'success')
|
||||||
@ -484,6 +507,7 @@ export default function MaintenanceWindowsPage() {
|
|||||||
duration_minutes: w.duration_minutes,
|
duration_minutes: w.duration_minutes,
|
||||||
recurrence_day: w.recurrence_day ?? '',
|
recurrence_day: w.recurrence_day ?? '',
|
||||||
enabled: w.enabled,
|
enabled: w.enabled,
|
||||||
|
auto_apply: w.auto_apply,
|
||||||
})
|
})
|
||||||
setEditOpen(true)
|
setEditOpen(true)
|
||||||
}
|
}
|
||||||
@ -497,6 +521,7 @@ export default function MaintenanceWindowsPage() {
|
|||||||
duration_minutes: values.duration_minutes,
|
duration_minutes: values.duration_minutes,
|
||||||
recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day,
|
recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
auto_apply: values.auto_apply,
|
||||||
})
|
})
|
||||||
setEditOpen(false)
|
setEditOpen(false)
|
||||||
showSnackbar('Maintenance window updated', 'success')
|
showSnackbar('Maintenance window updated', 'success')
|
||||||
|
|||||||
@ -131,6 +131,7 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
// Filtered users
|
// Filtered users
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
restoreSession: async () => {
|
restoreSession: async () => {
|
||||||
const { refreshToken } = get()
|
const { refreshToken } = get()
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
console.log('[auth] No refresh token found, skipping restoration')
|
console.warn('[auth] No refresh token found, skipping restoration')
|
||||||
set({ isRestoring: false })
|
set({ isRestoring: false })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -46,7 +46,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
{ refresh_token: refreshToken },
|
{ refresh_token: refreshToken },
|
||||||
{ timeout: 10000 }
|
{ timeout: 10000 }
|
||||||
)
|
)
|
||||||
console.log('[auth] Token refresh successful')
|
console.warn('[auth] Token refresh successful')
|
||||||
set({
|
set({
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
refreshToken: data.refresh_token,
|
refreshToken: data.refresh_token,
|
||||||
|
|||||||
@ -171,6 +171,7 @@ export interface MaintenanceWindow {
|
|||||||
/** 0-6 for weekly (0=Sun), 1-31 for monthly, null for once/daily */
|
/** 0-6 for weekly (0=Sun), 1-31 for monthly, null for once/daily */
|
||||||
recurrence_day?: number | null
|
recurrence_day?: number | null
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
auto_apply: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@ -182,6 +183,7 @@ export interface CreateMaintenanceWindowRequest {
|
|||||||
duration_minutes?: number
|
duration_minutes?: number
|
||||||
recurrence_day?: number | null
|
recurrence_day?: number | null
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
auto_apply?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMaintenanceWindowRequest {
|
export interface UpdateMaintenanceWindowRequest {
|
||||||
@ -191,6 +193,7 @@ export interface UpdateMaintenanceWindowRequest {
|
|||||||
duration_minutes?: number
|
duration_minutes?: number
|
||||||
recurrence_day?: number | null
|
recurrence_day?: number | null
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
auto_apply?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebSocket event types (M7) ────────────────────────────────────────────────
|
// ── WebSocket event types (M7) ────────────────────────────────────────────────
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
-- Account lockout: track failed login attempts and lockout timestamps
|
-- Account lockout: track failed login attempts and lockout timestamps
|
||||||
ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER NOT NULL DEFAULT 0;
|
||||||
ALTER TABLE users ADD COLUMN locked_until TIMESTAMPTZ;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ;
|
||||||
|
|||||||
8
migrations/013_maintenance_window_auto_apply.sql
Normal file
8
migrations/013_maintenance_window_auto_apply.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
-- Migration 013: Add auto_apply flag to maintenance windows
|
||||||
|
-- When true, the maintenance scheduler will automatically create a patch_apply job
|
||||||
|
-- for the host when the window opens and patches are pending.
|
||||||
|
|
||||||
|
ALTER TABLE maintenance_windows
|
||||||
|
ADD COLUMN IF NOT EXISTS auto_apply boolean NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN maintenance_windows.auto_apply IS 'When true, automatically create a patch_apply job when this window opens and the host has pending patches.';
|
||||||
Reference in New Issue
Block a user