containers: drive user provisioning and remounts from the system bus
Nightly Build / build (push) Successful in 6m51s

The endpoints that changed a user or a membership row also reached into
ContainerManager themselves: users_mgmt called ensure()/remove(), and both
shared_folders and projects called refresh_user_mounts through a local
remount() helper. Every future endpoint that grants membership would have
had to remember to do the same.

Announce instead. SystemEventBus gains UserCreated / UserDeleted /
UserMountsChanged, emitted after the DB write, and one subscriber —
wiring::spawn_user_lifecycle — does the Docker work: sequentially (which
serialises concurrent operations on the same container), best-effort by
contract (the row is already committed, so a hiccup settles at the user's
next login or at boot reconciliation), and holding only a Weak<Skald>. It
is spawned after construction, like set_skald, because it reacts through
Skald's own accessors.

Also fixes a real gap the event makes impossible to repeat: the web setup
wizard created the first admin without provisioning a container. It runs
against a live server, where reconcile_all() has already happened, so that
admin had no sandbox until the next restart. It now emits UserCreated like
any other creator; the console shell needs no equivalent, since it runs
before the server and boot reconciliation covers it.

Two behaviour changes: POST /api/users and POST /api/projects no longer
wait on Docker before responding. Provisioning was already best-effort, and
a new project's folder is still created synchronously, so the explorer —
which reads host-side — shows it at once; only execute_cmd reachability
lands a moment later.
This commit is contained in:
2026-07-26 21:57:25 +01:00
parent cf5415ae88
commit c50a0d84da
8 changed files with 153 additions and 45 deletions
+72 -3
View File
@@ -4,12 +4,14 @@
//!
//! Owner-bound background loops (cron, session-cancel, ticket-listener, tic) have
//! moved per-user into `UserContextFactory::build`. What remains here are the
//! instance-wide tasks: LLM-log cleanup on the registry pool, and MCP server
//! initialization.
//! instance-wide tasks: LLM-log cleanup on the registry pool, MCP server
//! initialization, and the user-lifecycle reconciler (which needs the finished
//! `Arc<Skald>` and is therefore spawned separately, after construction).
use std::sync::Arc;
use tracing::info;
use core_api::system_bus::{RecvError, SystemEvent};
use tracing::{info, warn};
use crate::config::CoreConfig;
use crate::elicitation::ElicitationBridge;
@@ -77,3 +79,70 @@ pub(super) fn spawn_background(
});
}
}
/// Spawns the **user-lifecycle reconciler** — the single subscriber that turns
/// user and membership events into container work (blueprint §6).
///
/// Producers (the Users admin page, the setup wizard, the shared-folder and
/// project membership endpoints) only announce *what changed*; none of them
/// reaches into [`ContainerManager`](crate::container::ContainerManager). That
/// is the point of routing this through the bus rather than calling the manager
/// from each handler: a future endpoint that grants membership cannot forget to
/// remount, because remounting was never its job.
///
/// Every reaction is **best-effort by contract**: the row is already committed
/// when the event fires, so a Docker hiccup is logged, never surfaced to the
/// caller — the state settles at the user's next login or at boot
/// reconciliation. Events are handled **sequentially**, which also serialises
/// concurrent `docker` operations on the same container.
///
/// Spawned after `Skald` is fully built (like `set_skald`) because
/// [`Skald::refresh_user_mounts`] is an accessor on the finished instance. The
/// back-reference is [`std::sync::Weak`], so this task never keeps `Skald` alive.
pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
let weak = Arc::downgrade(skald);
let shutdown = skald.rt.shutdown_token.clone();
let mut rx = skald.rt.system_bus.subscribe();
skald.rt.supervisor.spawn("user-lifecycle", async move {
loop {
let event = tokio::select! {
_ = shutdown.cancelled() => break,
event = rx.recv() => match event {
Ok(e) => e,
// A dropped event costs a stale container until the user's next
// login/boot — never a lost row, since the DB write came first.
Err(RecvError::Lagged(n)) => {
warn!(n, "user-lifecycle: system_bus lagged; container state may be stale until next login/boot");
continue;
}
Err(RecvError::Closed) => break,
},
};
let Some(skald) = weak.upgrade() else { break };
match event {
SystemEvent::UserCreated { user_id } => {
if let Err(e) = skald.container().ensure(&user_id).await {
warn!(user = %user_id, error = %e,
"user-lifecycle: failed to provision container (retried at next boot)");
}
}
SystemEvent::UserDeleted { user_id } => {
if let Err(e) = skald.container().remove(&user_id).await {
warn!(user = %user_id, error = %e,
"user-lifecycle: failed to remove container");
}
}
SystemEvent::UserMountsChanged { user_id } => {
if let Err(e) = skald.refresh_user_mounts(&user_id).await {
warn!(user = %user_id, error = %e,
"user-lifecycle: remount failed (settles at next login/boot)");
}
}
_ => {}
}
}
info!("user-lifecycle: reconciler stopped");
});
}