WP-11: package and OS updates from the UI
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:
@ -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,
|
||||
|
||||
@ -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)
|
||||
)]
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::host::validate_package_name;
|
||||
use domain::ports::HostUpdater;
|
||||
use domain::ports::{HostUpdater, LineSink};
|
||||
use domain::DomainError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@ -30,8 +30,13 @@ impl UpgradeParams {
|
||||
}
|
||||
|
||||
pub fn from_json(s: Option<&str>) -> Result<Self, DomainError> {
|
||||
let _ = s;
|
||||
todo!()
|
||||
let params = match s.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => Self::default(),
|
||||
Some(s) => serde_json::from_str(s)
|
||||
.map_err(|e| DomainError::Validation(format!("invalid params: {e}")))?,
|
||||
};
|
||||
params.validate()?;
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,9 +45,52 @@ pub struct PackageUpgradeJob {
|
||||
pub inventory: Arc<InventoryService>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl JobHandler for PackageUpgradeJob {
|
||||
async fn run(&self, _params: Option<String>, _log: &dyn JobLog) -> Result<(), String> {
|
||||
todo!()
|
||||
/// Bridges the synchronous `LineSink` of the updater to the async job log.
|
||||
struct ChannelSink(tokio::sync::mpsc::UnboundedSender<String>);
|
||||
|
||||
impl LineSink for ChannelSink {
|
||||
fn line(&self, text: &str) {
|
||||
let _ = self.0.send(text.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl JobHandler for PackageUpgradeJob {
|
||||
async fn run(&self, params: Option<String>, log: &dyn JobLog) -> Result<(), String> {
|
||||
let params = UpgradeParams::from_json(params.as_deref()).map_err(|e| e.to_string())?;
|
||||
if params.packages.is_empty() {
|
||||
log.line("upgrading all packages").await;
|
||||
} else {
|
||||
log.line(&format!("upgrading: {}", params.packages.join(" ")))
|
||||
.await;
|
||||
}
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let sink = ChannelSink(tx);
|
||||
let upgrade = async {
|
||||
let r = self.updater.upgrade(¶ms.packages, &sink).await;
|
||||
drop(sink);
|
||||
r
|
||||
};
|
||||
let drain = async {
|
||||
while let Some(line) = rx.recv().await {
|
||||
log.line(&line).await;
|
||||
}
|
||||
};
|
||||
let (result, _) = tokio::join!(upgrade, drain);
|
||||
result.map_err(|e| e.to_string())?;
|
||||
|
||||
log.line("refreshing inventory").await;
|
||||
let inv = self.inventory.refresh().await.map_err(|e| e.to_string())?;
|
||||
log.line(&format!(
|
||||
"{} packages, {} upgradable",
|
||||
inv.packages.len(),
|
||||
inv.upgradable()
|
||||
))
|
||||
.await;
|
||||
if inv.os.reboot_required {
|
||||
log.line("NOTE: reboot required to complete the update")
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,9 +19,36 @@ impl DebianUpdater {
|
||||
|
||||
#[async_trait]
|
||||
impl HostUpdater for DebianUpdater {
|
||||
async fn upgrade(&self, _packages: &[String], _out: &dyn LineSink) -> Result<(), DomainError> {
|
||||
let _ = &self.runner;
|
||||
todo!()
|
||||
async fn upgrade(&self, packages: &[String], out: &dyn LineSink) -> Result<(), DomainError> {
|
||||
out.line("$ apt-get update");
|
||||
if !self
|
||||
.runner
|
||||
.run_streaming("apt-get", &["update", "-q"], out)
|
||||
.await?
|
||||
{
|
||||
out.line("warning: apt-get update failed, continuing with the current package index");
|
||||
}
|
||||
let mut args: Vec<&str> = vec![
|
||||
"-y",
|
||||
"-q",
|
||||
"-o",
|
||||
"Dpkg::Options::=--force-confdef",
|
||||
"-o",
|
||||
"Dpkg::Options::=--force-confold",
|
||||
];
|
||||
if packages.is_empty() {
|
||||
args.push("dist-upgrade");
|
||||
} else {
|
||||
args.extend(["install", "--only-upgrade"]);
|
||||
args.extend(packages.iter().map(String::as_str));
|
||||
}
|
||||
out.line(&format!("$ apt-get {}", args.join(" ")));
|
||||
if !self.runner.run_streaming("apt-get", &args, out).await? {
|
||||
return Err(DomainError::Unavailable(
|
||||
"apt-get exited with a non-zero status".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user