Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions crates/forkd-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,76 @@ enum Cmd {
#[arg(long)]
base_image: Option<String>,
},

/// Stateful workspaces (#116) — long-lived sandboxes that survive
/// suspend / resume across daemon restarts. Drive via the
/// controller daemon (`FORKD_URL` / `FORKD_TOKEN`).
#[command(subcommand)]
Workspace(WorkspaceAction),
}

#[derive(Subcommand)]
enum WorkspaceAction {
/// Create a new workspace by spawning a sandbox from a snapshot
/// tag. The workspace tracks the sandbox so future `suspend` /
/// `resume` calls operate on it by name.
Create {
/// Workspace name. 1-64 chars, ASCII alnum / dash / underscore.
name: String,
/// Snapshot tag to fork from.
#[arg(long)]
snapshot: String,
/// Place the live sandbox in its own pre-provisioned netns.
#[arg(long)]
per_child_netns: bool,
/// Cgroup memory.max for the live sandbox (MiB).
#[arg(long)]
memory_limit_mib: Option<u64>,
/// Controller URL. Defaults to FORKD_URL or http://127.0.0.1:8889.
#[arg(long, env = "FORKD_URL", default_value = "http://127.0.0.1:8889")]
daemon_url: String,
/// Controller bearer token.
#[arg(long, env = "FORKD_TOKEN")]
daemon_token: Option<String>,
},
/// Snapshot the workspace's live sandbox and kill it. State is
/// preserved under `ws-<name>-state`; a subsequent `resume`
/// brings the workspace back from there.
Suspend {
name: String,
/// Use v0.3 diff snapshot mode for the suspend write.
/// ~200 ms source pause vs seconds for Full.
#[arg(long)]
diff: bool,
#[arg(long, env = "FORKD_URL", default_value = "http://127.0.0.1:8889")]
daemon_url: String,
#[arg(long, env = "FORKD_TOKEN")]
daemon_token: Option<String>,
},
/// Restore the workspace from its suspended state.
Resume {
name: String,
#[arg(long, env = "FORKD_URL", default_value = "http://127.0.0.1:8889")]
daemon_url: String,
#[arg(long, env = "FORKD_TOKEN")]
daemon_token: Option<String>,
},
/// List all workspaces tracked by the daemon.
List {
#[arg(long, env = "FORKD_URL", default_value = "http://127.0.0.1:8889")]
daemon_url: String,
#[arg(long, env = "FORKD_TOKEN")]
daemon_token: Option<String>,
},
/// Delete a workspace. Kills the live sandbox (if any) and
/// removes the state snapshot. Does NOT touch the source snapshot.
Delete {
name: String,
#[arg(long, env = "FORKD_URL", default_value = "http://127.0.0.1:8889")]
daemon_url: String,
#[arg(long, env = "FORKD_TOKEN")]
daemon_token: Option<String>,
},
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -453,6 +523,159 @@ fn main() -> Result<()> {
description,
base_image,
} => push_cmd(tag, url, description, base_image),
Cmd::Workspace(action) => workspace_cmd(action),
}
}

