diff --git a/ROADMAP.md b/ROADMAP.md index 98bc204..1ad664f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -356,7 +356,7 @@ The server is reachable via `ssh softvisor` (as root). Findings from the inspect | WP-01 | M1 | done | 2026-09-02 | | WP-02 | M1 (shell) / M2 (rest) | done | shell 2026-09-02; encrypted settings, SMTP, job runner, scheduler 2026-09-02 | | WP-10 | M2 | done | 2026-09-02; snaps inventoried, no upstream check | -| WP-11 | M2 | todo | | +| WP-11 | M2 | done | 2026-09-02; runs as root via systemd, sudoers scoping deferred to WP-41; log via polling | | WP-12 | M2 | todo | | | WP-20 | M3 | todo | | | WP-21 | M3 | todo | | diff --git a/backend/crates/api/src/lib.rs b/backend/crates/api/src/lib.rs index 6c43809..bbd46af 100644 --- a/backend/crates/api/src/lib.rs +++ b/backend/crates/api/src/lib.rs @@ -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 { - let inspector: Arc = if cfg.fake_host { - Arc::new(FakeHostInspector) + let runner = Arc::new(SystemCommandRunner); + let (inspector, updater): (Arc, Arc) = 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, inspector: Arc, + updater: Arc, register_jobs: impl FnOnce(JobRunner) -> JobRunner, ) -> anyhow::Result { 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, diff --git a/backend/crates/api/src/openapi.rs b/backend/crates/api/src/openapi.rs index 3c3c3a6..ffe7251 100644 --- a/backend/crates/api/src/openapi.rs +++ b/backend/crates/api/src/openapi.rs @@ -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) )] diff --git a/backend/crates/api/src/system.rs b/backend/crates/api/src/system.rs index 2403e85..27846db 100644 --- a/backend/crates/api/src/system.rs +++ b/backend/crates/api/src/system.rs @@ -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 { - 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, +} + +#[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, + AdminUser(admin): AdminUser, + Json(req): Json, +) -> Result<(StatusCode, Json), 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)] diff --git a/backend/crates/api/src/test_support.rs b/backend/crates/api/src/test_support.rs index f3ab69d..1c95b91 100644 --- a/backend/crates/api/src/test_support.rs +++ b/backend/crates/api/src/test_support.rs @@ -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"); diff --git a/backend/crates/application/src/upgrade_service.rs b/backend/crates/application/src/upgrade_service.rs index 6b4334e..395b49f 100644 --- a/backend/crates/application/src/upgrade_service.rs +++ b/backend/crates/application/src/upgrade_service.rs @@ -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 { - 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, } -#[async_trait] -impl JobHandler for PackageUpgradeJob { - async fn run(&self, _params: Option, _log: &dyn JobLog) -> Result<(), String> { - todo!() +/// Bridges the synchronous `LineSink` of the updater to the async job log. +struct ChannelSink(tokio::sync::mpsc::UnboundedSender); + +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, 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(()) } } diff --git a/backend/crates/infrastructure/src/host/updater.rs b/backend/crates/infrastructure/src/host/updater.rs index a3b3de4..f9d359f 100644 --- a/backend/crates/infrastructure/src/host/updater.rs +++ b/backend/crates/infrastructure/src/host/updater.rs @@ -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(()) } } diff --git a/frontend/src/components/PackageTable.vue b/frontend/src/components/PackageTable.vue index 7406aa5..6e90d9f 100644 --- a/frontend/src/components/PackageTable.vue +++ b/frontend/src/components/PackageTable.vue @@ -2,8 +2,16 @@ import { computed, ref } from 'vue' import type { Package } from '../api/types' -const props = defineProps<{ packages: Package[] }>() +const props = defineProps<{ packages: Package[]; selectable?: boolean }>() +const emit = defineEmits<{ 'update:selected': [names: string[]] }>() +const selected = ref>(new Set()) const search = ref('') + +function toggle(name: string, on: boolean) { + if (on) selected.value.add(name) + else selected.value.delete(name) + emit('update:selected', [...selected.value]) +} const upgradableOnly = ref(false) const source = ref<'all' | 'apt' | 'snap'>('all') @@ -52,6 +60,7 @@ const filtered = computed(() => + @@ -66,6 +75,16 @@ const filtered = computed(() => class="border-b border-gray-100" :class="p.candidate ? 'bg-amber-50' : ''" > + @@ -79,7 +98,9 @@ const filtered = computed(() => - +
Package Source Installed + + {{ p.name }} {{ p.source }} {{ p.installed }}
No packages match. + No packages match. +
diff --git a/frontend/src/pages/UpdatesPage.vue b/frontend/src/pages/UpdatesPage.vue index 982b158..fd0fe6a 100644 --- a/frontend/src/pages/UpdatesPage.vue +++ b/frontend/src/pages/UpdatesPage.vue @@ -10,7 +10,11 @@ const auth = useAuthStore() const toast = useToastStore() const inv = ref(null) const refreshing = ref(false) +const selected = ref([]) +const confirm = ref<{ packages: string[] } | null>(null) +const upgradeRun = ref(null) let timer: ReturnType | undefined +let upgradeTimer: ReturnType | undefined async function load() { inv.value = await api.get('/api/system/inventory') @@ -19,7 +23,10 @@ async function load() { onMounted(() => { load().catch((e) => toast.error(e instanceof ApiError ? e.message : 'Request failed')) }) -onUnmounted(() => clearInterval(timer)) +onUnmounted(() => { + clearInterval(timer) + clearInterval(upgradeTimer) +}) async function refresh() { refreshing.value = true @@ -44,6 +51,29 @@ async function refresh() { } } +async function startUpgrade() { + if (!confirm.value) return + const packages = confirm.value.packages + confirm.value = null + try { + upgradeRun.value = await api.post('/api/system/upgrade', { packages }) + upgradeTimer = setInterval(async () => { + if (!upgradeRun.value) return + const r = await api.get(`/api/jobs/${upgradeRun.value.id}`) + upgradeRun.value = r + if (r.status !== 'running') { + clearInterval(upgradeTimer) + selected.value = [] + if (r.status === 'success') toast.success('Update finished') + else toast.error('Update failed, see the log') + await load() + } + }, 1000) + } catch (e) { + toast.error(e instanceof ApiError ? e.message : 'Request failed') + } +} + const uptime = (s: number) => { const d = Math.floor(s / 86400) const h = Math.floor((s % 86400) / 3600) @@ -99,7 +129,75 @@ const uptime = (s: number) => { No inventory yet. Run a refresh to query the host.

+
+
+ + Update + {{ + upgradeRun.status + }} + ยท started {{ new Date(upgradeRun.started_at).toLocaleTimeString() }} + + +
+
{{
+      upgradeRun.log || '(waiting for output)'
+    }}
+
+
- +
+ + +
+ +
+ +
+