Files
Skald-Circle/crates/skald-core/src/container/mod.rs
T
dguiducci 2ec394c17c
Nightly Build / build (push) Successful in 6m27s
Projects: shareable, registry-backed, container-mounted; drop ticket board
Rework projects from single-user leftovers into shareable endeavours.

- DB: move `projects` from the owner bucket to the registry (system.db,
  not encrypted); add `owner_user_id` + `slug` (drop free `path`); new
  `project_members(project_id, user_id, can_write)` mirroring
  `shared_folder_members`. Drop `project_tickets` entirely. Only user↔agent
  conversations stay encrypted (per-user DB) — each member keeps a private
  project chat. Registry home dissolves the cross-DB-FK problem.
- Filesystem/container: on disk `{WD}/projects/{owner_userid}/{slug}`,
  agent/container path `projects/{owner_username}/{slug}`. Two-segment routing
  in UserFs (ProjectMount + host_base_and_tail arm) and a second loop in
  build_user_fs; read-only members get a :ro mount. Reuse the shared-folder
  remount machinery (refresh_user_shared_folders -> refresh_user_mounts).
- Remove the ticket system: ProjectTicketManager, UserContext.tickets, its
  wiring, and the project_tickets references in scheduled_jobs/cron.
- API: repoint handlers to the registry pool + membership scoping. Sharing is
  self-service — owner or any write-member may add/remove members and set
  read/write; only the owner deletes; the owner cannot be removed. New
  POST/DELETE /api/projects/{id}/members[/{user_id}]. Seed `@fs_any allow
  projects/*`; build_runtime_run_context sets working_directory to the agent
  path and drops the host-path allow_fs_writes.
- Frontend: create form without the free path field, owner/read-write badges;
  the detail page becomes header + description + sharing panel + Open chat + a
  file-explorer placeholder (the future primary surface). i18n en/it/fr.
2026-07-20 22:21:10 +01:00

315 lines
13 KiB
Rust

