Step 5: btrfs Workspace Management — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Master image import, workspace creation via btrfs snapshots, list/inspect/delete — all behind a filesystem-agnostic WorkspaceBackend trait, with REST endpoints and CLI commands.
Architecture: A new WorkspaceBackend trait abstracts all filesystem operations (import image, create snapshot, delete, list, inspect). BtrfsBackend implements this trait using libbtrfsutil for btrfs subvolume operations. The database tracks master images and workspaces via new master_images and workspaces tables. nexusd gains REST routes for images and workspaces. nexusctl gains image and ws subcommands. Each workspace subvolume contains a raw ext4 image file created by mke2fs -d, enabling Firecracker block device exposure while preserving btrfs CoW at the host layer.
Tech Stack (additions to existing):
libbtrfsutil0.7 — Rust bindings tolibbtrfsutilfor subvolume create, snapshot, delete, and inspection via ioctls. Requireslibbtrfsutilandlibclanginstalled on the host at build time.
XDG Directory Layout:
$XDG_DATA_HOME/nexus/ (~/.local/share/nexus/)
├── workspaces/
│ ├── @base-agent/ ← read-only master image (btrfs subvolume)
│ ├── @work-code-1/ ← CoW snapshot of @base-agent
│ └── @portal-openclaw/ ← CoW snapshot of portal master
└── images/
└── vmlinux ← kernel (future step)
See data model for the full master_images and workspaces table definitions.
See drives architecture for the btrfs-backed storage design.
See CLI design for image and workspace command grammar.
Task 1: Add libbtrfsutil Dependency to nexus-lib
Files:
- Modify:
nexus/nexus-lib/Cargo.toml
Step 1: Add libbtrfsutil dependency
# nexus/nexus-lib/Cargo.toml
[dependencies]
# ... existing deps ...
libbtrfsutil = "0.7"
Step 2: Verify build
Run: cd ~/Work/WorkFort/nexus && mise run check
Expected: Compiles with no errors. (Requires libbtrfsutil and libclang installed on the host.)
Step 3: Commit
cd ~/Work/WorkFort/nexus
git add nexus-lib/Cargo.toml Cargo.lock
git commit -m "chore(nexus-lib): add libbtrfsutil dependency for btrfs workspace operations"
Task 2: Add master_images and workspaces Tables to Schema
Files:
- Modify:
nexus/nexus-lib/src/store/schema.rs - Modify:
nexus/nexus-lib/src/store/sqlite.rs - Modify:
nexus/nexusd/tests/daemon.rs
Step 1: Update SCHEMA_VERSION and SCHEMA_SQL
Bump the schema version from 2 to 3 and append the master_images and workspaces tables.
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/store/schema.rs
/// Schema version — increment when the schema changes.
/// Pre-alpha migration strategy: if the stored version doesn't match,
/// delete the DB and recreate.
pub const SCHEMA_VERSION: u32 = 3;
/// Database schema. Executed as a single batch on first start.
/// Domain tables are added by later steps — each step bumps SCHEMA_VERSION
/// and appends its tables here. Pre-alpha migration (delete + recreate)
/// means all tables are always created from this single constant.
pub const SCHEMA_SQL: &str = r#"
-- Schema version tracking
CREATE TABLE schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Application settings (key-value store)
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('string', 'int', 'bool', 'json'))
);
-- VMs: Firecracker microVM instances
CREATE TABLE vms (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
role TEXT NOT NULL CHECK(role IN ('portal', 'work', 'service')),
state TEXT NOT NULL CHECK(state IN ('created', 'running', 'stopped', 'crashed', 'failed')),
cid INTEGER NOT NULL UNIQUE,
vcpu_count INTEGER NOT NULL DEFAULT 1,
mem_size_mib INTEGER NOT NULL DEFAULT 128,
config_json TEXT,
pid INTEGER,
socket_path TEXT,
uds_path TEXT,
console_log_path TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
started_at INTEGER,
stopped_at INTEGER
);
CREATE INDEX idx_vms_role ON vms(role);
CREATE INDEX idx_vms_state ON vms(state);
-- Master images: read-only btrfs subvolumes
CREATE TABLE master_images (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
subvolume_path TEXT NOT NULL UNIQUE,
size_bytes INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- Workspaces: btrfs subvolume snapshots
CREATE TABLE workspaces (
id TEXT PRIMARY KEY,
name TEXT UNIQUE,
vm_id TEXT,
subvolume_path TEXT NOT NULL UNIQUE,
master_image_id TEXT,
parent_workspace_id TEXT,
size_bytes INTEGER,
is_root_device INTEGER NOT NULL DEFAULT 0 CHECK(is_root_device IN (0, 1)),
is_read_only INTEGER NOT NULL DEFAULT 0 CHECK(is_read_only IN (0, 1)),
attached_at INTEGER,
detached_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (vm_id) REFERENCES vms(id) ON DELETE SET NULL,
FOREIGN KEY (master_image_id) REFERENCES master_images(id) ON DELETE RESTRICT,
FOREIGN KEY (parent_workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL
);
CREATE INDEX idx_workspaces_vm_id ON workspaces(vm_id);
CREATE INDEX idx_workspaces_base ON workspaces(master_image_id);
"#;
}
Step 2: Update table count expectations in existing tests
In nexus/nexus-lib/src/store/sqlite.rs, update the following tests:
init_creates_all_tables: changeassert_eq!(status.table_count, 3, ...)toassert_eq!(status.table_count, 5, ...)(schema_meta, settings, vms, master_images, workspaces)init_is_idempotent: changeassert_eq!(status.table_count, 3)toassert_eq!(status.table_count, 5)schema_mismatch_triggers_recreate: changeassert_eq!(status.table_count, 3, ...)toassert_eq!(status.table_count, 5, ...)
In nexus/nexusd/tests/daemon.rs, update:
daemon_starts_serves_health_and_stops: changeassert_eq!(body["database"]["table_count"], 3)toassert_eq!(body["database"]["table_count"], 5)
Step 3: Run workspace tests
Run: cd ~/Work/WorkFort/nexus && mise run test
Expected: All tests PASS.
Step 4: Commit
cd ~/Work/WorkFort/nexus
git add nexus-lib/src/store/schema.rs nexus-lib/src/store/sqlite.rs nexusd/tests/daemon.rs
git commit -m "feat(nexus-lib): add master_images and workspaces tables to schema (v3)"
Task 3: Master Image and Workspace Domain Types
Files:
- Create:
nexus/nexus-lib/src/workspace.rs - Modify:
nexus/nexus-lib/src/lib.rs
Step 1: Create the workspace module with domain types and tests
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/workspace.rs
use serde::{Deserialize, Serialize};
/// A master image: a read-only btrfs subvolume registered in the database.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MasterImage {
pub id: String,
pub name: String,
pub subvolume_path: String,
pub size_bytes: Option<i64>,
pub created_at: i64,
}
/// Parameters for importing a master image.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportImageParams {
/// Human-readable name for the image
pub name: String,
/// Path to the directory to import as a btrfs subvolume
pub source_path: String,
}
/// A workspace: a btrfs subvolume snapshot, optionally attached to a VM.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub id: String,
pub name: Option<String>,
pub vm_id: Option<String>,
pub subvolume_path: String,
pub master_image_id: Option<String>,
pub parent_workspace_id: Option<String>,
pub size_bytes: Option<i64>,
pub is_root_device: bool,
pub is_read_only: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub attached_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detached_at: Option<i64>,
pub created_at: i64,
}
/// Parameters for creating a workspace from a master image.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateWorkspaceParams {
/// Workspace name (optional, auto-generated if omitted)
pub name: Option<String>,
/// Name of the master image to snapshot from
pub base: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn master_image_serializes() {
let img = MasterImage {
id: "img-1".to_string(),
name: "base-agent".to_string(),
subvolume_path: "/data/workspaces/@base-agent".to_string(),
size_bytes: Some(1024 * 1024),
created_at: 1000,
};
let json = serde_json::to_string(&img).unwrap();
assert!(json.contains("base-agent"));
assert!(json.contains("1048576"));
}
#[test]
fn workspace_serializes_without_none_fields() {
let ws = Workspace {
id: "ws-1".to_string(),
name: Some("my-ws".to_string()),
vm_id: None,
subvolume_path: "/data/workspaces/@my-ws".to_string(),
master_image_id: Some("img-1".to_string()),
parent_workspace_id: None,
size_bytes: None,
is_root_device: false,
is_read_only: false,
attached_at: None,
detached_at: None,
created_at: 2000,
};
let json = serde_json::to_string(&ws).unwrap();
assert!(json.contains("my-ws"));
assert!(!json.contains("attached_at"));
assert!(!json.contains("detached_at"));
}
#[test]
fn import_params_deserializes() {
let json = r#"{"name": "base", "source_path": "/tmp/rootfs"}"#;
let params: ImportImageParams = serde_json::from_str(json).unwrap();
assert_eq!(params.name, "base");
assert_eq!(params.source_path, "/tmp/rootfs");
}
#[test]
fn create_workspace_params_deserializes_with_name() {
let json = r#"{"name": "my-ws", "base": "base-agent"}"#;
let params: CreateWorkspaceParams = serde_json::from_str(json).unwrap();
assert_eq!(params.name, Some("my-ws".to_string()));
assert_eq!(params.base, "base-agent");
}
#[test]
fn create_workspace_params_deserializes_without_name() {
let json = r#"{"base": "base-agent"}"#;
let params: CreateWorkspaceParams = serde_json::from_str(json).unwrap();
assert!(params.name.is_none());
assert_eq!(params.base, "base-agent");
}
}
}
Step 2: Export the module from lib.rs
Add pub mod workspace; to nexus/nexus-lib/src/lib.rs:
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/lib.rs
pub mod client;
pub mod config;
pub mod store;
pub mod vm;
pub mod workspace;
#[cfg(feature = "test-support")]
pub mod test_support;
}
Step 3: Run tests to verify they pass
Run: cd ~/Work/WorkFort/nexus && cargo test -p nexus-lib workspace::tests
Expected: All 5 tests PASS.
Step 4: Commit
cd ~/Work/WorkFort/nexus
git add nexus-lib/src/workspace.rs nexus-lib/src/lib.rs
git commit -m "feat(nexus-lib): add MasterImage and Workspace domain types"
Task 4: WorkspaceBackend Trait
Files:
- Create:
nexus/nexus-lib/src/backend/mod.rs - Create:
nexus/nexus-lib/src/backend/traits.rs - Modify:
nexus/nexus-lib/src/lib.rs
This trait abstracts all filesystem operations so btrfs can be swapped for another backend (e.g., plain directory copy, ZFS, OverlayFS) in the future. Similar to how StateStore abstracts over SQLite.
Step 1: Create the backend trait
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/backend/traits.rs
use std::path::{Path, PathBuf};
/// Errors from workspace backend operations.
#[derive(Debug)]
pub enum BackendError {
/// The source path does not exist or is inaccessible
NotFound(String),
/// A subvolume/image with this name already exists
AlreadyExists(String),
/// Filesystem operation failed
Io(String),
/// The backend does not support this operation
Unsupported(String),
}
impl std::fmt::Display for BackendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BackendError::NotFound(e) => write!(f, "not found: {e}"),
BackendError::AlreadyExists(e) => write!(f, "already exists: {e}"),
BackendError::Io(e) => write!(f, "I/O error: {e}"),
BackendError::Unsupported(e) => write!(f, "unsupported: {e}"),
}
}
}
impl std::error::Error for BackendError {}
/// Information about a subvolume/snapshot returned by the backend.
#[derive(Debug, Clone)]
pub struct SubvolumeInfo {
pub path: PathBuf,
pub read_only: bool,
/// Size in bytes, if available
pub size_bytes: Option<u64>,
}
/// Filesystem-agnostic trait for workspace storage operations.
///
/// The btrfs implementation uses subvolumes and CoW snapshots.
/// Other implementations could use directory copies, ZFS, OverlayFS, etc.
///
/// This trait is `Send + Sync` so it can be stored in `AppState` and
/// shared across async tasks.
pub trait WorkspaceBackend: Send + Sync {
/// Import a directory as a new master image subvolume.
///
/// 1. Creates a new subvolume at `dest` (under the workspaces root)
/// 2. Copies contents from `source` into the subvolume
/// 3. Marks the subvolume as read-only
///
/// Returns the path to the created subvolume.
fn import_image(&self, source: &Path, dest: &Path) -> Result<SubvolumeInfo, BackendError>;
/// Create a snapshot of an existing subvolume.
///
/// For btrfs: `btrfs subvolume snapshot source dest`
/// The snapshot is writable by default.
fn create_snapshot(&self, source: &Path, dest: &Path) -> Result<SubvolumeInfo, BackendError>;
/// Delete a subvolume/snapshot.
fn delete_subvolume(&self, path: &Path) -> Result<(), BackendError>;
/// Check if a path is a valid subvolume managed by this backend.
fn is_subvolume(&self, path: &Path) -> Result<bool, BackendError>;
/// Get information about a subvolume.
fn subvolume_info(&self, path: &Path) -> Result<SubvolumeInfo, BackendError>;
/// Set whether a subvolume is read-only.
fn set_read_only(&self, path: &Path, read_only: bool) -> Result<(), BackendError>;
}
}
Step 2: Create the backend module
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/backend/mod.rs
pub mod traits;
}
Step 3: Export from lib.rs
Update nexus/nexus-lib/src/lib.rs:
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/lib.rs
pub mod backend;
pub mod client;
pub mod config;
pub mod store;
pub mod vm;
pub mod workspace;
#[cfg(feature = "test-support")]
pub mod test_support;
}
Step 4: Verify build
Run: cd ~/Work/WorkFort/nexus && mise run check
Expected: Compiles with no errors.
Step 5: Commit
cd ~/Work/WorkFort/nexus
git add nexus-lib/src/backend/mod.rs nexus-lib/src/backend/traits.rs nexus-lib/src/lib.rs
git commit -m "feat(nexus-lib): add WorkspaceBackend trait for filesystem-agnostic storage operations"
Task 5: BtrfsBackend Implementation
Files:
- Create:
nexus/nexus-lib/src/backend/btrfs.rs - Modify:
nexus/nexus-lib/src/backend/mod.rs
Step 1: Implement BtrfsBackend
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/backend/btrfs.rs
use crate::backend::traits::{BackendError, SubvolumeInfo, WorkspaceBackend};
use std::path::{Path, PathBuf};
/// btrfs-backed implementation of WorkspaceBackend.
///
/// Uses libbtrfsutil for subvolume operations. Common operations
/// (create, snapshot) work unprivileged — no CAP_SYS_ADMIN required.
pub struct BtrfsBackend {
/// Root directory for all workspace subvolumes.
/// Typically $XDG_DATA_HOME/nexus/workspaces/
workspaces_root: PathBuf,
}
impl BtrfsBackend {
pub fn new(workspaces_root: PathBuf) -> Result<Self, BackendError> {
// Ensure the workspaces root directory exists
std::fs::create_dir_all(&workspaces_root).map_err(|e| {
BackendError::Io(format!(
"cannot create workspaces directory {}: {e}",
workspaces_root.display()
))
})?;
Ok(BtrfsBackend { workspaces_root })
}
pub fn workspaces_root(&self) -> &Path {
&self.workspaces_root
}
}
impl WorkspaceBackend for BtrfsBackend {
fn import_image(&self, source: &Path, dest: &Path) -> Result<SubvolumeInfo, BackendError> {
// Validate source exists
if !source.exists() {
return Err(BackendError::NotFound(format!(
"source path does not exist: {}",
source.display()
)));
}
// Check dest doesn't already exist
if dest.exists() {
return Err(BackendError::AlreadyExists(format!(
"destination already exists: {}",
dest.display()
)));
}
// Create a subvolume at dest
libbtrfsutil::create_subvolume(dest).map_err(|e| {
BackendError::Io(format!(
"cannot create subvolume at {}: {e}",
dest.display()
))
})?;
// Copy contents from source into the new subvolume
copy_dir_contents(source, dest).map_err(|e| {
// Clean up the subvolume on failure
let _ = libbtrfsutil::delete_subvolume(dest);
BackendError::Io(format!(
"cannot copy contents from {} to {}: {e}",
source.display(),
dest.display()
))
})?;
// Mark as read-only
libbtrfsutil::set_subvolume_read_only(dest, true).map_err(|e| {
BackendError::Io(format!(
"cannot set read-only on {}: {e}",
dest.display()
))
})?;
Ok(SubvolumeInfo {
path: dest.to_path_buf(),
read_only: true,
size_bytes: dir_size(dest).ok(),
})
}
fn create_snapshot(&self, source: &Path, dest: &Path) -> Result<SubvolumeInfo, BackendError> {
// Validate source is a subvolume
if !libbtrfsutil::is_subvolume(source).unwrap_or(false) {
return Err(BackendError::NotFound(format!(
"source is not a btrfs subvolume: {}",
source.display()
)));
}
// Check dest doesn't already exist
if dest.exists() {
return Err(BackendError::AlreadyExists(format!(
"destination already exists: {}",
dest.display()
)));
}
// Create a writable snapshot
libbtrfsutil::CreateSnapshotOptions::new()
.create(source, dest)
.map_err(|e| {
BackendError::Io(format!(
"cannot create snapshot from {} to {}: {e}",
source.display(),
dest.display()
))
})?;
Ok(SubvolumeInfo {
path: dest.to_path_buf(),
read_only: false,
size_bytes: dir_size(dest).ok(),
})
}
fn delete_subvolume(&self, path: &Path) -> Result<(), BackendError> {
if !path.exists() {
return Err(BackendError::NotFound(format!(
"subvolume does not exist: {}",
path.display()
)));
}
// If read-only, make writable first (required for deletion)
if libbtrfsutil::subvolume_read_only(path).unwrap_or(false) {
libbtrfsutil::set_subvolume_read_only(path, false).map_err(|e| {
BackendError::Io(format!(
"cannot unset read-only on {}: {e}",
path.display()
))
})?;
}
libbtrfsutil::delete_subvolume(path).map_err(|e| {
BackendError::Io(format!(
"cannot delete subvolume {}: {e}",
path.display()
))
})
}
fn is_subvolume(&self, path: &Path) -> Result<bool, BackendError> {
Ok(libbtrfsutil::is_subvolume(path).unwrap_or(false))
}
fn subvolume_info(&self, path: &Path) -> Result<SubvolumeInfo, BackendError> {
if !path.exists() {
return Err(BackendError::NotFound(format!(
"path does not exist: {}",
path.display()
)));
}
let read_only = libbtrfsutil::subvolume_read_only(path).map_err(|e| {
BackendError::Io(format!(
"cannot query subvolume {}: {e}",
path.display()
))
})?;
Ok(SubvolumeInfo {
path: path.to_path_buf(),
read_only,
size_bytes: dir_size(path).ok(),
})
}
fn set_read_only(&self, path: &Path, read_only: bool) -> Result<(), BackendError> {
libbtrfsutil::set_subvolume_read_only(path, read_only).map_err(|e| {
BackendError::Io(format!(
"cannot set read-only={read_only} on {}: {e}",
path.display()
))
})
}
}
/// Recursively copy directory contents from src to dest.
/// dest must already exist.
fn copy_dir_contents(src: &Path, dest: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if entry.file_type()?.is_dir() {
std::fs::create_dir_all(&dest_path)?;
copy_dir_contents(&src_path, &dest_path)?;
} else {
std::fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
/// Estimate directory size by summing file sizes.
fn dir_size(path: &Path) -> std::io::Result<u64> {
let mut total = 0u64;
if path.is_file() {
return Ok(std::fs::metadata(path)?.len());
}
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let meta = entry.metadata()?;
if meta.is_file() {
total += meta.len();
} else if meta.is_dir() {
total += dir_size(&entry.path())?;
}
}
Ok(total)
}
}
Step 2: Export from mod.rs
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/backend/mod.rs
pub mod btrfs;
pub mod traits;
}
Step 3: Verify build
Run: cd ~/Work/WorkFort/nexus && mise run check
Expected: Compiles with no errors.
Step 4: Commit
cd ~/Work/WorkFort/nexus
git add nexus-lib/src/backend/btrfs.rs nexus-lib/src/backend/mod.rs
git commit -m "feat(nexus-lib): implement BtrfsBackend using libbtrfsutil"
Task 6: Image and Workspace Store Methods
Files:
- Modify:
nexus/nexus-lib/src/store/traits.rs - Modify:
nexus/nexus-lib/src/store/sqlite.rs - Modify:
nexus/nexusd/src/api.rs(mock stores)
Step 1: Extend the StateStore trait
Add these imports and methods to nexus/nexus-lib/src/store/traits.rs:
#![allow(unused)]
fn main() {
// Add to imports at top of traits.rs
use crate::workspace::{ImportImageParams, MasterImage, Workspace};
// Add these methods to the StateStore trait, after the existing VM methods:
// --- Master Image methods ---
/// Register a master image in the database.
fn create_image(&self, params: &ImportImageParams, subvolume_path: &str) -> Result<MasterImage, StoreError>;
/// List all master images.
fn list_images(&self) -> Result<Vec<MasterImage>, StoreError>;
/// Get a master image by name or ID.
fn get_image(&self, name_or_id: &str) -> Result<Option<MasterImage>, StoreError>;
/// Delete a master image by name or ID.
/// Returns true if deleted, false if not found.
/// Fails with Conflict if workspaces reference this image.
fn delete_image(&self, name_or_id: &str) -> Result<bool, StoreError>;
// --- Workspace methods ---
/// Register a workspace in the database.
fn create_workspace(
&self,
name: Option<&str>,
subvolume_path: &str,
master_image_id: &str,
) -> Result<Workspace, StoreError>;
/// List all workspaces, optionally filtered by master image name.
fn list_workspaces(&self, base: Option<&str>) -> Result<Vec<Workspace>, StoreError>;
/// Get a workspace by name or ID.
fn get_workspace(&self, name_or_id: &str) -> Result<Option<Workspace>, StoreError>;
/// Delete a workspace by name or ID.
/// Returns true if deleted, false if not found.
/// Fails with Conflict if workspace is attached to a VM.
fn delete_workspace(&self, name_or_id: &str) -> Result<bool, StoreError>;
}
Step 2: Write the tests in sqlite.rs
Add these tests to the tests module in nexus/nexus-lib/src/store/sqlite.rs:
#![allow(unused)]
fn main() {
use crate::workspace::ImportImageParams;
#[test]
fn create_image_and_get_by_name() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
let params = ImportImageParams {
name: "base-agent".to_string(),
source_path: "/tmp/rootfs".to_string(),
};
let img = store.create_image(¶ms, "/data/workspaces/@base-agent").unwrap();
assert!(!img.id.is_empty());
assert_eq!(img.name, "base-agent");
assert_eq!(img.subvolume_path, "/data/workspaces/@base-agent");
let found = store.get_image("base-agent").unwrap().unwrap();
assert_eq!(found.id, img.id);
}
#[test]
fn create_image_duplicate_name_fails() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
let params = ImportImageParams {
name: "dup-img".to_string(),
source_path: "/tmp/a".to_string(),
};
store.create_image(¶ms, "/data/a").unwrap();
let result = store.create_image(¶ms, "/data/b");
assert!(result.is_err());
}
#[test]
fn list_images_returns_all() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
store.create_image(
&ImportImageParams { name: "img-a".to_string(), source_path: "/a".to_string() },
"/data/a",
).unwrap();
store.create_image(
&ImportImageParams { name: "img-b".to_string(), source_path: "/b".to_string() },
"/data/b",
).unwrap();
let imgs = store.list_images().unwrap();
assert_eq!(imgs.len(), 2);
}
#[test]
fn delete_image_removes_record() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
store.create_image(
&ImportImageParams { name: "del-me".to_string(), source_path: "/a".to_string() },
"/data/del-me",
).unwrap();
let deleted = store.delete_image("del-me").unwrap();
assert!(deleted);
assert!(store.get_image("del-me").unwrap().is_none());
}
#[test]
fn delete_image_not_found_returns_false() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
assert!(!store.delete_image("ghost").unwrap());
}
#[test]
fn delete_image_with_workspaces_fails() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
let img = store.create_image(
&ImportImageParams { name: "base".to_string(), source_path: "/a".to_string() },
"/data/base",
).unwrap();
store.create_workspace(Some("ws-1"), "/data/ws-1", &img.id).unwrap();
let result = store.delete_image("base");
assert!(result.is_err());
}
#[test]
fn create_workspace_and_get_by_name() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
let img = store.create_image(
&ImportImageParams { name: "base".to_string(), source_path: "/a".to_string() },
"/data/base",
).unwrap();
let ws = store.create_workspace(Some("my-ws"), "/data/my-ws", &img.id).unwrap();
assert!(!ws.id.is_empty());
assert_eq!(ws.name, Some("my-ws".to_string()));
assert_eq!(ws.master_image_id, Some(img.id.clone()));
let found = store.get_workspace("my-ws").unwrap().unwrap();
assert_eq!(found.id, ws.id);
}
#[test]
fn create_workspace_auto_generates_name_when_none() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
let img = store.create_image(
&ImportImageParams { name: "base".to_string(), source_path: "/a".to_string() },
"/data/base",
).unwrap();
let ws = store.create_workspace(None, "/data/anon-ws", &img.id).unwrap();
assert!(ws.name.is_none());
}
#[test]
fn list_workspaces_returns_all() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
let img = store.create_image(
&ImportImageParams { name: "base".to_string(), source_path: "/a".to_string() },
"/data/base",
).unwrap();
store.create_workspace(Some("ws-a"), "/data/ws-a", &img.id).unwrap();
store.create_workspace(Some("ws-b"), "/data/ws-b", &img.id).unwrap();
let wss = store.list_workspaces(None).unwrap();
assert_eq!(wss.len(), 2);
}
#[test]
fn list_workspaces_filter_by_base() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
let img_a = store.create_image(
&ImportImageParams { name: "base-a".to_string(), source_path: "/a".to_string() },
"/data/base-a",
).unwrap();
let img_b = store.create_image(
&ImportImageParams { name: "base-b".to_string(), source_path: "/b".to_string() },
"/data/base-b",
).unwrap();
store.create_workspace(Some("ws-a"), "/data/ws-a", &img_a.id).unwrap();
store.create_workspace(Some("ws-b"), "/data/ws-b", &img_b.id).unwrap();
let wss = store.list_workspaces(Some("base-a")).unwrap();
assert_eq!(wss.len(), 1);
assert_eq!(wss[0].name, Some("ws-a".to_string()));
}
#[test]
fn delete_workspace_removes_record() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
let img = store.create_image(
&ImportImageParams { name: "base".to_string(), source_path: "/a".to_string() },
"/data/base",
).unwrap();
store.create_workspace(Some("del-ws"), "/data/del-ws", &img.id).unwrap();
let deleted = store.delete_workspace("del-ws").unwrap();
assert!(deleted);
assert!(store.get_workspace("del-ws").unwrap().is_none());
}
#[test]
fn delete_workspace_not_found_returns_false() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open_and_init(&db_path).unwrap();
assert!(!store.delete_workspace("ghost").unwrap());
}
}
Step 3: Run tests to verify they fail
Run: cd ~/Work/WorkFort/nexus && cargo test -p nexus-lib store::sqlite::tests::create_image
Expected: FAIL — create_image does not exist on SqliteStore yet.
Step 4: Implement the store methods
Add these imports at the top of nexus/nexus-lib/src/store/sqlite.rs:
#![allow(unused)]
fn main() {
use crate::workspace::{ImportImageParams, MasterImage, Workspace};
}
Add the image and workspace CRUD implementation to impl StateStore for SqliteStore:
#![allow(unused)]
fn main() {
fn create_image(&self, params: &ImportImageParams, subvolume_path: &str) -> Result<MasterImage, StoreError> {
let conn = self.conn.lock().unwrap();
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO master_images (id, name, subvolume_path) VALUES (?1, ?2, ?3)",
rusqlite::params![id, params.name, subvolume_path],
)
.map_err(|e| {
if let rusqlite::Error::SqliteFailure(err, _) = &e {
if err.code == rusqlite::ErrorCode::ConstraintViolation {
return StoreError::Conflict(format!("image name '{}' already exists", params.name));
}
}
StoreError::Query(format!("cannot insert image: {e}"))
})?;
drop(conn);
self.get_image(&id)?
.ok_or_else(|| StoreError::Query("image not found after insert".to_string()))
}
fn list_images(&self) -> Result<Vec<MasterImage>, StoreError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT id, name, subvolume_path, size_bytes, created_at FROM master_images ORDER BY created_at DESC")
.map_err(|e| StoreError::Query(format!("cannot prepare image list query: {e}")))?;
let images = stmt
.query_map([], |row| Ok(row_to_image(row)))
.map_err(|e| StoreError::Query(format!("cannot list images: {e}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| StoreError::Query(format!("cannot read image row: {e}")))?;
Ok(images)
}
fn get_image(&self, name_or_id: &str) -> Result<Option<MasterImage>, StoreError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT id, name, subvolume_path, size_bytes, created_at FROM master_images WHERE id = ?1 OR name = ?1")
.map_err(|e| StoreError::Query(format!("cannot prepare image get query: {e}")))?;
let mut rows = stmt
.query_map([name_or_id], |row| Ok(row_to_image(row)))
.map_err(|e| StoreError::Query(format!("cannot get image: {e}")))?;
match rows.next() {
Some(Ok(img)) => Ok(Some(img)),
Some(Err(e)) => Err(StoreError::Query(format!("cannot read image row: {e}"))),
None => Ok(None),
}
}
fn delete_image(&self, name_or_id: &str) -> Result<bool, StoreError> {
let image = match self.get_image(name_or_id)? {
Some(img) => img,
None => return Ok(false),
};
// Check for workspaces referencing this image
let conn = self.conn.lock().unwrap();
let ws_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM workspaces WHERE master_image_id = ?1",
[&image.id],
|row| row.get(0),
)
.map_err(|e| StoreError::Query(format!("cannot check workspace references: {e}")))?;
if ws_count > 0 {
return Err(StoreError::Conflict(format!(
"cannot delete image '{}': {} workspace(s) reference it, delete them first",
image.name, ws_count
)));
}
let deleted = conn
.execute(
"DELETE FROM master_images WHERE id = ?1",
[&image.id],
)
.map_err(|e| StoreError::Query(format!("cannot delete image: {e}")))?;
Ok(deleted > 0)
}
fn create_workspace(
&self,
name: Option<&str>,
subvolume_path: &str,
master_image_id: &str,
) -> Result<Workspace, StoreError> {
let conn = self.conn.lock().unwrap();
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO workspaces (id, name, subvolume_path, master_image_id) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![id, name, subvolume_path, master_image_id],
)
.map_err(|e| {
if let rusqlite::Error::SqliteFailure(err, _) = &e {
if err.code == rusqlite::ErrorCode::ConstraintViolation {
return StoreError::Conflict(format!(
"workspace name '{}' already exists",
name.unwrap_or("(unnamed)")
));
}
}
StoreError::Query(format!("cannot insert workspace: {e}"))
})?;
drop(conn);
self.get_workspace(&id)?
.ok_or_else(|| StoreError::Query("workspace not found after insert".to_string()))
}
fn list_workspaces(&self, base: Option<&str>) -> Result<Vec<Workspace>, StoreError> {
let conn = self.conn.lock().unwrap();
let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match base {
Some(base_name) => {
(
"SELECT w.id, w.name, w.vm_id, w.subvolume_path, w.master_image_id, \
w.parent_workspace_id, w.size_bytes, w.is_root_device, w.is_read_only, \
w.attached_at, w.detached_at, w.created_at \
FROM workspaces w \
JOIN master_images m ON w.master_image_id = m.id \
WHERE m.name = ? \
ORDER BY w.created_at DESC".to_string(),
vec![Box::new(base_name.to_string()) as Box<dyn rusqlite::types::ToSql>],
)
}
None => {
(
"SELECT id, name, vm_id, subvolume_path, master_image_id, \
parent_workspace_id, size_bytes, is_root_device, is_read_only, \
attached_at, detached_at, created_at \
FROM workspaces ORDER BY created_at DESC".to_string(),
vec![],
)
}
};
let mut stmt = conn.prepare(&sql)
.map_err(|e| StoreError::Query(format!("cannot prepare workspace list query: {e}")))?;
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let workspaces = stmt
.query_map(param_refs.as_slice(), |row| Ok(row_to_workspace(row)))
.map_err(|e| StoreError::Query(format!("cannot list workspaces: {e}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| StoreError::Query(format!("cannot read workspace row: {e}")))?;
Ok(workspaces)
}
fn get_workspace(&self, name_or_id: &str) -> Result<Option<Workspace>, StoreError> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare(
"SELECT id, name, vm_id, subvolume_path, master_image_id, \
parent_workspace_id, size_bytes, is_root_device, is_read_only, \
attached_at, detached_at, created_at \
FROM workspaces WHERE id = ?1 OR name = ?1",
)
.map_err(|e| StoreError::Query(format!("cannot prepare workspace get query: {e}")))?;
let mut rows = stmt
.query_map([name_or_id], |row| Ok(row_to_workspace(row)))
.map_err(|e| StoreError::Query(format!("cannot get workspace: {e}")))?;
match rows.next() {
Some(Ok(ws)) => Ok(Some(ws)),
Some(Err(e)) => Err(StoreError::Query(format!("cannot read workspace row: {e}"))),
None => Ok(None),
}
}
fn delete_workspace(&self, name_or_id: &str) -> Result<bool, StoreError> {
let ws = match self.get_workspace(name_or_id)? {
Some(ws) => ws,
None => return Ok(false),
};
// Cannot delete workspace attached to a VM
if ws.vm_id.is_some() {
return Err(StoreError::Conflict(format!(
"cannot delete workspace '{}': attached to VM, detach it first",
ws.name.as_deref().unwrap_or(&ws.id)
)));
}
let conn = self.conn.lock().unwrap();
let deleted = conn
.execute("DELETE FROM workspaces WHERE id = ?1", [&ws.id])
.map_err(|e| StoreError::Query(format!("cannot delete workspace: {e}")))?;
Ok(deleted > 0)
}
}
Add the row mapping helper functions (outside the impl block):
#![allow(unused)]
fn main() {
fn row_to_image(row: &rusqlite::Row) -> MasterImage {
MasterImage {
id: row.get(0).unwrap(),
name: row.get(1).unwrap(),
subvolume_path: row.get(2).unwrap(),
size_bytes: row.get(3).unwrap(),
created_at: row.get(4).unwrap(),
}
}
fn row_to_workspace(row: &rusqlite::Row) -> Workspace {
Workspace {
id: row.get(0).unwrap(),
name: row.get(1).unwrap(),
vm_id: row.get(2).unwrap(),
subvolume_path: row.get(3).unwrap(),
master_image_id: row.get(4).unwrap(),
parent_workspace_id: row.get(5).unwrap(),
size_bytes: row.get(6).unwrap(),
is_root_device: row.get::<_, i32>(7).unwrap() != 0,
is_read_only: row.get::<_, i32>(8).unwrap() != 0,
attached_at: row.get(9).unwrap(),
detached_at: row.get(10).unwrap(),
created_at: row.get(11).unwrap(),
}
}
}
Step 5: Update MockStore and FailingStore in nexusd/src/api.rs
Add stub implementations of the new trait methods to MockStore and FailingStore:
#![allow(unused)]
fn main() {
fn create_image(&self, _params: &ImportImageParams, _subvolume_path: &str) -> Result<MasterImage, StoreError> {
unimplemented!()
}
fn list_images(&self) -> Result<Vec<MasterImage>, StoreError> {
unimplemented!()
}
fn get_image(&self, _name_or_id: &str) -> Result<Option<MasterImage>, StoreError> {
unimplemented!()
}
fn delete_image(&self, _name_or_id: &str) -> Result<bool, StoreError> {
unimplemented!()
}
fn create_workspace(&self, _name: Option<&str>, _subvolume_path: &str, _master_image_id: &str) -> Result<Workspace, StoreError> {
unimplemented!()
}
fn list_workspaces(&self, _base: Option<&str>) -> Result<Vec<Workspace>, StoreError> {
unimplemented!()
}
fn get_workspace(&self, _name_or_id: &str) -> Result<Option<Workspace>, StoreError> {
unimplemented!()
}
fn delete_workspace(&self, _name_or_id: &str) -> Result<bool, StoreError> {
unimplemented!()
}
}
Add the import to the test module:
#![allow(unused)]
fn main() {
use nexus_lib::workspace::{ImportImageParams, MasterImage, Workspace};
}
Step 6: Run tests to verify they pass
Run: cd ~/Work/WorkFort/nexus && cargo test -p nexus-lib store::sqlite::tests
Expected: All tests PASS (existing VM tests + new image/workspace tests).
Step 7: Run full workspace tests
Run: cd ~/Work/WorkFort/nexus && mise run test
Expected: All tests PASS.
Step 8: Commit
cd ~/Work/WorkFort/nexus
git add nexus-lib/src/store/traits.rs nexus-lib/src/store/sqlite.rs nexusd/src/api.rs
git commit -m "feat(nexus-lib): implement image and workspace CRUD in SqliteStore"
Task 7: Workspace Service — Orchestrates Backend + Store
Files:
- Create:
nexus/nexus-lib/src/workspace_service.rs - Modify:
nexus/nexus-lib/src/lib.rs
The workspace service coordinates between the WorkspaceBackend (filesystem) and StateStore (database). It is the single point of orchestration: import an image = create subvolume + register in DB. Create workspace = snapshot subvolume + register in DB. Delete = remove from DB + delete subvolume.
Step 1: Create the workspace service
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/workspace_service.rs
use crate::backend::traits::{BackendError, WorkspaceBackend};
use crate::store::traits::{StateStore, StoreError};
use crate::workspace::{ImportImageParams, MasterImage, Workspace};
use std::path::PathBuf;
use uuid::Uuid;
/// Errors from workspace service operations.
#[derive(Debug)]
pub enum WorkspaceServiceError {
Store(StoreError),
Backend(BackendError),
/// A referenced entity (e.g., master image) was not found.
NotFound(String),
}
impl std::fmt::Display for WorkspaceServiceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WorkspaceServiceError::Store(e) => write!(f, "{e}"),
WorkspaceServiceError::Backend(e) => write!(f, "{e}"),
WorkspaceServiceError::NotFound(e) => write!(f, "not found: {e}"),
}
}
}
impl std::error::Error for WorkspaceServiceError {}
impl From<StoreError> for WorkspaceServiceError {
fn from(e: StoreError) -> Self {
WorkspaceServiceError::Store(e)
}
}
impl From<BackendError> for WorkspaceServiceError {
fn from(e: BackendError) -> Self {
WorkspaceServiceError::Backend(e)
}
}
/// Orchestrates workspace operations between the filesystem backend and the database.
pub struct WorkspaceService<'a> {
store: &'a dyn StateStore,
backend: &'a dyn WorkspaceBackend,
workspaces_root: PathBuf,
}
impl<'a> WorkspaceService<'a> {
pub fn new(
store: &'a dyn StateStore,
backend: &'a dyn WorkspaceBackend,
workspaces_root: PathBuf,
) -> Self {
WorkspaceService {
store,
backend,
workspaces_root,
}
}
/// Import a directory as a master image.
///
/// 1. Creates a btrfs subvolume from the source directory
/// 2. Marks it read-only
/// 3. Registers in the database
pub fn import_image(&self, params: &ImportImageParams) -> Result<MasterImage, WorkspaceServiceError> {
let source = PathBuf::from(¶ms.source_path);
let dest = self.workspaces_root.join(format!("@{}", params.name));
// Create the subvolume on disk (copies source, marks read-only)
let info = self.backend.import_image(&source, &dest)?;
// Register in database; roll back subvolume on failure
let image = match self.store.create_image(params, &dest.to_string_lossy()) {
Ok(img) => img,
Err(e) => {
let _ = self.backend.delete_subvolume(&dest);
return Err(e.into());
}
};
Ok(image)
}
/// Create a workspace by snapshotting a master image.
///
/// 1. Looks up the master image by name
/// 2. Creates a btrfs snapshot
/// 3. Registers in the database
pub fn create_workspace(
&self,
base_name: &str,
ws_name: Option<&str>,
) -> Result<Workspace, WorkspaceServiceError> {
// Look up the master image
let image = self.store.get_image(base_name)?
.ok_or_else(|| WorkspaceServiceError::NotFound(
format!("master image '{}' not found", base_name)
))?;
let source = PathBuf::from(&image.subvolume_path);
let snap_name = match ws_name {
Some(name) => name.to_string(),
None => format!("{}-{}", base_name, &Uuid::new_v4().to_string()[..8]),
};
let dest = self.workspaces_root.join(format!("@{}", snap_name));
// Create the snapshot on disk
self.backend.create_snapshot(&source, &dest)?;
// Register in database; roll back snapshot on failure
let workspace = match self.store.create_workspace(
ws_name,
&dest.to_string_lossy(),
&image.id,
) {
Ok(ws) => ws,
Err(e) => {
let _ = self.backend.delete_subvolume(&dest);
return Err(e.into());
}
};
Ok(workspace)
}
/// Delete a workspace: remove from DB, then delete the subvolume.
pub fn delete_workspace(&self, name_or_id: &str) -> Result<bool, WorkspaceServiceError> {
let ws = match self.store.get_workspace(name_or_id)? {
Some(ws) => ws,
None => return Ok(false),
};
// Delete from DB first (validates constraints like attached-to-VM)
self.store.delete_workspace(&ws.id)?;
// Delete from filesystem
let path = PathBuf::from(&ws.subvolume_path);
if path.exists() {
self.backend.delete_subvolume(&path)?;
}
Ok(true)
}
/// Delete a master image: ensure no workspaces reference it, then remove.
pub fn delete_image(&self, name_or_id: &str) -> Result<bool, WorkspaceServiceError> {
let image = match self.store.get_image(name_or_id)? {
Some(img) => img,
None => return Ok(false),
};
// Delete from DB first (validates constraints like workspace references)
self.store.delete_image(&image.id)?;
// Delete from filesystem
let path = PathBuf::from(&image.subvolume_path);
if path.exists() {
self.backend.delete_subvolume(&path)?;
}
Ok(true)
}
/// List all master images (delegates to store).
pub fn list_images(&self) -> Result<Vec<MasterImage>, WorkspaceServiceError> {
Ok(self.store.list_images()?)
}
/// Get a master image by name or ID (delegates to store).
pub fn get_image(&self, name_or_id: &str) -> Result<Option<MasterImage>, WorkspaceServiceError> {
Ok(self.store.get_image(name_or_id)?)
}
/// List all workspaces, optionally filtered (delegates to store).
pub fn list_workspaces(&self, base: Option<&str>) -> Result<Vec<Workspace>, WorkspaceServiceError> {
Ok(self.store.list_workspaces(base)?)
}
/// Get a workspace by name or ID (delegates to store).
pub fn get_workspace(&self, name_or_id: &str) -> Result<Option<Workspace>, WorkspaceServiceError> {
Ok(self.store.get_workspace(name_or_id)?)
}
}
}
Step 2: Export from lib.rs
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/lib.rs
pub mod backend;
pub mod client;
pub mod config;
pub mod store;
pub mod vm;
pub mod workspace;
pub mod workspace_service;
#[cfg(feature = "test-support")]
pub mod test_support;
}
Step 3: Verify build
Run: cd ~/Work/WorkFort/nexus && mise run check
Expected: Compiles with no errors.
Step 4: Commit
cd ~/Work/WorkFort/nexus
git add nexus-lib/src/workspace_service.rs nexus-lib/src/lib.rs
git commit -m "feat(nexus-lib): add WorkspaceService orchestrating backend + store"
Task 8: Add Workspaces Root to Config
Files:
- Modify:
nexus/nexus-lib/src/config.rs
Step 1: Add workspaces_root to the daemon Config
Add a storage section to the config:
#![allow(unused)]
fn main() {
// In nexus/nexus-lib/src/config.rs
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub api: ApiConfig,
pub storage: StorageConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct StorageConfig {
pub workspaces: String,
}
impl Default for StorageConfig {
fn default() -> Self {
let data_dir = dirs::data_dir()
.expect("cannot determine XDG_DATA_HOME")
.join("nexus")
.join("workspaces");
StorageConfig {
workspaces: data_dir.to_string_lossy().to_string(),
}
}
}
}
Add a helper:
#![allow(unused)]
fn main() {
/// Returns the default workspaces path: $XDG_DATA_HOME/nexus/workspaces
pub fn default_workspaces_path() -> PathBuf {
let data_dir = dirs::data_dir()
.expect("cannot determine XDG_DATA_HOME")
.join("nexus");
data_dir.join("workspaces")
}
}
Step 2: Add tests
#![allow(unused)]
fn main() {
#[test]
fn storage_config_defaults() {
let config = Config::default();
assert!(config.storage.workspaces.contains("nexus/workspaces"));
}
#[test]
fn config_with_storage_section_deserializes() {
let yaml = r#"
api:
listen: "127.0.0.1:8080"
storage:
workspaces: "/mnt/btrfs/nexus/workspaces"
"#;
let config: Config = serde_norway::from_str(yaml).unwrap();
assert_eq!(config.storage.workspaces, "/mnt/btrfs/nexus/workspaces");
}
}
Step 3: Run tests
Run: cd ~/Work/WorkFort/nexus && cargo test -p nexus-lib config::tests
Expected: All tests PASS.
Step 4: Commit
cd ~/Work/WorkFort/nexus
git add nexus-lib/src/config.rs
git commit -m "feat(nexus-lib): add storage.workspaces config with XDG default"
Task 9: REST API Endpoints for Images and Workspaces
Files:
- Modify:
nexus/nexusd/src/api.rs - Modify:
nexus/nexusd/src/main.rs
Step 1: Update AppState to include WorkspaceBackend
Update nexus/nexusd/src/api.rs to add the workspace backend and workspaces root to AppState:
#![allow(unused)]
fn main() {
// nexus/nexusd/src/api.rs — update AppState
use nexus_lib::backend::traits::WorkspaceBackend;
use nexus_lib::workspace::{ImportImageParams, CreateWorkspaceParams, MasterImage, Workspace};
use nexus_lib::workspace_service::{WorkspaceService, WorkspaceServiceError};
use std::path::PathBuf;
pub struct AppState {
pub store: Box<dyn StateStore + Send + Sync>,
pub backend: Box<dyn WorkspaceBackend>,
pub workspaces_root: PathBuf,
}
}
Step 2: Add the image and workspace handlers
#![allow(unused)]
fn main() {
async fn import_image(
State(state): State<Arc<AppState>>,
Json(params): Json<ImportImageParams>,
) -> (StatusCode, Json<serde_json::Value>) {
let svc = WorkspaceService::new(
state.store.as_ref(),
state.backend.as_ref(),
state.workspaces_root.clone(),
);
match svc.import_image(¶ms) {
Ok(img) => (StatusCode::CREATED, Json(serde_json::to_value(img).unwrap())),
Err(WorkspaceServiceError::Store(StoreError::Conflict(msg))) => (
StatusCode::CONFLICT,
Json(serde_json::json!({"error": msg})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
),
}
}
async fn list_images(
State(state): State<Arc<AppState>>,
) -> (StatusCode, Json<serde_json::Value>) {
let svc = WorkspaceService::new(
state.store.as_ref(),
state.backend.as_ref(),
state.workspaces_root.clone(),
);
match svc.list_images() {
Ok(imgs) => (StatusCode::OK, Json(serde_json::to_value(imgs).unwrap())),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
),
}
}
async fn get_image(
State(state): State<Arc<AppState>>,
Path(name_or_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let svc = WorkspaceService::new(
state.store.as_ref(),
state.backend.as_ref(),
state.workspaces_root.clone(),
);
match svc.get_image(&name_or_id) {
Ok(Some(img)) => (StatusCode::OK, Json(serde_json::to_value(img).unwrap())),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("image '{}' not found", name_or_id)})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
),
}
}
async fn delete_image_handler(
State(state): State<Arc<AppState>>,
Path(name_or_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let svc = WorkspaceService::new(
state.store.as_ref(),
state.backend.as_ref(),
state.workspaces_root.clone(),
);
match svc.delete_image(&name_or_id) {
Ok(true) => (StatusCode::NO_CONTENT, Json(serde_json::json!(null))),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("image '{}' not found", name_or_id)})),
),
Err(WorkspaceServiceError::Store(StoreError::Conflict(msg))) => (
StatusCode::CONFLICT,
Json(serde_json::json!({"error": msg})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
),
}
}
async fn create_workspace_handler(
State(state): State<Arc<AppState>>,
Json(params): Json<CreateWorkspaceParams>,
) -> (StatusCode, Json<serde_json::Value>) {
let svc = WorkspaceService::new(
state.store.as_ref(),
state.backend.as_ref(),
state.workspaces_root.clone(),
);
match svc.create_workspace(¶ms.base, params.name.as_deref()) {
Ok(ws) => (StatusCode::CREATED, Json(serde_json::to_value(ws).unwrap())),
Err(WorkspaceServiceError::NotFound(msg)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": msg})),
),
Err(WorkspaceServiceError::Store(StoreError::Conflict(msg))) => (
StatusCode::CONFLICT,
Json(serde_json::json!({"error": msg})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
),
}
}
async fn list_workspaces(
State(state): State<Arc<AppState>>,
axum::extract::Query(query): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> (StatusCode, Json<serde_json::Value>) {
let base = query.get("base").map(|s| s.as_str());
let svc = WorkspaceService::new(
state.store.as_ref(),
state.backend.as_ref(),
state.workspaces_root.clone(),
);
match svc.list_workspaces(base) {
Ok(wss) => (StatusCode::OK, Json(serde_json::to_value(wss).unwrap())),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
),
}
}
async fn get_workspace(
State(state): State<Arc<AppState>>,
Path(name_or_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let svc = WorkspaceService::new(
state.store.as_ref(),
state.backend.as_ref(),
state.workspaces_root.clone(),
);
match svc.get_workspace(&name_or_id) {
Ok(Some(ws)) => (StatusCode::OK, Json(serde_json::to_value(ws).unwrap())),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("workspace '{}' not found", name_or_id)})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
),
}
}
async fn delete_workspace_handler(
State(state): State<Arc<AppState>>,
Path(name_or_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let svc = WorkspaceService::new(
state.store.as_ref(),
state.backend.as_ref(),
state.workspaces_root.clone(),
);
match svc.delete_workspace(&name_or_id) {
Ok(true) => (StatusCode::NO_CONTENT, Json(serde_json::json!(null))),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": format!("workspace '{}' not found", name_or_id)})),
),
Err(WorkspaceServiceError::Store(StoreError::Conflict(msg))) => (
StatusCode::CONFLICT,
Json(serde_json::json!({"error": msg})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
),
}
}
}
Step 3: Update the router
#![allow(unused)]
fn main() {
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/v1/health", get(health))
.route("/v1/vms", post(create_vm).get(list_vms))
.route("/v1/vms/{name_or_id}", get(get_vm).delete(delete_vm))
.route("/v1/images", post(import_image).get(list_images))
.route("/v1/images/{name_or_id}", get(get_image).delete(delete_image_handler))
.route("/v1/workspaces", post(create_workspace_handler).get(list_workspaces))
.route("/v1/workspaces/{name_or_id}", get(get_workspace).delete(delete_workspace_handler))
.with_state(state)
}
}
Step 4: Update nexusd main.rs
Update nexus/nexusd/src/main.rs to initialize the BtrfsBackend and pass it to AppState:
#![allow(unused)]
fn main() {
// nexus/nexusd/src/main.rs — add imports
use nexus_lib::backend::btrfs::BtrfsBackend;
// In main(), after initializing the store:
let workspaces_root = std::path::PathBuf::from(&config.storage.workspaces);
let backend = match BtrfsBackend::new(workspaces_root.clone()) {
Ok(backend) => {
info!(workspaces = %workspaces_root.display(), "workspace backend initialized");
backend
}
Err(e) => {
error!(error = %e, "failed to initialize workspace backend");
std::process::exit(1);
}
};
let state = Arc::new(api::AppState {
store: Box::new(store),
backend: Box::new(backend),
workspaces_root,
});
}
Step 5: Update test helper test_state() in api.rs tests
Since tests don’t run on a btrfs filesystem, create a mock WorkspaceBackend for API tests:
#![allow(unused)]
fn main() {
use nexus_lib::backend::traits::{BackendError, SubvolumeInfo, WorkspaceBackend};
use std::path::PathBuf;
/// A no-op backend for API unit tests (no real btrfs needed).
struct MockBackend;
impl WorkspaceBackend for MockBackend {
fn import_image(&self, _source: &std::path::Path, dest: &std::path::Path) -> Result<SubvolumeInfo, BackendError> {
Ok(SubvolumeInfo { path: dest.to_path_buf(), read_only: true, size_bytes: None })
}
fn create_snapshot(&self, _source: &std::path::Path, dest: &std::path::Path) -> Result<SubvolumeInfo, BackendError> {
Ok(SubvolumeInfo { path: dest.to_path_buf(), read_only: false, size_bytes: None })
}
fn delete_subvolume(&self, _path: &std::path::Path) -> Result<(), BackendError> { Ok(()) }
fn is_subvolume(&self, _path: &std::path::Path) -> Result<bool, BackendError> { Ok(true) }
fn subvolume_info(&self, path: &std::path::Path) -> Result<SubvolumeInfo, BackendError> {
Ok(SubvolumeInfo { path: path.to_path_buf(), read_only: false, size_bytes: None })
}
fn set_read_only(&self, _path: &std::path::Path, _read_only: bool) -> Result<(), BackendError> { Ok(()) }
}
fn test_state() -> Arc<AppState> {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let ws_root = dir.path().join("workspaces");
std::fs::create_dir_all(&ws_root).unwrap();
let store = SqliteStore::open_and_init(&db_path).unwrap();
// Leak the tempdir so it lives long enough
std::mem::forget(dir);
Arc::new(AppState {
store: Box::new(store),
backend: Box::new(MockBackend),
workspaces_root: ws_root,
})
}
}
Step 6: Verify build
Run: cd ~/Work/WorkFort/nexus && mise run check
Expected: Compiles with no errors.
Step 7: Run existing tests
Run: cd ~/Work/WorkFort/nexus && mise run test:unit
Expected: All unit tests PASS.
Step 8: Commit
cd ~/Work/WorkFort/nexus
git add nexusd/src/api.rs nexusd/src/main.rs
git commit -m "feat(nexusd): add REST API endpoints for images and workspaces"
TODO (nice to have): Add API-level tests for image and workspace endpoints mirroring the existing VM API tests in
api.rs. Use theMockBackendfrom Step 5 to exercisePOST /v1/images-> 201,GET /v1/images-> 200,GET /v1/images/:name-> 200/404,DELETE /v1/images/:name-> 204/404/409, and the corresponding workspace endpoints. Also consider wrapping blocking btrfs calls intokio::task::spawn_blockingin production to avoid blocking the async runtime.
Task 10: Add Image and Workspace Methods to NexusClient
Files:
- Modify:
nexus/nexus-lib/src/client.rs
Step 1: Add image and workspace methods to NexusClient
#![allow(unused)]
fn main() {
// --- Image methods ---
pub async fn import_image(&self, params: &crate::workspace::ImportImageParams) -> Result<crate::workspace::MasterImage, ClientError> {
let url = format!("{}/v1/images", self.base_url);
let resp = self.http.post(&url).json(params).send().await.map_err(|e| {
if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
else { ClientError::Api(e.to_string()) }
})?;
let status = resp.status();
if status == reqwest::StatusCode::CONFLICT {
let body: serde_json::Value = resp.json().await.map_err(|e| ClientError::Api(e.to_string()))?;
return Err(ClientError::Api(body["error"].as_str().unwrap_or("conflict").to_string()));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(ClientError::Api(format!("unexpected status {status}: {body}")));
}
resp.json().await.map_err(|e| ClientError::Api(e.to_string()))
}
pub async fn list_images(&self) -> Result<Vec<crate::workspace::MasterImage>, ClientError> {
let url = format!("{}/v1/images", self.base_url);
let resp = self.http.get(&url).send().await.map_err(|e| {
if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
else { ClientError::Api(e.to_string()) }
})?;
if !resp.status().is_success() {
return Err(ClientError::Api(format!("unexpected status: {}", resp.status())));
}
resp.json().await.map_err(|e| ClientError::Api(e.to_string()))
}
pub async fn get_image(&self, name_or_id: &str) -> Result<Option<crate::workspace::MasterImage>, ClientError> {
let url = format!("{}/v1/images/{name_or_id}", self.base_url);
let resp = self.http.get(&url).send().await.map_err(|e| {
if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
else { ClientError::Api(e.to_string()) }
})?;
if resp.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); }
if !resp.status().is_success() {
return Err(ClientError::Api(format!("unexpected status: {}", resp.status())));
}
resp.json().await.map(Some).map_err(|e| ClientError::Api(e.to_string()))
}
pub async fn delete_image(&self, name_or_id: &str) -> Result<bool, ClientError> {
let url = format!("{}/v1/images/{name_or_id}", self.base_url);
let resp = self.http.delete(&url).send().await.map_err(|e| {
if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
else { ClientError::Api(e.to_string()) }
})?;
match resp.status().as_u16() {
204 => Ok(true),
404 => Ok(false),
409 => {
let body: serde_json::Value = resp.json().await.map_err(|e| ClientError::Api(e.to_string()))?;
Err(ClientError::Api(body["error"].as_str().unwrap_or("conflict").to_string()))
}
other => Err(ClientError::Api(format!("unexpected status: {other}"))),
}
}
// --- Workspace methods ---
pub async fn create_workspace(&self, params: &crate::workspace::CreateWorkspaceParams) -> Result<crate::workspace::Workspace, ClientError> {
let url = format!("{}/v1/workspaces", self.base_url);
let resp = self.http.post(&url).json(params).send().await.map_err(|e| {
if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
else { ClientError::Api(e.to_string()) }
})?;
let status = resp.status();
if status == reqwest::StatusCode::CONFLICT {
let body: serde_json::Value = resp.json().await.map_err(|e| ClientError::Api(e.to_string()))?;
return Err(ClientError::Api(body["error"].as_str().unwrap_or("conflict").to_string()));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(ClientError::Api(format!("unexpected status {status}: {body}")));
}
resp.json().await.map_err(|e| ClientError::Api(e.to_string()))
}
pub async fn list_workspaces(&self, base: Option<&str>) -> Result<Vec<crate::workspace::Workspace>, ClientError> {
let mut url = format!("{}/v1/workspaces", self.base_url);
if let Some(b) = base {
url.push_str(&format!("?base={b}"));
}
let resp = self.http.get(&url).send().await.map_err(|e| {
if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
else { ClientError::Api(e.to_string()) }
})?;
if !resp.status().is_success() {
return Err(ClientError::Api(format!("unexpected status: {}", resp.status())));
}
resp.json().await.map_err(|e| ClientError::Api(e.to_string()))
}
pub async fn get_workspace(&self, name_or_id: &str) -> Result<Option<crate::workspace::Workspace>, ClientError> {
let url = format!("{}/v1/workspaces/{name_or_id}", self.base_url);
let resp = self.http.get(&url).send().await.map_err(|e| {
if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
else { ClientError::Api(e.to_string()) }
})?;
if resp.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); }
if !resp.status().is_success() {
return Err(ClientError::Api(format!("unexpected status: {}", resp.status())));
}
resp.json().await.map(Some).map_err(|e| ClientError::Api(e.to_string()))
}
pub async fn delete_workspace(&self, name_or_id: &str) -> Result<bool, ClientError> {
let url = format!("{}/v1/workspaces/{name_or_id}", self.base_url);
let resp = self.http.delete(&url).send().await.map_err(|e| {
if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
else { ClientError::Api(e.to_string()) }
})?;
match resp.status().as_u16() {
204 => Ok(true),
404 => Ok(false),
409 => {
let body: serde_json::Value = resp.json().await.map_err(|e| ClientError::Api(e.to_string()))?;
Err(ClientError::Api(body["error"].as_str().unwrap_or("conflict").to_string()))
}
other => Err(ClientError::Api(format!("unexpected status: {other}"))),
}
}
}
Step 2: Run tests
Run: cd ~/Work/WorkFort/nexus && cargo test -p nexus-lib client::tests
Expected: All tests PASS.
Step 3: Commit
cd ~/Work/WorkFort/nexus
git add nexus-lib/src/client.rs
git commit -m "feat(nexus-lib): add image and workspace methods to NexusClient"
Task 11: nexusctl image and ws Subcommands
Files:
- Modify:
nexus/nexusctl/src/main.rs
Step 1: Add the Image and Ws subcommands to the Commands enum
#![allow(unused)]
fn main() {
#[derive(Subcommand)]
enum Commands {
/// Show daemon status
Status,
/// Print version information
Version,
/// Manage virtual machines
Vm {
#[command(subcommand)]
action: VmAction,
},
/// Manage master images
Image {
#[command(subcommand)]
action: ImageAction,
},
/// Manage workspaces (alias: workspace)
#[command(alias = "workspace")]
Ws {
#[command(subcommand)]
action: WsAction,
},
}
#[derive(Subcommand)]
enum ImageAction {
/// List all master images
List,
/// Import a directory as a master image
Import {
/// Path to directory to import
path: String,
/// Name for the image
#[arg(long)]
name: String,
},
/// Show image details
Inspect {
/// Image name or ID
name: String,
},
/// Delete a master image
Delete {
/// Image name or ID
name: String,
/// Skip confirmation
#[arg(short, long)]
yes: bool,
},
}
#[derive(Subcommand)]
enum WsAction {
/// List all workspaces
List {
/// Filter by base image name
#[arg(long)]
base: Option<String>,
},
/// Create a workspace from a master image
Create {
/// Workspace name
#[arg(long)]
name: Option<String>,
/// Base image name
#[arg(long)]
base: String,
},
/// Show workspace details
Inspect {
/// Workspace name or ID
name: String,
},
/// Delete a workspace
Delete {
/// Workspace name or ID
name: String,
/// Skip confirmation
#[arg(short, long)]
yes: bool,
},
}
}
Step 2: Add imports and dispatch in main()
#![allow(unused)]
fn main() {
use nexus_lib::workspace::{CreateWorkspaceParams, ImportImageParams};
}
Update the match in main():
#![allow(unused)]
fn main() {
match cli.command {
Commands::Status => cmd_status(&daemon_addr).await,
Commands::Version => cmd_version(&daemon_addr).await,
Commands::Vm { action } => cmd_vm(&daemon_addr, action).await,
Commands::Image { action } => cmd_image(&daemon_addr, action).await,
Commands::Ws { action } => cmd_ws(&daemon_addr, action).await,
}
}
Step 3: Implement the image command handlers
#![allow(unused)]
fn main() {
async fn cmd_image(daemon_addr: &str, action: ImageAction) -> ExitCode {
let client = NexusClient::new(daemon_addr);
match action {
ImageAction::List => {
match client.list_images().await {
Ok(imgs) => {
if imgs.is_empty() {
println!("No images found.");
return ExitCode::SUCCESS;
}
println!("{:<20} {:<50}", "NAME", "PATH");
for img in &imgs {
println!("{:<20} {:<50}", img.name, img.subvolume_path);
}
ExitCode::SUCCESS
}
Err(e) if e.is_connect() => {
print_connect_error(daemon_addr);
ExitCode::from(EXIT_DAEMON_UNREACHABLE)
}
Err(e) => {
eprintln!("Error: {e}");
ExitCode::from(EXIT_GENERAL_ERROR)
}
}
}
ImageAction::Import { path, name } => {
let params = ImportImageParams {
name: name.clone(),
source_path: path.clone(),
};
match client.import_image(¶ms).await {
Ok(img) => {
println!("Imported image \"{}\"", img.name);
println!(" Path: {}", img.subvolume_path);
println!("\n Create a workspace: nexusctl ws create --base {} --name my-ws", img.name);
ExitCode::SUCCESS
}
Err(e) if e.is_connect() => {
print_connect_error(daemon_addr);
ExitCode::from(EXIT_DAEMON_UNREACHABLE)
}
Err(e) => {
eprintln!("Error: cannot import image \"{name}\"\n {e}");
ExitCode::from(EXIT_GENERAL_ERROR)
}
}
}
ImageAction::Inspect { name } => {
match client.get_image(&name).await {
Ok(Some(img)) => {
println!("Name: {}", img.name);
println!("ID: {}", img.id);
println!("Path: {}", img.subvolume_path);
if let Some(size) = img.size_bytes {
println!("Size: {} bytes", size);
}
println!("Created: {}", format_timestamp(img.created_at));
ExitCode::SUCCESS
}
Ok(None) => {
eprintln!("Error: image \"{}\" not found", name);
ExitCode::from(EXIT_NOT_FOUND)
}
Err(e) if e.is_connect() => {
print_connect_error(daemon_addr);
ExitCode::from(EXIT_DAEMON_UNREACHABLE)
}
Err(e) => {
eprintln!("Error: {e}");
ExitCode::from(EXIT_GENERAL_ERROR)
}
}
}
ImageAction::Delete { name, yes } => {
if !yes {
eprintln!(
"Error: refusing to delete image without confirmation\n \
Run with --yes to skip confirmation: nexusctl image delete {} --yes",
name
);
return ExitCode::from(EXIT_GENERAL_ERROR);
}
match client.delete_image(&name).await {
Ok(true) => {
println!("Deleted image \"{}\"", name);
ExitCode::SUCCESS
}
Ok(false) => {
eprintln!("Error: image \"{}\" not found", name);
ExitCode::from(EXIT_NOT_FOUND)
}
Err(e) if e.is_connect() => {
print_connect_error(daemon_addr);
ExitCode::from(EXIT_DAEMON_UNREACHABLE)
}
Err(e) => {
eprintln!("Error: cannot delete image \"{}\"\n {e}", name);
ExitCode::from(EXIT_CONFLICT)
}
}
}
}
}
}
Step 4: Implement the workspace command handlers
#![allow(unused)]
fn main() {
async fn cmd_ws(daemon_addr: &str, action: WsAction) -> ExitCode {
let client = NexusClient::new(daemon_addr);
match action {
WsAction::List { base } => {
match client.list_workspaces(base.as_deref()).await {
Ok(wss) => {
if wss.is_empty() {
println!("No workspaces found.");
return ExitCode::SUCCESS;
}
println!("{:<20} {:<50} {:<10}", "NAME", "PATH", "READ-ONLY");
for ws in &wss {
let name = ws.name.as_deref().unwrap_or("(unnamed)");
let ro = if ws.is_read_only { "yes" } else { "no" };
println!("{:<20} {:<50} {:<10}", name, ws.subvolume_path, ro);
}
ExitCode::SUCCESS
}
Err(e) if e.is_connect() => {
print_connect_error(daemon_addr);
ExitCode::from(EXIT_DAEMON_UNREACHABLE)
}
Err(e) => {
eprintln!("Error: {e}");
ExitCode::from(EXIT_GENERAL_ERROR)
}
}
}
WsAction::Create { name, base } => {
let params = CreateWorkspaceParams {
name: name.clone(),
base: base.clone(),
};
match client.create_workspace(¶ms).await {
Ok(ws) => {
let ws_name = ws.name.as_deref().unwrap_or(&ws.id);
println!("Created workspace \"{}\" from base \"{}\"", ws_name, base);
println!(" Path: {}", ws.subvolume_path);
println!("\n Inspect it: nexusctl ws inspect {}", ws_name);
ExitCode::SUCCESS
}
Err(e) if e.is_connect() => {
print_connect_error(daemon_addr);
ExitCode::from(EXIT_DAEMON_UNREACHABLE)
}
Err(e) => {
eprintln!("Error: cannot create workspace\n {e}");
ExitCode::from(EXIT_GENERAL_ERROR)
}
}
}
WsAction::Inspect { name } => {
match client.get_workspace(&name).await {
Ok(Some(ws)) => {
let ws_name = ws.name.as_deref().unwrap_or("(unnamed)");
println!("Name: {}", ws_name);
println!("ID: {}", ws.id);
println!("Path: {}", ws.subvolume_path);
if let Some(ref img_id) = ws.master_image_id {
println!("Base Image: {}", img_id);
}
if let Some(ref vm_id) = ws.vm_id {
println!("Attached: VM {}", vm_id);
} else {
println!("Attached: (none)");
}
println!("Read-Only: {}", if ws.is_read_only { "yes" } else { "no" });
println!("Root Dev: {}", if ws.is_root_device { "yes" } else { "no" });
if let Some(size) = ws.size_bytes {
println!("Size: {} bytes", size);
}
println!("Created: {}", format_timestamp(ws.created_at));
ExitCode::SUCCESS
}
Ok(None) => {
eprintln!("Error: workspace \"{}\" not found", name);
ExitCode::from(EXIT_NOT_FOUND)
}
Err(e) if e.is_connect() => {
print_connect_error(daemon_addr);
ExitCode::from(EXIT_DAEMON_UNREACHABLE)
}
Err(e) => {
eprintln!("Error: {e}");
ExitCode::from(EXIT_GENERAL_ERROR)
}
}
}
WsAction::Delete { name, yes } => {
if !yes {
eprintln!(
"Error: refusing to delete workspace without confirmation\n \
Run with --yes to skip confirmation: nexusctl ws delete {} --yes",
name
);
return ExitCode::from(EXIT_GENERAL_ERROR);
}
match client.delete_workspace(&name).await {
Ok(true) => {
println!("Deleted workspace \"{}\"", name);
ExitCode::SUCCESS
}
Ok(false) => {
eprintln!("Error: workspace \"{}\" not found", name);
ExitCode::from(EXIT_NOT_FOUND)
}
Err(e) if e.is_connect() => {
print_connect_error(daemon_addr);
ExitCode::from(EXIT_DAEMON_UNREACHABLE)
}
Err(e) => {
eprintln!("Error: cannot delete workspace \"{}\"\n {e}", name);
ExitCode::from(EXIT_CONFLICT)
}
}
}
}
}
}
Step 5: Verify build
Run: cd ~/Work/WorkFort/nexus && mise run build
Expected: Compiles with no errors.
Step 6: Verify help output
Run: cd ~/Work/WorkFort/nexus && cargo run -p nexusctl -- image --help
Expected output showing list, import, inspect, delete subcommands.
Run: cd ~/Work/WorkFort/nexus && cargo run -p nexusctl -- ws --help
Expected output showing list, create, inspect, delete subcommands.
Step 7: Commit
cd ~/Work/WorkFort/nexus
git add nexusctl/src/main.rs
git commit -m "feat(nexusctl): add image and ws subcommands for workspace management"
Task 12: Update TestDaemon for Workspace Backend
Files:
- Modify:
nexus/nexus-lib/src/test_support.rs
The TestDaemon currently does not configure a workspaces root for the daemon. Integration tests that exercise workspace endpoints need the daemon to have a temp workspaces directory. Since the daemon creates BtrfsBackend::new(workspaces_root) on startup (which just calls create_dir_all), and integration tests run on the real filesystem, the TestDaemon just needs a temp directory passed via config.
Step 1: Update TestDaemon config to include storage section
#![allow(unused)]
fn main() {
// In test_support.rs, update the config_yaml line in start_with_binary:
let config_yaml = format!(
"api:\n listen: \"{addr}\"\nstorage:\n workspaces: \"{}\"",
tmp_dir.path().join("workspaces").display()
);
}
Step 2: Run tests
Run: cd ~/Work/WorkFort/nexus && mise run test
Expected: All existing tests PASS.
Step 3: Commit
cd ~/Work/WorkFort/nexus
git add nexus-lib/src/test_support.rs
git commit -m "test(nexus-lib): configure workspaces root in TestDaemon"
Task 13: Workspace-Wide Verification
TODO (nice to have): Add an automated
image_workspace_crud_lifecycleintegration test innexusd/tests/daemon.rs(similar to the existingvm_crud_lifecycletest). This requires a btrfs filesystem in the test environment. If not available in CI, gate the test behind a#[cfg]or#[ignore]attribute.
Step 1: Full build
Run: cd ~/Work/WorkFort/nexus && mise run build
Expected: Compiles with no errors and no warnings.
Step 2: Full test suite
Run: cd ~/Work/WorkFort/nexus && mise run test
Expected: All tests pass.
Step 3: Clippy
Run: cd ~/Work/WorkFort/nexus && mise run clippy
Expected: No warnings.
Step 4: End-to-end smoke test (requires btrfs filesystem)
Note: This smoke test requires a btrfs-formatted filesystem at the workspaces root path. If the host filesystem is not btrfs, configure storage.workspaces in the nexus config to point to a btrfs mount point.
Terminal 1:
cd ~/Work/WorkFort/nexus && mise run run
Terminal 2:
cd ~/Work/WorkFort/nexus
# Create a test directory to import as an image
mkdir -p /tmp/test-rootfs
echo "hello from rootfs" > /tmp/test-rootfs/README
# Import image
mise run run:nexusctl -- --daemon 127.0.0.1:9600 image import /tmp/test-rootfs --name base
# Expected: Imported image "base"
# List images
mise run run:nexusctl -- --daemon 127.0.0.1:9600 image list
# Expected: Table with "base" image
# Inspect image
mise run run:nexusctl -- --daemon 127.0.0.1:9600 image inspect base
# Expected: Full detail (name, ID, path, created timestamp)
# Create workspace from image
mise run run:nexusctl -- --daemon 127.0.0.1:9600 ws create --base base --name my-ws
# Expected: Created workspace "my-ws" from base "base"
# List workspaces
mise run run:nexusctl -- --daemon 127.0.0.1:9600 ws list
# Expected: Table with "my-ws"
# Inspect workspace
mise run run:nexusctl -- --daemon 127.0.0.1:9600 ws inspect my-ws
# Expected: Full detail (name, ID, path, base image, attached state)
# Verify with btrfs
btrfs subvolume list ~/.local/share/nexus/workspaces/
# Expected: Shows @base (read-only) and @my-ws (read-write)
# Delete workspace
mise run run:nexusctl -- --daemon 127.0.0.1:9600 ws delete my-ws --yes
# Expected: Deleted workspace "my-ws"
# Try deleting image with no workspaces
mise run run:nexusctl -- --daemon 127.0.0.1:9600 image delete base --yes
# Expected: Deleted image "base"
# Verify with btrfs
btrfs subvolume list ~/.local/share/nexus/workspaces/
# Expected: Empty (no subvolumes)
Kill the daemon (Ctrl-C in terminal 1).
Step 5: Verify schema migration
sqlite3 ~/.local/state/nexus/nexus.db ".tables"
# Expected: master_images schema_meta settings vms workspaces
sqlite3 ~/.local/state/nexus/nexus.db "SELECT value FROM schema_meta WHERE key='version'"
# Expected: 3
Step 6: Commit (if any final adjustments were needed)
cd ~/Work/WorkFort/nexus
git add nexus/
git commit -m "chore: final adjustments from step 5 verification"
Verification Checklist
-
mise run buildsucceeds with no warnings -
mise run test– all tests pass -
mise run clippy– no warnings -
WorkspaceBackendtrait is filesystem-agnostic (no btrfs-specific types in the trait interface) -
BtrfsBackendimplementsWorkspaceBackendusinglibbtrfsutil - Schema version is 3,
master_imagesandworkspacestables exist -
POST /v1/imageswithImportImageParamscreates subvolume + registers in DB -
GET /v1/imagesreturns JSON array of all images -
GET /v1/images/:namereturns image detail -
DELETE /v1/images/:namereturns 204, refuses if workspaces exist (409) -
POST /v1/workspaceswithCreateWorkspaceParamscreates btrfs snapshot + registers in DB -
GET /v1/workspacesreturns JSON array of all workspaces -
GET /v1/workspaces?base=<name>filters by base image -
GET /v1/workspaces/:namereturns workspace detail -
DELETE /v1/workspaces/:namereturns 204, refuses if attached to VM (409) -
nexusctl image import /path --name baseimports and registers an image -
nexusctl image listrenders a table -
nexusctl image inspect baseshows full detail -
nexusctl image delete base --yesdeletes the image -
nexusctl ws create --base base --name my-wscreates a workspace from image -
nexusctl ws listrenders a table with NAME, PATH, READ-ONLY columns -
nexusctl ws inspect my-wsshows full detail -
nexusctl ws delete my-ws --yesdeletes the workspace -
nexusctl workspace listworks as alias fornexusctl ws list - btrfs subvolumes are created and visible via
btrfs subvolume list - Master images are marked read-only after import
- Workspace snapshots are writable by default
- Pre-alpha schema migration (delete + recreate) works with version bump from 2 to 3