fn workspace_cmd(action: WorkspaceAction) -> Result<()> {
use serde_json::{json, Value};
fn daemon_request(
method: &str,
url: String,
path: &str,
token: Option<String>,
body: Option<Value>,
) -> Result<Value> {
let mut req = ureq::request(method, &format!("{}{path}", url.trim_end_matches('/')));
if let Some(t) = token {
req = req.set("Authorization", &format!("Bearer {t}"));
}
req = req.set("Content-Type", "application/json");
let resp = match body {
Some(b) => {
let bytes =
serde_json::to_vec(&b).map_err(|e| anyhow::anyhow!("serialize body: {e}"))?;
req.send_bytes(&bytes)
}
None => req.call(),
};
match resp {
Ok(r) => {
let text = r.into_string().unwrap_or_default();
if text.is_empty() {
Ok(Value::Null)
} else {
serde_json::from_str(&text).map_err(|e| anyhow::anyhow!("parse response: {e}"))
}
}
Err(ureq::Error::Status(code, r)) => {
let body = r.into_string().unwrap_or_default();
anyhow::bail!("daemon HTTP {code}: {body}")
}
Err(e) => Err(anyhow::anyhow!("daemon request failed: {e}")),
}
}
fn print_ws(v: &Value) {
println!(
"{:<24} {:<10} source={:<24} state={:<24} live={}",
v.get("name").and_then(Value::as_str).unwrap_or("?"),
v.get("status").and_then(Value::as_str).unwrap_or("?"),
v.get("source_snapshot_tag")
.and_then(Value::as_str)
.unwrap_or("?"),
v.get("current_state_tag")
.and_then(Value::as_str)
.unwrap_or("-"),
v.get("live_sandbox_id")
.and_then(Value::as_str)
.unwrap_or("-"),
);
}
match action {
WorkspaceAction::Create {
name,
snapshot,
per_child_netns,
memory_limit_mib,
daemon_url,
daemon_token,
} => {
let mut body = json!({
"name": name,
"snapshot_tag": snapshot,
"per_child_netns": per_child_netns,
});
if let Some(m) = memory_limit_mib {
body["memory_limit_mib"] = json!(m);
}
let resp = daemon_request(
"POST",
daemon_url,
"/v1/workspaces",
daemon_token,
Some(body),
)?;
print_ws(&resp);
Ok(())
}
WorkspaceAction::Suspend {
name,
diff,
daemon_url,
daemon_token,
} => {
let body = json!({"diff": diff});
let resp = daemon_request(
"POST",
daemon_url,
&format!("/v1/workspaces/{name}/suspend"),
daemon_token,
Some(body),
)?;
print_ws(&resp);
Ok(())
}
WorkspaceAction::Resume {
name,
daemon_url,
daemon_token,
} => {
let resp = daemon_request(
"POST",
daemon_url,
&format!("/v1/workspaces/{name}/resume"),
daemon_token,
Some(json!({})),
)?;
print_ws(&resp);
Ok(())
}
WorkspaceAction::List {
daemon_url,
daemon_token,
} => {
let resp = daemon_request("GET", daemon_url, "/v1/workspaces", daemon_token, None)?;
if let Some(arr) = resp.as_array() {
if arr.is_empty() {
println!("(no workspaces)");
} else {
for ws in arr {
print_ws(ws);
}
}
} else {
println!(
"{}",
serde_json::to_string_pretty(&resp).unwrap_or_default()
);
}
Ok(())
}
WorkspaceAction::Delete {
name,
daemon_url,
daemon_token,
} => {
daemon_request(
"DELETE",
daemon_url,
&format!("/v1/workspaces/{name}"),
daemon_token,
None,
)?;
println!("deleted workspace '{name}'");
Ok(())
}
}
}

Expand Down
66 changes: 66 additions & 0 deletions crates/forkd-controller/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,72 @@ pub struct SandboxInfo {
pub last_branch_memory_path: Option<std::path::PathBuf>,
}

/// State of a stateful workspace (#116). Tracks whether the workspace
/// is currently driving a live sandbox or has been suspended to a
/// state tag (so a future `resume` can pick up where it left off).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WorkspaceStatus {
/// Has a live sandbox (`live_sandbox_id` is Some).
Running,
/// No live sandbox; `current_state_tag` points at the latest
/// suspended snapshot. `resume` spawns from there.
Suspended,
/// Was Running at daemon shutdown / crash. The live sandbox is
/// gone; the workspace needs a fresh resume from
/// `current_state_tag` (if any) or `source_snapshot_tag` (if
/// never suspended).
Stale,
}

/// `POST /v1/workspaces` — create a new stateful workspace.
///
/// Spawns a sandbox from `snapshot_tag` and tracks it as a workspace
/// the user can `suspend` / `resume` across daemon restarts. The
/// workspace is identified by `name` (unique per daemon).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateWorkspaceRequest {
pub name: String,
pub snapshot_tag: String,
#[serde(default)]
pub per_child_netns: bool,
#[serde(default)]
pub memory_limit_mib: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceInfo {
pub id: String,
pub name: String,
pub source_snapshot_tag: String,
/// Set after the first successful `suspend`. None for workspaces
/// that have only been Running since creation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_state_tag: Option<String>,
pub status: WorkspaceStatus,
/// Set when status == Running. None otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub live_sandbox_id: Option<String>,
pub created_at_unix: u64,
pub last_active_unix: u64,
/// Persisted between resumes — used to chain diff snapshots
/// across the workspace lifetime if the operator opts in via
/// `suspend?diff=true`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_branch_memory_path: Option<std::path::PathBuf>,
}

/// `POST /v1/workspaces/:name/suspend` request body.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SuspendWorkspaceRequest {
/// Use v0.3 diff snapshot for the suspend write. ~200 ms source
/// pause vs seconds for a Full snapshot. Honors the same
/// `last_branch_memory_path` chain that `POST /v1/sandboxes/:id/branch`
/// uses.
#[serde(default)]
pub diff: bool,
}

/// `POST /v1/sandboxes/:id/exec`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecRequest {
Expand Down
Loading
Loading