//! Per-user Docker containers (blueprint §6): the execution sandbox.
//!
//! Each user gets one **permanent** container (`skald-{userid}`), built from our
//! own image (`skald-runtime`, python + node). The container is created when the
//! user is created and started at application boot; `execute_cmd` and — later —
//! the user's stateful MCP servers run inside it, against the user's bind-mounted
//! home (`{WD}/homes/{userid}` → `/root`) plus the shared folders they belong to.
//!
//! Docker is a **hard requirement**: [`ContainerManager::check_docker`] fails
//! construction if the daemon is unreachable, and the shell exits at boot.
//!
//! We shell out to the `docker` CLI rather than link a Docker client crate: fewer
//! dependencies, and the same process-spawning shape `execute_cmd` already uses.
//! The container holds no durable state — everything lives in the bind mounts — so
//! a container can be recreated from the image at any time; boot reconciliation
//! relies on that.
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use sqlx::SqlitePool;
use core_api::user_fs::{ProjectMount, SharedMount, UserFs};
use crate::db;
/// Our runtime image tag. Built once from the embedded [`Dockerfile`].
const IMAGE_TAG: &str = "skald-runtime";
/// The embedded Dockerfile — the source of truth, so the image can be built with
/// no files shipped alongside the binary (binary-first).
const DOCKERFILE: &str = include_str!("Dockerfile");
/// Subdirectory of the working directory holding per-user homes.
pub const HOMES_DIR: &str = "homes";
/// Subdirectory of the working directory holding shared folders.
pub const SHARED_DIR: &str = "shared";
/// Subdirectory of the working directory holding project folders
/// (`{WD}/projects/{owner_userid}/{slug}`).
pub const PROJECTS_DIR: &str = "projects";
/// Home mount point inside the container.
pub const CONTAINER_HOME: &str = "/root";
/// Grace window `docker stop` gives in-container processes (SIGTERM → SIGKILL)
/// before force-killing — enough for a shell or MCP `docker exec` child to exit.
const STOP_GRACE: Duration = Duration::from_secs(10);
/// The deterministic container name for a user — derivable without any manager,
/// so `UserFs` can carry it and `execute_cmd` can exec into it directly.
pub fn container_name(user_id: &str) -> String {
format!("skald-{user_id}")
}
/// Builds the [`UserFs`] view for a user: private home + the shared folders they
/// belong to, plus the container those mount into. Host paths are absolute
/// (anchored at the process working directory), as Docker bind mounts require.
pub async fn build_user_fs(system: &SqlitePool, user_id: &str) -> Result<UserFs> {
let wd = std::env::current_dir().context("failed to read working directory")?;
let home_host = wd.join(HOMES_DIR).join(user_id);
let container_home = PathBuf::from(CONTAINER_HOME);
let memberships = db::shared_folders::list_for_user(system, user_id).await?;
let shared = memberships
.into_iter()
.map(|m| SharedMount {
container: container_home.join(SHARED_DIR).join(&m.folder_name),
host: wd.join(SHARED_DIR).join(&m.folder_name),
name: m.folder_name,
can_write: m.can_write,
})
.collect();
// Projects (owned + shared-with-them). Host keys on the owner's stable userid; the
// agent/container path keys on the owner's username (`projects/{owner_username}/{slug}`).
let project_rows = db::project_members::list_for_user_mounts(system, user_id).await?;
let projects = project_rows
.into_iter()
.map(|p| ProjectMount {
container: container_home
.join(PROJECTS_DIR)
.join(&p.owner_username)
.join(&p.slug),
host: wd.join(PROJECTS_DIR).join(&p.owner_user_id).join(&p.slug),
owner_username: p.owner_username,
slug: p.slug,
can_write: p.can_write,
})
.collect();
Ok(UserFs::new(user_id, home_host, container_name(user_id), container_home, shared, projects))
}
/// Owns the container lifecycle: the docker availability check, the runtime image,
/// and per-user create/start/stop/remove. Cheap to clone (holds an `Arc` pool).
#[derive(Clone)]
pub struct ContainerManager {
system: Arc<SqlitePool>,
}
impl ContainerManager {
pub fn new(system: Arc<SqlitePool>) -> Self {
Self { system }
}
/// Fails if the Docker daemon is unreachable. Called at boot before anything
/// else; a failure here stops the process (docker is REQUIRED).
pub async fn check_docker(&self) -> Result<()> {
match docker(&["version", "--format", "{{.Server.Version}}"]).await {
Ok(v) => {
crate::boot::section(format!("Docker ready (server {})", v.trim()));
Ok(())
}
Err(e) => bail!(
"Docker is REQUIRED but not available: {e}. \
Install Docker and ensure the daemon is running, then restart."
),
}
}
/// Boot reconciliation: build the image if missing, then ensure every active
/// user has a running container. Idempotent.
pub async fn reconcile_all(&self) -> Result<()> {
self.ensure_image().await?;
let users = db::users::list(&self.system).await?;
let mut started = 0usize;
for user in &users {
if !user.active {
continue;
}
if let Err(e) = self.ensure(&user.id).await {
tracing::error!(user = %user.id, error = %e, "failed to ensure user container");
} else {
started += 1;
}
}
crate::boot::section(format!("User containers ready ({started})"));
Ok(())
}
/// Builds the runtime image if the tag is absent. Writes the embedded
/// Dockerfile to a temp dir and builds from there, so nothing is shipped
/// beside the binary.
pub async fn ensure_image(&self) -> Result<()> {
if docker_ok(&["image", "inspect", IMAGE_TAG]).await {
return Ok(());
}
crate::boot::section(format!("Building container image {IMAGE_TAG} (first run)…"));
let dir = std::env::temp_dir().join(format!("skald-image-{}", std::process::id()));
std::fs::create_dir_all(&dir).context("failed to create image build dir")?;
std::fs::write(dir.join("Dockerfile"), DOCKERFILE).context("failed to write Dockerfile")?;
let dir_str = dir.to_string_lossy().to_string();
let out = docker(&["build", "-t", IMAGE_TAG, &dir_str]).await;
let _ = std::fs::remove_dir_all(&dir);
out.context("docker build failed")?;
Ok(())
}
/// Ensures the user's container exists and is running. Creates the host
/// directories, the container (if missing) with the right bind mounts, and
/// starts it (if stopped). Idempotent — a no-op when already running.
pub async fn ensure(&self, user_id: &str) -> Result<()> {
let fs = build_user_fs(&self.system, user_id).await?;
// Host directories must exist before the mount, or Docker creates them
// root-owned with surprising modes.
for (host, _container, _w) in fs.mounts() {
std::fs::create_dir_all(&host)
.with_context(|| format!("failed to create host dir {}", host.display()))?;
}
let name = &fs.container_name;
match container_state(name).await {
ContainerState::Running => return Ok(()),
ContainerState::Stopped => {
docker(&["start", name]).await.context("docker start failed")?;
return Ok(());
}
ContainerState::Absent => {}
}
let mut args: Vec<String> = vec![
"create".into(),
"--name".into(),
name.clone(),
"--workdir".into(),
fs.container_home.to_string_lossy().into_owned(),
];
for (host, container, writable) in fs.mounts() {
let mut spec = format!("{}:{}", host.display(), container.display());
if !writable {
spec.push_str(":ro");
}
args.push("-v".into());
args.push(spec);
}
args.push(IMAGE_TAG.into());
// Long-lived idle process; nothing runs until `docker exec` drives it.
args.extend(["sleep".into(), "infinity".into()]);
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
docker(&argv).await.context("docker create failed")?;
docker(&["start", name]).await.context("docker start failed")?;
tracing::info!(user = %user_id, container = %name, "user container created and started");
Ok(())
}
/// Stops every user's container (best-effort) at shutdown.
pub async fn stop_all(&self) -> Result<()> {
let users = db::users::list(&self.system).await?;
for user in &users {
let name = container_name(&user.id);
if let Err(e) = docker(&["stop", &name]).await {
tracing::debug!(container = %name, error = %e, "container stop (ignored)");
}
}
Ok(())
}
/// Removes a user's container (force), e.g. on user deletion. Best-effort:
/// a missing container is fine.
pub async fn remove(&self, user_id: &str) -> Result<()> {
let name = container_name(user_id);
let _ = docker(&["rm", "-f", &name]).await;
Ok(())
}
/// Gracefully shuts down a user's container: `docker stop` sends SIGTERM to the
/// in-container processes and waits up to `STOP_GRACE` before SIGKILL, so an
/// in-flight `execute_cmd` shell (and any per-user MCP `docker exec` child) gets
/// a window to exit cleanly instead of vanishing mid-write. Best-effort: a
/// missing or already-stopped container is fine.
pub async fn stop(&self, user_id: &str) -> Result<()> {
let name = container_name(user_id);
let secs = STOP_GRACE.as_secs().to_string();
if let Err(e) = docker(&["stop", "-t", &secs, &name]).await {
tracing::debug!(container = %name, error = %e, "container stop (ignored)");
}
Ok(())
}
/// Cleanly recreates a user's container so it picks up a changed mount topology
/// — e.g. a shared-folder membership change (§6), whose mounts are fixed at
/// `docker create` time and cannot be altered on a live container. Graceful
/// [`stop`](Self::stop) → remove → [`ensure`](Self::ensure) (which rebuilds the
/// mount set from the current memberships and recreates the host dirs). The
/// container holds no durable state — everything lives in the bind mounts — so a
/// recreate is safe by construction. A no-op-safe `rm` (the container is already
/// stopped) precedes `ensure`, which then finds it absent and creates it fresh.
///
/// Caveat (caller's concern, not this method's): the per-user MCP runtime and a
/// logged-in user's `UserFs` snapshot are both bound to the old container/
/// membership and are NOT refreshed here — see the shared-folders remount wiring.
pub async fn recreate(&self, user_id: &str) -> Result<()> {
self.stop(user_id).await?;
let _ = docker(&["rm", &container_name(user_id)]).await;
self.ensure(user_id).await
}
}
// ── docker CLI helpers ────────────────────────────────────────────────────────
#[derive(PartialEq)]
enum ContainerState {
Running,
Stopped,
Absent,
}
/// Reads a container's running state via `docker inspect`.
async fn container_state(name: &str) -> ContainerState {
match docker(&["inspect", "-f", "{{.State.Running}}", name]).await {
Ok(out) if out.trim() == "true" => ContainerState::Running,
Ok(_) => ContainerState::Stopped,
Err(_) => ContainerState::Absent,
}
}
/// Runs `docker <args>`, returning trimmed stdout on success or an error carrying
/// stderr. `stdin` is closed so a build never blocks waiting for input.
async fn docker(args: &[&str]) -> Result<String> {
let output = tokio::process::Command::new("docker")
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.context("failed to spawn `docker` (is the Docker CLI installed?)")?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
let err = String::from_utf8_lossy(&output.stderr);
bail!("docker {:?} failed: {}", args, err.trim());
}
}
/// True when `docker <args>` exits zero. For probes (`image inspect`,
/// `container inspect`) where a non-zero exit just means "absent".
async fn docker_ok(args: &[&str]) -> bool {
tokio::process::Command::new("docker")
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
}