containers: drive user provisioning and remounts from the system bus
Nightly Build / build (push) Successful in 6m51s
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:
@@ -16,6 +16,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use core_api::system_bus::SystemEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use skald_core::db::project_members::{ProjectAccess, ProjectMember};
|
||||
@@ -143,13 +144,14 @@ fn project_dir(owner_user_id: &str, slug: &str) -> Result<PathBuf, ApiError> {
|
||||
.join(slug))
|
||||
}
|
||||
|
||||
/// Recreate a user's container with the new mount set (best-effort — settles at their
|
||||
/// next login/boot on failure). See [`Skald::refresh_user_mounts`].
|
||||
async fn remount(skald: &Skald, user_id: &str) {
|
||||
if let Err(e) = skald.refresh_user_mounts(user_id).await {
|
||||
tracing::warn!(user = %user_id, error = %e,
|
||||
"project remount failed (settles at next login/boot)");
|
||||
}
|
||||
/// Announces that a user's mount topology changed; the lifecycle reconciler
|
||||
/// recreates their container with the new mount set (best-effort — settles at their
|
||||
/// next login/boot on failure). See `Skald::refresh_user_mounts`, its subscriber.
|
||||
///
|
||||
/// Emitted **after** the membership row is committed, since the reconciler reads
|
||||
/// the current rows.
|
||||
fn remount(skald: &Skald, user_id: &str) {
|
||||
skald.system_bus().send(SystemEvent::UserMountsChanged { user_id: user_id.to_string() });
|
||||
}
|
||||
|
||||
async fn detail(skald: &Skald, project: Project, caller: &str, can_write: bool) -> Result<ProjectDetail, ApiError> {
|
||||
@@ -189,8 +191,9 @@ pub async fn list(
|
||||
}
|
||||
|
||||
/// POST /api/projects — create a project owned by the caller (a private project = one
|
||||
/// member). The folder is created and the caller's container remounted so the agent
|
||||
/// can reach it immediately.
|
||||
/// member). The folder is created synchronously (the explorer reads it host-side, so
|
||||
/// it is browsable at once); the container remount that makes it reachable from
|
||||
/// `execute_cmd` is announced on the bus and lands shortly after.
|
||||
pub async fn create(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
@@ -217,7 +220,7 @@ pub async fn create(
|
||||
// Create the bind-mount source, then remount so the container sees it.
|
||||
std::fs::create_dir_all(project_dir(&auth.user_id, &slug)?)
|
||||
.map_err(|e| ApiError::bad_request(format!("failed to create project directory: {e}")))?;
|
||||
remount(&skald, &auth.user_id).await;
|
||||
remount(&skald, &auth.user_id);
|
||||
|
||||
let d = detail(&skald, project, &auth.user_id, true).await?;
|
||||
Ok((StatusCode::CREATED, Json(d)))
|
||||
@@ -278,7 +281,7 @@ pub async fn delete(
|
||||
// Best-effort: drop the folder; the DB row is already gone.
|
||||
let _ = std::fs::remove_dir_all(project_dir(&project.owner_user_id, &project.slug)?);
|
||||
for m in members {
|
||||
remount(&skald, &m.user_id).await;
|
||||
remount(&skald, &m.user_id);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -300,7 +303,7 @@ pub async fn add_member(
|
||||
}
|
||||
project_members::add_member(skald.db(), p.id, &body.user_id, body.can_write).await?;
|
||||
projects::touch(skald.db(), p.id).await?;
|
||||
remount(&skald, &body.user_id).await;
|
||||
remount(&skald, &body.user_id);
|
||||
|
||||
let members = project_members::members(skald.db(), p.id).await?;
|
||||
Ok(Json(members.into_iter().map(Into::into).collect()))
|
||||
@@ -320,7 +323,7 @@ pub async fn remove_member(
|
||||
}
|
||||
project_members::remove_member(skald.db(), mp.id, &mp.user_id).await?;
|
||||
projects::touch(skald.db(), mp.id).await?;
|
||||
remount(&skald, &mp.user_id).await;
|
||||
remount(&skald, &mp.user_id);
|
||||
|
||||
let members = project_members::members(skald.db(), mp.id).await?;
|
||||
Ok(Json(members.into_iter().map(Into::into).collect()))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, extract::State};
|
||||
use core_api::system_bus::SystemEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use skald_core::skald::Skald;
|
||||
@@ -110,5 +111,12 @@ pub async fn create_user(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The web wizard runs against a *live* server, where boot reconciliation has
|
||||
// already happened — so without this the first admin had no container until the
|
||||
// next restart. One announcement, and the reconciler provisions it like any
|
||||
// other user (blueprint §6). The console shell needs no equivalent: it runs
|
||||
// before the server, and `reconcile_all()` picks the admin up at boot.
|
||||
skald.system_bus().send(SystemEvent::UserCreated { user_id: id.clone() });
|
||||
|
||||
Ok(Json(CreateUserResult { user_id: id }))
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
//! The folder rows + membership live in the registry (`system.db`); the physical
|
||||
//! directory `{WD}/shared/{name}` is created here so the bind-mount has a source
|
||||
//! and the admin can drop files in immediately. Propagating a membership change
|
||||
//! into a *running* container (recreate) and into a logged-in user's fs view +
|
||||
//! system prompt is the follow-on step — see blueprint §6.
|
||||
//! into a *running* container and into a logged-in user's fs view is **not** done
|
||||
//! here: this module only announces `UserMountsChanged` on the system bus, and the
|
||||
//! lifecycle reconciler (`skald::wiring`) reacts (blueprint §6).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::Json;
|
||||
use core_api::system_bus::SystemEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use skald_core::db::{role_capabilities, shared_folders, users};
|
||||
@@ -52,16 +54,16 @@ fn create_shared_dir(name: &str) -> Result<(), ApiError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a membership change to a user's live environment — recreate their
|
||||
/// container with the new mounts and, if they are logged in, refresh their fs view
|
||||
/// + per-user MCP in place (blueprint §6 remount). Best-effort: the membership row
|
||||
/// is already committed, so a Docker hiccup is logged, not surfaced — it settles at
|
||||
/// Announces that a user's mount topology changed. The lifecycle reconciler
|
||||
/// recreates their container with the new mounts and, if they are logged in,
|
||||
/// refreshes their fs view + per-user MCP in place (blueprint §6 remount).
|
||||
///
|
||||
/// Emitted **after** the membership row is committed, since the reconciler reads
|
||||
/// the current rows. Fire-and-forget by contract: a Docker hiccup is the
|
||||
/// reconciler's to log, never this endpoint's to surface — the state settles at
|
||||
/// the user's next login/boot.
|
||||
async fn remount(skald: &Skald, user_id: &str) {
|
||||
if let Err(e) = skald.refresh_user_mounts(user_id).await {
|
||||
tracing::warn!(user = %user_id, error = %e,
|
||||
"shared-folder remount failed (settles at next login/boot)");
|
||||
}
|
||||
fn remount(skald: &Skald, user_id: &str) {
|
||||
skald.system_bus().send(SystemEvent::UserMountsChanged { user_id: user_id.to_string() });
|
||||
}
|
||||
|
||||
// ── response / request types ──────────────────────────────────────────────────
|
||||
@@ -183,7 +185,7 @@ pub async fn delete(
|
||||
// The on-disk directory is deliberately left in place: unsharing a folder must
|
||||
// not destroy the files inside it. The admin removes them by hand if intended.
|
||||
for m in &members {
|
||||
remount(&skald, &m.user_id).await;
|
||||
remount(&skald, &m.user_id);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -214,7 +216,7 @@ pub async fn add_member(
|
||||
}
|
||||
shared_folders::add_member(skald.db(), id, &body.user_id, body.can_write).await?;
|
||||
// Mount the folder into (or re-grant RO/RW inside) the member's environment.
|
||||
remount(&skald, &body.user_id).await;
|
||||
remount(&skald, &body.user_id);
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
@@ -228,6 +230,6 @@ pub async fn remove_member(
|
||||
require_manage(&skald, &auth.user_id).await?;
|
||||
shared_folders::remove_member(skald.db(), id, &user_id).await?;
|
||||
// Unmount the folder from the (former) member's environment.
|
||||
remount(&skald, &user_id).await;
|
||||
remount(&skald, &user_id);
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Json, extract::{Path, State}};
|
||||
use core_api::system_bus::SystemEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use skald_core::db::users::UserSummary;
|
||||
@@ -102,11 +103,10 @@ pub async fn create(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Provision the user's container now (blueprint §6). Best-effort: a failure here
|
||||
// is not fatal to user creation — boot reconciliation will retry.
|
||||
if let Err(e) = skald.container().ensure(&id).await {
|
||||
tracing::warn!(user = %id, error = %e, "failed to provision user container (will retry at next boot)");
|
||||
}
|
||||
// Announce the new user. Provisioning their container (blueprint §6) is the
|
||||
// lifecycle reconciler's job, not this endpoint's — and creation does not wait
|
||||
// on Docker, which was already best-effort here (boot reconciliation retries).
|
||||
skald.system_bus().send(SystemEvent::UserCreated { user_id: id.clone() });
|
||||
|
||||
Ok(Json(CreatedUser { id }))
|
||||
}
|
||||
@@ -174,10 +174,8 @@ pub async fn delete(
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
skald.users().delete_user(&id).await?;
|
||||
// Tear down the user's container (best-effort; a missing one is fine).
|
||||
if let Err(e) = skald.container().remove(&id).await {
|
||||
tracing::warn!(user = %id, error = %e, "failed to remove user container");
|
||||
}
|
||||
// The row is gone; the reconciler tears the container down (a missing one is fine).
|
||||
skald.system_bus().send(SystemEvent::UserDeleted { user_id: id });
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user