WP-11: package and OS updates from the UI
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

package_upgrade job streams apt-get output into the job log (streaming
CommandRunner, DebianUpdater with dist-upgrade or --only-upgrade), refreshes
the inventory afterwards and flags reboot-required. POST /api/system/upgrade
(admin), package name validation, selectable package table with confirm
dialog and live log on the Updates page. Fake updater for dev.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:27:28 +02:00
parent 1f56f015a2
commit 684695e71a
9 changed files with 277 additions and 33 deletions

View File

@ -15,16 +15,17 @@ use std::sync::Arc;
use application::scheduler::Scheduler;
use application::{
AuthService, InventoryService, JobRunner, PackageRefreshJob, SettingsService, UserService,
AuthService, InventoryService, JobRunner, PackageRefreshJob, PackageUpgradeJob,
SettingsService, UserService,
};
use axum::{routing::get, Json, Router};
use domain::jobs::JobKind;
use domain::ports::HostInspector;
use domain::ports::Mailer;
use domain::ports::{HostInspector, HostUpdater};
use infrastructure::{
AesGcmCipher, Argon2Hasher, DbPool, DebianInspector, FakeHostInspector, JwtIssuer,
LettreMailer, SqliteAuditLog, SqliteInventory, SqliteJobRuns, SqliteRefreshTokens,
SqliteSettings, SqliteUsers, SystemCommandRunner,
AesGcmCipher, Argon2Hasher, DbPool, DebianInspector, DebianUpdater, FakeHostInspector,
FakeHostUpdater, JwtIssuer, LettreMailer, SqliteAuditLog, SqliteInventory, SqliteJobRuns,
SqliteRefreshTokens, SqliteSettings, SqliteUsers, SystemCommandRunner,
};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
@ -45,12 +46,17 @@ pub struct AppState {
impl AppState {
/// Wire the services on top of a connected database.
pub fn new(cfg: Config, pool: DbPool) -> anyhow::Result<Self> {
let inspector: Arc<dyn HostInspector> = if cfg.fake_host {
Arc::new(FakeHostInspector)
let runner = Arc::new(SystemCommandRunner);
let (inspector, updater): (Arc<dyn HostInspector>, Arc<dyn HostUpdater>) = if cfg.fake_host
{
(Arc::new(FakeHostInspector), Arc::new(FakeHostUpdater))
} else {
Arc::new(DebianInspector::new(Arc::new(SystemCommandRunner)))
(
Arc::new(DebianInspector::new(runner.clone())),
Arc::new(DebianUpdater::new(runner)),
)
};
Self::with_adapters(cfg, pool, Arc::new(LettreMailer), inspector, |r| r)
Self::with_adapters(cfg, pool, Arc::new(LettreMailer), inspector, updater, |r| r)
}
/// Wiring with replaceable adapters (used by tests and the fake-host mode).
@ -59,6 +65,7 @@ impl AppState {
pool: DbPool,
mailer: Arc<dyn Mailer>,
inspector: Arc<dyn HostInspector>,
updater: Arc<dyn HostUpdater>,
register_jobs: impl FnOnce(JobRunner) -> JobRunner,
) -> anyhow::Result<Self> {
let users = Arc::new(SqliteUsers(pool.clone()));
@ -80,10 +87,18 @@ impl AppState {
inspector,
Arc::new(SqliteInventory(pool.clone())),
));
let runner = JobRunner::new(Arc::new(SqliteJobRuns(pool))).register(
JobKind::PackageRefresh,
Arc::new(PackageRefreshJob(inventory.clone())),
);
let runner = JobRunner::new(Arc::new(SqliteJobRuns(pool)))
.register(
JobKind::PackageRefresh,
Arc::new(PackageRefreshJob(inventory.clone())),
)
.register(
JobKind::PackageUpgrade,
Arc::new(PackageUpgradeJob {
updater,
inventory: inventory.clone(),
}),
);
let jobs = Arc::new(register_jobs(runner));
Ok(Self {
cfg,

View File

@ -26,7 +26,7 @@ impl Modify for BearerAuth {
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,
crate::system::inventory,
crate::system::inventory, crate::system::upgrade,
),
modifiers(&BearerAuth)
)]

View File

@ -1,17 +1,51 @@
//! /api/system: host inventory.
use application::UpgradeParams;
use axum::extract::State;
use axum::routing::get;
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use domain::host::{OsInfo, Package};
use serde::Serialize;
use domain::jobs::JobKind;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::error::ApiError;
use crate::extract::AuthUser;
use crate::extract::{AdminUser, AuthUser};
use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new().route("/inventory", get(inventory))
Router::new()
.route("/inventory", get(inventory))
.route("/upgrade", post(upgrade))
}
#[derive(Deserialize, ToSchema)]
pub struct UpgradeRequest {
/// Empty upgrades all packages.
#[serde(default)]
pub packages: Vec<String>,
}
#[utoipa::path(post, path = "/api/system/upgrade", tag = "system", security(("bearer" = [])), request_body = UpgradeRequest,
responses((status = 202, body = crate::jobs::JobRunDto), (status = 409), (status = 422)))]
async fn upgrade(
State(state): State<AppState>,
AdminUser(admin): AdminUser,
Json(req): Json<UpgradeRequest>,
) -> Result<(StatusCode, Json<crate::jobs::JobRunDto>), ApiError> {
let params = UpgradeParams {
packages: req.packages,
};
params.validate()?;
let run = state
.jobs
.start(
JobKind::PackageUpgrade,
Some(params.to_json()),
&admin.email,
)
.await?;
Ok((StatusCode::ACCEPTED, Json(run.into())))
}
#[derive(Serialize, ToSchema)]

View File

@ -84,6 +84,7 @@ async fn build_test_app_with(cfg: Config) -> Router {
pool,
Arc::new(RecordingMailer),
Arc::new(infrastructure::FakeHostInspector),
Arc::new(infrastructure::FakeHostUpdater),
register_test_jobs,
)
.expect("state");