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(() =>
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() }}
+
+
+ Close
+
+
+
{{
+ upgradeRun.log || '(waiting for output)'
+ }}
+
+
-
+
+
+ Update selected ({{ selected.length }})
+
+
+ Update all ({{ inv.summary.upgradable }})
+
+
+
+
+
+
+
+
Apply updates?
+
+
+ apt-get will upgrade: {{ confirm.packages.join(', ') }}
+
+ apt-get dist-upgrade will upgrade all {{ inv?.summary.upgradable }} packages.
+ A reboot may be required afterwards.
+
+
+
+ Cancel
+
+
+ Confirm update
+
+
+