From 1de0ebd7374aaab90b0d0bb845b9c72aa70c08ba Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Sun, 22 Feb 2026 10:52:14 +0800 Subject: [PATCH 1/4] feat(update): add Podman socket detection and container ID support - Replace docker_socket_available() with resolve_container_socket_with() that probes DOCKER_HOST, Docker rootful, Podman rootful, and Podman rootless sockets in priority order - Add socket_path field to UpdateStatus (serde-skipped) so the resolved path is carried through to apply_docker_update - Connect via resolved socket using API v1.40 (Podman compat ceiling) instead of bollard's default; works with both Docker and Podman - Extend mountinfo parsing to match /overlay-containers// (Podman) in addition to /docker/containers// (Docker) - Extract parse_container_id_from_mountinfo for unit testability - Add 12 unit tests covering socket probe priority and mountinfo parsing Co-Authored-By: Claude Sonnet 4.6 --- src/update.rs | 230 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 211 insertions(+), 19 deletions(-) diff --git a/src/update.rs b/src/update.rs index b782fe8dd..6007ad1a7 100644 --- a/src/update.rs +++ b/src/update.rs @@ -48,10 +48,13 @@ pub struct UpdateStatus { pub release_url: Option, pub release_notes: Option, pub deployment: Deployment, - /// Whether the Docker socket is accessible (enables one-click update). + /// Whether the container runtime socket is accessible (enables one-click update). pub can_apply: bool, pub checked_at: Option>, pub error: Option, + /// Resolved container socket path (not exposed in API responses). + #[serde(skip)] + pub socket_path: Option, } impl Default for UpdateStatus { @@ -66,6 +69,7 @@ impl Default for UpdateStatus { can_apply: false, checked_at: None, error: None, + socket_path: None, } } } @@ -75,8 +79,9 @@ pub type SharedUpdateStatus = Arc>; pub fn new_shared_status() -> SharedUpdateStatus { let mut status = UpdateStatus::default(); - // Probe Docker socket availability on init - status.can_apply = status.deployment == Deployment::Docker && docker_socket_available(); + let socket = resolve_container_socket(); + status.can_apply = status.deployment == Deployment::Docker && socket.is_some(); + status.socket_path = socket; Arc::new(ArcSwap::from_pointee(status)) } @@ -97,6 +102,7 @@ pub async fn check_for_update(status: &SharedUpdateStatus) { current_version: CURRENT_VERSION.to_string(), deployment: current.deployment, can_apply: current.can_apply, + socket_path: current.socket_path.clone(), checked_at: Some(chrono::Utc::now()), ..Default::default() }; @@ -177,9 +183,58 @@ fn is_newer_version(latest: &str, current: &str) -> bool { latest > current } -/// Check if the Docker socket is accessible. -fn docker_socket_available() -> bool { - std::path::Path::new("/var/run/docker.sock").exists() +/// Probe for a usable container runtime socket. +/// +/// Checks in order: +/// 1. `DOCKER_HOST` env var (unix:// scheme only — tcp:// is skipped) +/// 2. `/var/run/docker.sock` (Docker rootful) +/// 3. `/run/podman/podman.sock` (Podman rootful) +/// 4. `$XDG_RUNTIME_DIR/podman/podman.sock` (Podman rootless) +/// +/// Returns the path of the first socket that exists, or `None`. +fn resolve_container_socket() -> Option { + resolve_container_socket_with( + std::env::var("DOCKER_HOST").ok().as_deref(), + "/var/run/docker.sock", + "/run/podman/podman.sock", + std::env::var("XDG_RUNTIME_DIR").ok().as_deref(), + ) +} + +fn resolve_container_socket_with( + docker_host: Option<&str>, + docker_rootful: &str, + podman_rootful: &str, + xdg_runtime_dir: Option<&str>, +) -> Option { + // 1. DOCKER_HOST env var (unix:// only — tcp:// and other schemes are skipped) + if let Some(host) = docker_host { + if let Some(path) = host.strip_prefix("unix://") { + if std::path::Path::new(path).exists() { + return Some(path.to_string()); + } + } + } + + // 2. Docker rootful socket + if std::path::Path::new(docker_rootful).exists() { + return Some(docker_rootful.to_string()); + } + + // 3. Podman rootful socket + if std::path::Path::new(podman_rootful).exists() { + return Some(podman_rootful.to_string()); + } + + // 4. Podman rootless socket + if let Some(xdg_runtime) = xdg_runtime_dir { + let podman_rootless = format!("{}/podman/podman.sock", xdg_runtime); + if std::path::Path::new(&podman_rootless).exists() { + return Some(podman_rootless); + } + } + + None } /// Apply a Docker self-update: pull the new image, recreate this container. @@ -199,6 +254,11 @@ pub async fn apply_docker_update(status: &SharedUpdateStatus) -> anyhow::Result< anyhow::bail!("Docker socket not available"); } + let socket_path = current + .socket_path + .as_deref() + .ok_or_else(|| anyhow::anyhow!("container socket path not available"))?; + let latest_version = current .latest_version .as_deref() @@ -210,8 +270,13 @@ pub async fn apply_docker_update(status: &SharedUpdateStatus) -> anyhow::Result< "applying Docker update" ); - let docker = bollard::Docker::connect_with_local_defaults() - .map_err(|e| anyhow::anyhow!("failed to connect to Docker: {}", e))?; + let client_version = bollard::ClientVersion { + major_version: 1, + minor_version: 40, + }; + let docker = + bollard::Docker::connect_with_socket(socket_path, 120, &client_version) + .map_err(|e| anyhow::anyhow!("failed to connect to Docker: {}", e))?; // Determine which image tag this container is running let container_id = get_own_container_id()?; @@ -378,9 +443,16 @@ pub async fn apply_docker_update(status: &SharedUpdateStatus) -> anyhow::Result< std::process::exit(0); } -/// Read this container's ID from /proc/self/cgroup or the hostname. +/// Read this container's ID from /proc/self/mountinfo or the hostname. fn get_own_container_id() -> anyhow::Result { - // In Docker, the hostname is typically the short container ID + // Try /proc/self/mountinfo first — works for both Docker and Podman + if let Ok(content) = std::fs::read_to_string("/proc/self/mountinfo") { + if let Some(id) = parse_container_id_from_mountinfo(&content) { + return Ok(id); + } + } + + // Fall back to the hostname — Docker sets it to the short container ID if let Ok(hostname) = std::fs::read_to_string("/etc/hostname") { let hostname = hostname.trim(); if hostname.len() >= 12 && hostname.chars().all(|c| c.is_ascii_hexdigit()) { @@ -388,23 +460,31 @@ fn get_own_container_id() -> anyhow::Result { } } - // Fall back to /proc/self/mountinfo parsing - if let Ok(content) = std::fs::read_to_string("/proc/self/mountinfo") { + anyhow::bail!("could not determine own container ID") +} + +/// Parse a 64-character hex container ID from /proc/self/mountinfo content. +/// +/// Recognises both Docker (`/docker/containers//`) and Podman +/// (`/overlay-containers//`) storage path patterns. +fn parse_container_id_from_mountinfo(content: &str) -> Option { + for (marker, offset) in &[ + ("/docker/containers/", 19usize), + ("/overlay-containers/", 20usize), + ] { for line in content.lines() { - // Look for docker container ID pattern in mount paths - if let Some(pos) = line.find("/docker/containers/") { - let after = &line[pos + 19..]; + if let Some(pos) = line.find(marker) { + let after = &line[pos + offset..]; if let Some(end) = after.find('/') { let id = &after[..end]; - if id.len() >= 12 { - return Ok(id.to_string()); + if id.len() == 64 && id.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(id.to_string()); } } } } } - - anyhow::bail!("could not determine own container ID") + None } /// Given a current image reference and a new version, produce the target image tag. @@ -456,4 +536,116 @@ mod tests { "ghcr.io/spacedriveapp/spacebot:v0.2.0-slim" ); } + + // ── resolve_container_socket_with ───────────────────────────────────── + + fn fake_socket(dir: &tempfile::TempDir, name: &str) -> String { + let path = dir.path().join(name); + std::fs::File::create(&path).unwrap(); + path.to_str().unwrap().to_string() + } + + #[test] + fn test_resolve_socket_docker_only() { + let dir = tempfile::TempDir::new().unwrap(); + let docker = fake_socket(&dir, "docker.sock"); + let result = resolve_container_socket_with(None, &docker, "/nonexistent", None); + assert_eq!(result.as_deref(), Some(docker.as_str())); + } + + #[test] + fn test_resolve_socket_podman_rootful_only() { + let dir = tempfile::TempDir::new().unwrap(); + let podman = fake_socket(&dir, "podman.sock"); + let result = resolve_container_socket_with(None, "/nonexistent", &podman, None); + assert_eq!(result.as_deref(), Some(podman.as_str())); + } + + #[test] + fn test_resolve_socket_podman_rootless() { + let dir = tempfile::TempDir::new().unwrap(); + let xdg = dir.path().to_str().unwrap().to_string(); + std::fs::create_dir_all(dir.path().join("podman")).unwrap(); + let sock = fake_socket(&dir, "podman/podman.sock"); + let result = + resolve_container_socket_with(None, "/nonexistent", "/nonexistent", Some(&xdg)); + assert_eq!(result, Some(sock)); + } + + #[test] + fn test_resolve_socket_docker_host_unix() { + let dir = tempfile::TempDir::new().unwrap(); + let sock = fake_socket(&dir, "custom.sock"); + let host = format!("unix://{}", sock); + let result = + resolve_container_socket_with(Some(&host), "/nonexistent", "/nonexistent", None); + assert_eq!(result.as_deref(), Some(sock.as_str())); + } + + #[test] + fn test_resolve_socket_docker_host_tcp_skipped() { + let dir = tempfile::TempDir::new().unwrap(); + let docker = fake_socket(&dir, "docker.sock"); + // tcp:// is skipped; falls through to the docker_rootful path + let result = resolve_container_socket_with( + Some("tcp://192.168.1.1:2376"), + &docker, + "/nonexistent", + None, + ); + assert_eq!(result.as_deref(), Some(docker.as_str())); + } + + #[test] + fn test_resolve_socket_none() { + let result = + resolve_container_socket_with(None, "/nonexistent", "/nonexistent", None); + assert_eq!(result, None); + } + + // ── parse_container_id_from_mountinfo ───────────────────────────────── + + const DOCKER_ID: &str = + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + const PODMAN_ID: &str = + "0011223344556677889900112233445566778899001122334455667788990011"; + + #[test] + fn test_parse_mountinfo_docker() { + let content = format!( + "123 0 0:1 / / rw - ext4 /dev/sda rw\n\ + 456 123 0:2 / /var/lib/docker/containers/{}/shm rw\n", + DOCKER_ID + ); + assert_eq!( + parse_container_id_from_mountinfo(&content).as_deref(), + Some(DOCKER_ID) + ); + } + + #[test] + fn test_parse_mountinfo_podman() { + let content = format!( + "123 0 0:1 / / rw - ext4 /dev/sda rw\n\ + 456 123 0:2 / /var/lib/containers/storage/overlay-containers/{}/userdata rw\n", + PODMAN_ID + ); + assert_eq!( + parse_container_id_from_mountinfo(&content).as_deref(), + Some(PODMAN_ID) + ); + } + + #[test] + fn test_parse_mountinfo_no_match() { + let content = "123 0 0:1 / / rw - ext4 /dev/sda rw\n"; + assert_eq!(parse_container_id_from_mountinfo(content), None); + } + + #[test] + fn test_parse_mountinfo_short_id_ignored() { + // A path segment that looks like a container path but has a short ID + let content = "456 123 0:2 / /docker/containers/abc123/shm rw\n"; + assert_eq!(parse_container_id_from_mountinfo(content), None); + } } From 46045bc21387da9eb9bcaa7030902a8d687b14df Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Sun, 22 Feb 2026 10:53:27 +0800 Subject: [PATCH 2/4] feat(update): update UI hint and add Podman documentation - Change banner hint from "Mount docker.sock" to "Mount the container runtime socket" to be runtime-agnostic - Add Podman section to docker.mdx covering quick start, rootful and rootless one-click update socket mounting, podman-compose example, systemctl socket activation, and SPACEBOT_DEPLOYMENT note Co-Authored-By: Claude Sonnet 4.6 --- .../content/docs/(getting-started)/docker.mdx | 90 ++++++++++++++++++- interface/src/components/UpdateBanner.tsx | 2 +- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/(getting-started)/docker.mdx b/docs/content/docs/(getting-started)/docker.mdx index 8b4c2e584..d0637c5c3 100644 --- a/docs/content/docs/(getting-started)/docker.mdx +++ b/docs/content/docs/(getting-started)/docker.mdx @@ -215,7 +215,8 @@ docker compose up -d ### One-Click Update -Mount the Docker socket to enable updating directly from the web UI: +Mount the container runtime socket to enable updating directly from the web UI. +For Docker, mount `/var/run/docker.sock`. For Podman, see the [Podman](#podman) section below. ```yaml services: @@ -277,3 +278,90 @@ fly volumes create spacebot_data --size 5 fly secrets set ANTHROPIC_API_KEY="sk-ant-..." fly deploy ``` + +## Podman + +Spacebot works with Podman as a drop-in replacement for Docker. Set +`SPACEBOT_DEPLOYMENT=docker` (the same value used for Docker) and mount the +Podman socket to enable one-click updates from the web UI. + +### Quick Start + +```bash +podman run -d \ + --name spacebot \ + -e ANTHROPIC_API_KEY="sk-ant-..." \ + -e SPACEBOT_DEPLOYMENT=docker \ + -v spacebot-data:/data \ + -p 19898:19898 \ + ghcr.io/spacedriveapp/spacebot:slim +``` + +### One-Click Updates with Podman + +Spacebot supports both rootful and rootless Podman socket paths. + +**Rootful Podman** — start the socket service and mount it: + +```bash +systemctl enable --now podman.socket +``` + +```bash +podman run -d \ + --name spacebot \ + -e ANTHROPIC_API_KEY="sk-ant-..." \ + -e SPACEBOT_DEPLOYMENT=docker \ + -v spacebot-data:/data \ + -v /run/podman/podman.sock:/run/podman/podman.sock \ + -p 19898:19898 \ + ghcr.io/spacedriveapp/spacebot:slim +``` + +**Rootless Podman** — enable the user socket and pass `XDG_RUNTIME_DIR`: + +```bash +systemctl --user enable --now podman.socket +``` + +```bash +podman run -d \ + --name spacebot \ + -e ANTHROPIC_API_KEY="sk-ant-..." \ + -e SPACEBOT_DEPLOYMENT=docker \ + -e XDG_RUNTIME_DIR=/run/user/$(id -u) \ + -v spacebot-data:/data \ + -v $XDG_RUNTIME_DIR/podman/podman.sock:$XDG_RUNTIME_DIR/podman/podman.sock \ + -p 19898:19898 \ + ghcr.io/spacedriveapp/spacebot:slim +``` + +You can also set `DOCKER_HOST=unix:///path/to/podman.sock` to point Spacebot at +any custom socket location. + +### Podman Compose + +```yaml +services: + spacebot: + image: ghcr.io/spacedriveapp/spacebot:slim + container_name: spacebot + restart: unless-stopped + ports: + - "19898:19898" + volumes: + - spacebot-data:/data + - /run/podman/podman.sock:/run/podman/podman.sock + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - SPACEBOT_DEPLOYMENT=docker + +volumes: + spacebot-data: +``` + +Run with `podman-compose up -d`. + +> **Note:** `SPACEBOT_DEPLOYMENT=docker` is required regardless of whether you +> use Docker or Podman — the value tells Spacebot that it is running inside a +> container and can manage its own lifecycle via the socket. diff --git a/interface/src/components/UpdateBanner.tsx b/interface/src/components/UpdateBanner.tsx index c278ca149..979c98910 100644 --- a/interface/src/components/UpdateBanner.tsx +++ b/interface/src/components/UpdateBanner.tsx @@ -64,7 +64,7 @@ export function UpdateBanner() { )} {!data.can_apply && data.deployment === "docker" && ( - Mount docker.sock for one-click updates + Mount the container runtime socket for one-click updates )}