From d6806d59cac041071948c7c5dcfafefc8cb1c23b Mon Sep 17 00:00:00 2001 From: Stephan Fitzpatrick Date: Sat, 24 Oct 2026 23:20:01 +0000 Subject: [PATCH 1/2] Sandboxes expire: an optional expires on every sandbox, reaped by isb serve A sandbox spec gains `expires` (`24h`, `7d`, or `never`), and `isb create --expires`. isb keeps the deadline on the instance as user.isb.expires_at, and isb serve's reaper now deletes expired sandboxes in every incus project, plain ones in `default` included, not only orgs. - Default: 7 days for a plain sandbox, the org's sandbox_expiry with --org; applied by the CLI and isb rpc (Python, TypeScript) when isb creates the sandbox. The Rust API, isbx and the stack controller set none (EnsureOptions::default_expiry is None by default). - A lease: each isb up / create --ensure renews it, never shortens it, and never adds one to an instance without a deadline, so existing sandboxes are untouched. - isb ls shows EXPIRES; isb expiry NAME [--in D | --by D | --never]. - sandbox_create takes the spec's expires too; isb serve still refuses never and caps at 30 days. - Named volumes stay, as with isb rm. - To stay under the ratchet: ReadyCheck to spec/ready.rs, the name helpers to plan/names.rs, update_instance to sandbox/update.rs, sandbox_extend to daemon/sandboxes.rs, ls and prune to the CLI's instances.rs. Claude-Session: https://claude.ai/code/session_01SqR3QhYxi5hybLoCwjddgp --- SKILL.md | 8 +- crates/isb-core/src/plan.rs | 55 +--- crates/isb-core/src/plan/names.rs | 52 ++++ crates/isb-core/src/rpc.rs | 38 ++- crates/isb-core/src/sandbox.rs | 69 ++--- crates/isb-core/src/sandbox/expiry.rs | 286 ++++++++++++++++++ crates/isb-core/src/sandbox/update.rs | 56 ++++ crates/isb-core/src/spec.rs | 106 +------ crates/isb-core/src/spec/ready.rs | 97 ++++++ crates/isb-daemon/src/daemon/guide/cli.md | 6 +- .../isb-daemon/src/daemon/guide/sandboxes.md | 3 +- crates/isb-daemon/src/daemon/mod.rs | 108 +------ crates/isb-daemon/src/daemon/sandboxes.rs | 136 ++++++++- crates/isb-daemon/src/daemon/tools.rs | 4 +- crates/isb-daemon/src/sandbox_life.rs | 29 +- docs/concepts/sandboxes.md | 53 +++- docs/guides/sdk.md | 7 + docs/reference/cli.md | 18 +- docs/reference/compose.md | 34 ++- docs/reference/mcp-tools.md | 2 +- scripts/ratchet.txt | 7 +- sdk/python/src/isb/_spec.py | 12 + sdk/typescript/src/spec.ts | 8 + src/bin/isb.rs | 59 +--- src/bin/isb/expiry.rs | 135 +++++++++ src/bin/isb/instances.rs | 68 ++++- src/bin/isb/project.rs | 10 +- 27 files changed, 1045 insertions(+), 421 deletions(-) create mode 100644 crates/isb-core/src/plan/names.rs create mode 100644 crates/isb-core/src/sandbox/expiry.rs create mode 100644 crates/isb-core/src/sandbox/update.rs create mode 100644 crates/isb-core/src/spec/ready.rs create mode 100644 src/bin/isb/expiry.rs diff --git a/SKILL.md b/SKILL.md index 3519be44..2a6e030b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -329,8 +329,12 @@ The SDKs drive the local `isb` binary, so they need the incus socket too. to boot, and wait for the incus agent before `exec` works. Host edits do not reach file watchers inside a VM (use polling). Port forwards into a VM must not listen on host 127.0.0.1. -- **Sandboxes made through the daemon expire.** Extend one you still need - (`sandbox_extend`) before its expiry, or it is deleted. +- **Every sandbox expires.** One from `isb create` or `isb up` is deleted 7 + days after it was last brought up (an org's: its `sandbox_expiry`, 24 h by + default); each `isb up` renews it. Set `expires: 4h` (`--expires 4h`) for + a one-off, `expires: never` for a tool container that must stay, and see + or change it with `isb ls` and `isb expiry NAME --by 24h`. Through the + daemon: `sandbox_extend` before its expiry, or it is deleted. - A stalled incus step fails with the step's name rather than hanging; `--create-timeout 20m` allows slow image downloads. diff --git a/crates/isb-core/src/plan.rs b/crates/isb-core/src/plan.rs index 1b2d3f89..c4d28be1 100644 --- a/crates/isb-core/src/plan.rs +++ b/crates/isb-core/src/plan.rs @@ -21,11 +21,14 @@ use serde_json::{Value, json}; use crate::error::{Error, Result}; use crate::flex::parse_duration; use crate::idmap::{self, SubIds}; +use crate::sandbox::expiry::Expires; use crate::spec::{ ExecDefaults, InstanceType, MountType, PortBind, ReadyCheck, RestartMode, SandboxSpec, }; +mod names; mod owner; +pub use names::{device_name_for_path, validate_instance_name}; pub type Props = BTreeMap; @@ -372,60 +375,15 @@ pub struct Desired { /// can be written while it is stopped. #[serde(skip)] pub before_start: Option, + /// The spec's `expires` (crate::sandbox::expiry). + #[serde(skip)] + pub expires: Option, } /// Named-volume definitions available to a sandbox (from a compose file's /// top-level `volumes:`). pub type VolumeDefs = BTreeMap; -/// Valid incus instance name: <= 63 chars, [a-zA-Z0-9-], starts with a letter, -/// does not end with '-'. -pub fn validate_instance_name(name: &str) -> Result<()> { - let ok = !name.is_empty() - && name.len() <= 63 - && name.starts_with(|c: char| c.is_ascii_alphabetic()) - && !name.ends_with('-') - && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); - if ok { - Ok(()) - } else { - Err(Error::invalid(format!( - "invalid sandbox name {name:?}: use at most 63 of [a-z0-9-], starting with a letter" - ))) - } -} - -/// Deterministic device name for a guest mount path. -pub fn device_name_for_path(guest: &str) -> String { - let mut s = String::new(); - for c in guest.to_ascii_lowercase().chars() { - if c.is_ascii_alphanumeric() { - s.push(c); - } else if !s.ends_with('-') { - s.push('-'); - } - } - let s = s.trim_matches('-').to_string(); - let s = if s.is_empty() { "mount".to_string() } else { s }; - if s.len() <= 48 { - return s; - } - format!( - "{}-{:08x}", - s[s.len() - 39..].trim_start_matches('-'), - fnv32(guest) - ) -} - -fn fnv32(s: &str) -> u32 { - let mut h: u32 = 0x811c9dc5; - for b in s.bytes() { - h ^= b as u32; - h = h.wrapping_mul(0x01000193); - } - h -} - /// Split `tcp:1.2.3.4:5173` into ("tcp", "1.2.3.4", 5173). IPv6 in brackets works. pub fn split_addr(addr: &str) -> Option<(&str, &str, u16)> { let (proto, rest) = addr.split_once(':')?; @@ -968,6 +926,7 @@ pub fn resolve( .collect(), egress, before_start: None, + expires: spec.expires.as_deref().map(Expires::parse).transpose()?, }) } diff --git a/crates/isb-core/src/plan/names.rs b/crates/isb-core/src/plan/names.rs new file mode 100644 index 00000000..1f9b92ce --- /dev/null +++ b/crates/isb-core/src/plan/names.rs @@ -0,0 +1,52 @@ +//! Names isb derives: instance names it accepts, and deterministic device +//! names for guest mount paths. + +use crate::error::{Error, Result}; + +/// Valid incus instance name: <= 63 chars, [a-zA-Z0-9-], starts with a letter, +/// does not end with '-'. +pub fn validate_instance_name(name: &str) -> Result<()> { + let ok = !name.is_empty() + && name.len() <= 63 + && name.starts_with(|c: char| c.is_ascii_alphabetic()) + && !name.ends_with('-') + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); + if ok { + Ok(()) + } else { + Err(Error::invalid(format!( + "invalid sandbox name {name:?}: use at most 63 of [a-z0-9-], starting with a letter" + ))) + } +} + +/// Deterministic device name for a guest mount path. +pub fn device_name_for_path(guest: &str) -> String { + let mut s = String::new(); + for c in guest.to_ascii_lowercase().chars() { + if c.is_ascii_alphanumeric() { + s.push(c); + } else if !s.ends_with('-') { + s.push('-'); + } + } + let s = s.trim_matches('-').to_string(); + let s = if s.is_empty() { "mount".to_string() } else { s }; + if s.len() <= 48 { + return s; + } + format!( + "{}-{:08x}", + s[s.len() - 39..].trim_start_matches('-'), + fnv32(guest) + ) +} + +fn fnv32(s: &str) -> u32 { + let mut h: u32 = 0x811c9dc5; + for b in s.bytes() { + h ^= b as u32; + h = h.wrapping_mul(0x01000193); + } + h +} diff --git a/crates/isb-core/src/rpc.rs b/crates/isb-core/src/rpc.rs index 511f0ac1..9fdb5516 100644 --- a/crates/isb-core/src/rpc.rs +++ b/crates/isb-core/src/rpc.rs @@ -59,8 +59,12 @@ struct Server { out: Out, client: Client, execs: Mutex>, + org_expiry: OrgExpiry, } +/// Looks up an org's `sandbox_expiry` (the CLI reads `isb serve`'s state). +pub type OrgExpiry = fn(&crate::org::OrgId) -> Option; + #[derive(Deserialize)] struct Request { #[serde(default)] @@ -75,11 +79,22 @@ pub fn serve( input: R, output: W, client: Client, +) -> Result<()> { + serve_with(input, output, client, |_| None) +} + +/// [`serve`], with the orgs' default sandbox expiry from `org_expiry`. +pub fn serve_with( + input: R, + output: W, + client: Client, + org_expiry: OrgExpiry, ) -> Result<()> { let server = Arc::new(Server { out: Arc::new(Mutex::new(Box::new(output))), client, execs: Mutex::new(HashMap::new()), + org_expiry, }); server.send(&json!({"isb": env!("CARGO_PKG_VERSION"), "protocol": PROTOCOL})); let mut workers = Vec::new(); @@ -139,6 +154,13 @@ impl Server { } } + fn default_expiry(&self, c: &Client) -> Option { + Some(sandbox::expiry::default_for( + c.project_name(), + self.org_expiry, + )) + } + fn client(&self, project: &Option) -> Client { match project { Some(p) => self.client.clone().project(p), @@ -163,6 +185,7 @@ impl Server { let c = self.client(&a.project); let opts = EnsureOptions { wait_ready: a.wait_ready, + default_expiry: self.default_expiry(&c), ..Default::default() }; let d = resolve_spec(&c, &a)?; @@ -177,6 +200,7 @@ impl Server { diff: DiffOptions { prune_devices: a.prune_devices, }, + default_expiry: self.default_expiry(&c), ..Default::default() }; let d = resolve_spec(&c, &a)?; @@ -344,20 +368,16 @@ impl Server { "compose.up" => { let a: ComposeParams = params(p)?; let proj = compose::load(&a.load())?; + let c = self.client(&a.project); let opts = EnsureOptions { wait_ready: a.wait_ready, diff: DiffOptions { prune_devices: a.prune_devices, }, + default_expiry: self.default_expiry(&compose::client_for(&c, &proj)), ..Default::default() }; - let out = compose::up( - &self.client(&a.project), - &proj, - &a.services, - opts, - &mut progress, - )?; + let out = compose::up(&c, &proj, &a.services, opts, &mut progress)?; Ok(json!( out.into_iter() .map(|(s, r)| json!({"service": s, "report": r})) @@ -532,7 +552,9 @@ fn create_desired( return Err(Error::AlreadyExists(d.name.clone())); } let plan = sandbox::plan_desired(c, d, opts.diff)?; - sandbox::apply(c, d, &plan, report)?; + let stamped = sandbox::expiry::stamped(d, &plan, opts.default_expiry); + sandbox::apply(c, &stamped, &plan, report)?; + sandbox::expiry::report_created(&stamped, report); if opts.wait_ready { sandbox::wait_ready(c, &d.name, &d.ready, d.ready_timeout, &d.exec)?; } diff --git a/crates/isb-core/src/sandbox.rs b/crates/isb-core/src/sandbox.rs index 8a43ca43..05c8a4ff 100644 --- a/crates/isb-core/src/sandbox.rs +++ b/crates/isb-core/src/sandbox.rs @@ -12,8 +12,10 @@ use crate::error::{Error, Result}; use crate::exec::{self, ExecOptions, ExecOutput, ExecStream, Stdin}; use crate::idmap::SubIds; use crate::lock::NameLock; +pub mod expiry; pub mod images; mod prune; +mod update; use crate::plan::{ self, Action, Actual, Desired, DesiredDevice, DiffOptions, HostFacts, Props, SandboxPlan, @@ -21,6 +23,8 @@ use crate::plan::{ }; use crate::spec::{ExecDefaults, PortSpec, ReadyCheck, SandboxSpec}; pub use prune::{PruneItem, prune_missing_path}; +#[doc(hidden)] +pub use update::update_instance; /// Config key recording which isb call created an instance. A half-created /// instance is only ever cleaned up by the call whose token it carries. @@ -131,6 +135,9 @@ pub struct EnsureOptions { pub wait_ready: bool, /// How long to wait for another isb holding this sandbox's lock. pub lock_wait: Duration, + /// The lifetime of a sandbox whose spec gives no `expires` ([`expiry`]): + /// set by the CLI and `isb rpc`; `None` gives it no deadline. + pub default_expiry: Option, } impl Default for EnsureOptions { @@ -139,6 +146,7 @@ impl Default for EnsureOptions { diff: DiffOptions::default(), wait_ready: true, lock_wait: Duration::from_secs(900), + default_expiry: None, } } } @@ -472,55 +480,6 @@ fn flush_updates( Ok(()) } -type Obj = serde_json::Map; - -/// Read-modify-write of an instance under `If-Match`, so a concurrent change by -/// another tool is never silently overwritten (a 412 re-reads and retries). -#[doc(hidden)] -pub fn update_instance( - client: &Client, - name: &str, - step: &str, - modify: &mut dyn FnMut(&mut Obj, &mut Obj) -> Result<()>, -) -> Result<()> { - let path = inst_path(name); - for attempt in 0..5 { - let (inst, etag) = client.get_etag(&path)?; - let mut config = inst - .get("config") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - let mut devices = inst - .get("devices") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - modify(&mut config, &mut devices)?; - let body = json!({ - "architecture": inst.get("architecture"), - "config": config, - "devices": devices, - "ephemeral": inst.get("ephemeral"), - "profiles": inst.get("profiles"), - "stateful": inst.get("stateful"), - "description": inst.get("description"), - }); - match client.mutate_if_match( - "PUT", - &path, - &body, - etag.as_deref(), - step, - client.timeouts.other, - ) { - Err(Error::Api { status: 412, .. }) if attempt < 4 => continue, - r => return r.map(|_| ()), - } - } - unreachable!() -} - fn create_instance(client: &Client, desired: &Desired, report: &mut dyn FnMut(&str)) -> Result<()> { let fingerprint = match &desired.image.server { None => Some( @@ -904,7 +863,13 @@ pub fn ensure( }, )?; let plan = plan_desired(client, desired, opts.diff)?; - let mut out = apply(client, desired, &plan, report)?; + let stamped = expiry::stamped(desired, &plan, opts.default_expiry); + let mut out = apply(client, &stamped, &plan, report)?; + if out.created { + expiry::report_created(&stamped, report); + } else { + expiry::renew(client, desired, opts.default_expiry, report)?; + } // Report the settled listen address of every searched port, whether this // call added it or found it already correct somewhere in its range. if desired.devices.values().any(|d| d.search.is_some()) { @@ -1006,7 +971,9 @@ impl Sandbox { return Err(Error::AlreadyExists(d.name.clone())); } let plan = plan_desired(client, &d, opts.diff)?; - apply(client, &d, &plan, report)?; + let stamped = expiry::stamped(&d, &plan, opts.default_expiry); + apply(client, &stamped, &plan, report)?; + expiry::report_created(&stamped, report); if opts.wait_ready { wait_ready(client, &d.name, &d.ready, d.ready_timeout, &d.exec)?; if let Some(p) = &d.egress { diff --git a/crates/isb-core/src/sandbox/expiry.rs b/crates/isb-core/src/sandbox/expiry.rs new file mode 100644 index 00000000..3006f488 --- /dev/null +++ b/crates/isb-core/src/sandbox/expiry.rs @@ -0,0 +1,286 @@ +//! A sandbox's expiry (docs/concepts/sandboxes.md#expiry): the deadline isb +//! keeps on the instance as `user.isb.expires_at`, after which `isb serve`'s +//! reaper deletes it. Set when isb creates the sandbox and renewed by each +//! later `isb up`, so it is a lease: a sandbox nobody brings up again goes +//! away on its own. + +use std::borrow::Cow; +use std::time::Duration; + +use serde_json::json; + +use super::{Reporter, update_instance}; +use crate::client::Client; +use crate::error::{Error, Result}; +use crate::plan::{Action, Desired, SandboxPlan}; + +/// The instance config key holding the deadline: unix seconds, or `never`. +pub const KEY_EXPIRES_AT: &str = "user.isb.expires_at"; + +/// What a plain sandbox (outside every org) lives without an `expires`. +pub const DEFAULT_PLAIN_EXPIRY: Duration = Duration::from_secs(7 * 86400); + +/// What an org's sandbox lives without an `expires`, unless the org's +/// admins changed its `sandbox_expiry`. +pub const DEFAULT_ORG_EXPIRY: Duration = Duration::from_secs(86400); + +/// A renewal that moves the deadline by less than this is not written, so +/// `isb up` in a loop does not rewrite the instance each time. +const RENEW_SLACK: u64 = 60; + +/// A spec's `expires`: a lifetime from now, or never. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Expires { + Never, + After(Duration), +} + +impl Expires { + /// `24h`, `7d`, a number of seconds, or `never` (`none`, `off`). + pub fn parse(s: &str) -> Result { + let s = s.trim(); + if matches!(s, "never" | "none" | "off") { + return Ok(Expires::Never); + } + let d = crate::flex::parse_duration(s) + .map_err(|e| Error::invalid(format!("expires {s:?}: {e}")))?; + if d.is_zero() { + return Err(Error::invalid(format!( + "expires {s:?}: a positive duration, or never" + ))); + } + Ok(Expires::After(d)) + } +} + +/// The lifetime a new sandbox in incus project `project` gets when its spec +/// names none: in an org's project, the org's `sandbox_expiry` +/// (`org_expiry`, else [`DEFAULT_ORG_EXPIRY`]); elsewhere, +/// [`DEFAULT_PLAIN_EXPIRY`]. +pub fn default_for( + project: &str, + org_expiry: impl FnOnce(&crate::org::OrgId) -> Option, +) -> Duration { + match crate::org::OrgId::from_incus_project(project) { + Some(org) => org_expiry(&org).unwrap_or(DEFAULT_ORG_EXPIRY), + None => DEFAULT_PLAIN_EXPIRY, + } +} + +/// A stack replica and a build never expire: their controller owns them. +fn exempt(d: &Desired) -> bool { + d.config.contains_key("user.isb.stack") || d.config.contains_key("user.isb.build") +} + +/// The deadline a sandbox created now gets: unix seconds, `never`, or none +/// (no `expires` and no default: the instance carries no key). +fn at_creation(d: &Desired, default: Option, now: u64) -> Option { + if exempt(d) { + return None; + } + match d.expires { + Some(Expires::Never) => Some("never".into()), + Some(Expires::After(life)) => Some((now + life.as_secs()).to_string()), + None => default.map(|life| (now + life.as_secs()).to_string()), + } +} + +/// What a later `up` does to an existing deadline (`current`, as stored): +/// `Some(new value)`, or `None` to leave it. +/// +/// - `expires: never` makes it never. +/// - `expires: D` makes it now + D, unless that is not later. +/// - no `expires` renews a deadline the instance has by the default, and +/// never adds one: an instance without a deadline (made before isb had +/// them, or set to never) keeps none. +pub fn renewal( + current: Option<&str>, + expires: Option, + default: Option, + now: u64, +) -> Option { + let at: Option = current.and_then(|c| c.trim().parse().ok()); + let life = match expires { + Some(Expires::Never) => { + return (current.map(str::trim) != Some("never")).then(|| "never".into()); + } + Some(Expires::After(life)) => life, + None => match (at, default) { + (Some(_), Some(life)) => life, + _ => return None, + }, + }; + let want = now + life.as_secs(); + match at { + Some(at) if want < at + RENEW_SLACK => None, + _ => Some(want.to_string()), + } +} + +/// `in 6d 23h`, `never`, or `5m ago`, for a stored deadline. +pub fn describe(v: &str, now: u64) -> String { + match v.trim().parse::() { + Ok(at) if at > now => format!("in {}", human(at - now)), + Ok(at) => format!("{} ago", human(now - at)), + Err(_) if v.trim() == "never" => "never".into(), + Err(_) => "-".into(), + } +} + +/// `desired` with its deadline in the create config, when `plan` creates +/// the instance. +pub fn stamped<'a>( + desired: &'a Desired, + plan: &SandboxPlan, + default: Option, +) -> Cow<'a, Desired> { + let creates = plan + .actions + .iter() + .any(|a| matches!(a, Action::CreateInstance { .. })); + match at_creation(desired, default, crate::stack::now_secs()).filter(|_| creates) { + Some(v) => { + let mut d = desired.clone(); + d.config.insert(KEY_EXPIRES_AT.into(), v); + Cow::Owned(d) + } + None => Cow::Borrowed(desired), + } +} + +/// Say when a sandbox just created from `d` expires, if it does. +pub fn report_created(d: &Desired, report: Reporter<'_>) { + if let Some(v) = d.config.get(KEY_EXPIRES_AT) { + let when = describe(v, crate::stack::now_secs()); + report(&format!("{}: expires {when}", d.name)); + } +} + +/// Renew an existing sandbox's deadline (see [`renewal`]). +pub(super) fn renew( + client: &Client, + desired: &Desired, + default: Option, + report: Reporter<'_>, +) -> Result<()> { + if exempt(desired) || (desired.expires.is_none() && default.is_none()) { + return Ok(()); + } + let now = crate::stack::now_secs(); + let inst = client.get(&super::inst_path(&desired.name))?; + let current = inst["config"][KEY_EXPIRES_AT].as_str(); + let Some(v) = renewal(current, desired.expires, default, now) else { + return Ok(()); + }; + update_instance( + client, + &desired.name, + &format!("renew expiry of {}", desired.name), + &mut |config, _| { + config.insert(KEY_EXPIRES_AT.into(), json!(v)); + Ok(()) + }, + )?; + report(&format!("{}: expires {}", desired.name, describe(&v, now))); + Ok(()) +} + +/// `90061` -> `1d 1h`: the two largest units. +pub fn human(secs: u64) -> String { + let units = [(86400, "d"), (3600, "h"), (60, "m"), (1, "s")]; + let mut parts = Vec::new(); + let mut rest = secs; + for (n, u) in units { + if rest >= n { + parts.push(format!("{}{u}", rest / n)); + rest %= n; + } + if parts.len() == 2 { + break; + } + } + if parts.is_empty() { + "0s".into() + } else { + parts.join(" ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DAY: u64 = 86400; + + #[test] + fn parses_durations_and_never() { + assert_eq!( + Expires::parse("24h").unwrap(), + Expires::After(Duration::from_secs(DAY)) + ); + assert_eq!(Expires::parse(" never ").unwrap(), Expires::Never); + assert_eq!(Expires::parse("none").unwrap(), Expires::Never); + assert!(Expires::parse("0s").is_err()); + assert!(Expires::parse("soon").is_err()); + } + + #[test] + fn defaults_by_project() { + assert_eq!(default_for("default", |_| None), DEFAULT_PLAIN_EXPIRY); + assert_eq!(default_for("titan-norm-ct", |_| None), DEFAULT_PLAIN_EXPIRY); + assert_eq!(default_for("isb-norm", |_| None), DEFAULT_ORG_EXPIRY); + let three = Duration::from_secs(3 * DAY); + assert_eq!( + default_for("isb-norm", |o| { + assert_eq!(o.as_str(), "norm"); + Some(three) + }), + three + ); + } + + #[test] + fn an_explicit_expires_sets_never_shortens() { + let d = Some(Expires::After(Duration::from_secs(DAY))); + // Added where there was none. + assert_eq!(renewal(None, d, None, 1000), Some((1000 + DAY).to_string())); + assert_eq!(renewal(Some("never"), d, None, 0), Some(DAY.to_string())); + // Renewed from now. + assert_eq!( + renewal(Some("500"), d, None, 1000), + Some((1000 + DAY).to_string()) + ); + // A later deadline (from `isb expiry --in 30d`) stays. + assert_eq!(renewal(Some(&(40 * DAY).to_string()), d, None, 0), None); + // A renewal of under a minute is not written. + assert_eq!(renewal(Some(&(DAY - 30).to_string()), d, None, 0), None); + } + + #[test] + fn never_clears_and_the_default_only_renews() { + assert_eq!( + renewal(Some("123"), Some(Expires::Never), None, 0), + Some("never".into()) + ); + assert_eq!(renewal(Some("never"), Some(Expires::Never), None, 0), None); + let week = Some(DEFAULT_PLAIN_EXPIRY); + // An instance without a deadline keeps none: the default is for + // new sandboxes only. + assert_eq!(renewal(None, None, week, 1000), None); + assert_eq!(renewal(Some("never"), None, week, 1000), None); + assert_eq!( + renewal(Some("2000"), None, week, 1000), + Some((1000 + 7 * DAY).to_string()) + ); + // No default (a library caller, the stack controller): untouched. + assert_eq!(renewal(Some("2000"), None, None, 1000), None); + } + + #[test] + fn describes_deadlines() { + assert_eq!(describe("7200", 0), "in 2h"); + assert_eq!(describe("100", 400), "5m ago"); + assert_eq!(describe("never", 0), "never"); + assert_eq!(describe("soon", 0), "-"); + } +} diff --git a/crates/isb-core/src/sandbox/update.rs b/crates/isb-core/src/sandbox/update.rs new file mode 100644 index 00000000..1751d1d0 --- /dev/null +++ b/crates/isb-core/src/sandbox/update.rs @@ -0,0 +1,56 @@ +//! Changing an instance's config and devices safely under concurrent writers. + +use serde_json::{Value, json}; + +use super::inst_path; +use crate::client::Client; +use crate::error::{Error, Result}; + +type Obj = serde_json::Map; + +/// Read-modify-write of an instance under `If-Match`, so a concurrent change by +/// another tool is never silently overwritten (a 412 re-reads and retries). +#[doc(hidden)] +pub fn update_instance( + client: &Client, + name: &str, + step: &str, + modify: &mut dyn FnMut(&mut Obj, &mut Obj) -> Result<()>, +) -> Result<()> { + let path = inst_path(name); + for attempt in 0..5 { + let (inst, etag) = client.get_etag(&path)?; + let mut config = inst + .get("config") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let mut devices = inst + .get("devices") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + modify(&mut config, &mut devices)?; + let body = json!({ + "architecture": inst.get("architecture"), + "config": config, + "devices": devices, + "ephemeral": inst.get("ephemeral"), + "profiles": inst.get("profiles"), + "stateful": inst.get("stateful"), + "description": inst.get("description"), + }); + match client.mutate_if_match( + "PUT", + &path, + &body, + etag.as_deref(), + step, + client.timeouts.other, + ) { + Err(Error::Api { status: 412, .. }) if attempt < 4 => continue, + r => return r.map(|_| ()), + } + } + unreachable!() +} diff --git a/crates/isb-core/src/spec.rs b/crates/isb-core/src/spec.rs index 51d47c46..6d509d59 100644 --- a/crates/isb-core/src/spec.rs +++ b/crates/isb-core/src/spec.rs @@ -18,6 +18,8 @@ pub use env::Environment; mod mount; pub(crate) use mount::is_host_path; pub use mount::{MountType, VolumeOptions, VolumeSpec}; +mod ready; +pub use ready::ReadyCheck; /// A compose file: named volumes plus any number of services, each one /// sandbox. Mirrors docker compose wherever incus allows. @@ -363,6 +365,19 @@ pub struct SandboxSpec { #[serde(default, skip_serializing_if = "Option::is_none")] pub egress: Option, + /// How long the sandbox lives (`24h`, `7d`), or `never`. Kept on the + /// instance as `user.isb.expires_at`; `isb serve` deletes it once that + /// passes. Each `isb up` renews it from now, and never shortens it. + /// Omitted: 7 days for a plain sandbox, the org's `sandbox_expiry` in an + /// org (docs/concepts/sandboxes.md#expiry). Stacks ignore it. + #[serde( + default, + deserialize_with = "flex::opt_string", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "Option")] + pub expires: Option, + /// Extra instance config keys, set verbatim (escape hatch). #[serde( default, @@ -774,97 +789,6 @@ impl JsonSchema for PortSpec { } } -/// A readiness check. "Running" alone is not ready: networking comes up a beat -/// after the instance does. -#[derive(Debug, Clone, PartialEq, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum ReadyCheck { - /// The instance reports Running. - Running, - /// The incus agent answers exec (VMs; always true for a container once running). - Agent, - /// The guest has a default route (IPv4 or IPv6). - DefaultRoute, - /// `getent passwd ` succeeds in the guest. - UserExists(String), - /// The path is writable by the service's `user` (else root). - PathWritable(String), - /// This argv exits 0 in the guest (run as root). - Command(Vec), -} - -// YAML libraries disagree on externally tagged enums (serde_yaml_ng wants -// `!user_exists dev` tags), so the map form `{user_exists: dev}` is spelled out. -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -enum ReadyRepr { - Name(String), - UserExists { user_exists: String }, - PathWritable { path_writable: String }, - Command { command: Vec }, -} - -impl Serialize for ReadyCheck { - fn serialize(&self, s: S) -> Result { - match self { - ReadyCheck::Running => ReadyRepr::Name("running".into()), - ReadyCheck::DefaultRoute => ReadyRepr::Name("default_route".into()), - ReadyCheck::Agent => ReadyRepr::Name("agent".into()), - ReadyCheck::UserExists(u) => ReadyRepr::UserExists { - user_exists: u.clone(), - }, - ReadyCheck::PathWritable(p) => ReadyRepr::PathWritable { - path_writable: p.clone(), - }, - ReadyCheck::Command(c) => ReadyRepr::Command { - command: c.iter().cloned().map(flex::Scalar::String).collect(), - }, - } - .serialize(s) - } -} - -impl<'de> Deserialize<'de> for ReadyCheck { - fn deserialize>(d: D) -> Result { - use serde::de::Error as _; - let r = ReadyRepr::deserialize(d).map_err(|_| { - D::Error::custom( - "expected running, agent, default_route, {user_exists: USER}, {path_writable: PATH} or {command: [ARGV...]}", - ) - })?; - Ok(match r { - ReadyRepr::Name(n) => match n.as_str() { - "running" => ReadyCheck::Running, - "default_route" => ReadyCheck::DefaultRoute, - "agent" => ReadyCheck::Agent, - other => { - return Err(D::Error::custom(format!( - "unknown readiness check {other:?} (running, agent, default_route, user_exists, path_writable, command)" - ))); - } - }, - ReadyRepr::UserExists { user_exists } => ReadyCheck::UserExists(user_exists), - ReadyRepr::PathWritable { path_writable } => ReadyCheck::PathWritable(path_writable), - ReadyRepr::Command { command } => { - ReadyCheck::Command(command.into_iter().map(flex::Scalar::into_string).collect()) - } - }) - } -} - -impl std::fmt::Display for ReadyCheck { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ReadyCheck::Running => write!(f, "running"), - ReadyCheck::DefaultRoute => write!(f, "default_route"), - ReadyCheck::Agent => write!(f, "agent"), - ReadyCheck::UserExists(u) => write!(f, "user_exists({u})"), - ReadyCheck::PathWritable(p) => write!(f, "path_writable({p})"), - ReadyCheck::Command(c) => write!(f, "command({})", c.join(" ")), - } - } -} - /// The `exec:` block of a service: exec defaults with no docker equivalent. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] diff --git a/crates/isb-core/src/spec/ready.rs b/crates/isb-core/src/spec/ready.rs new file mode 100644 index 00000000..efa05b71 --- /dev/null +++ b/crates/isb-core/src/spec/ready.rs @@ -0,0 +1,97 @@ +//! Readiness checks (`ready:`): what must hold before a sandbox counts as up. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::flex; + +/// A readiness check. "Running" alone is not ready: networking comes up a beat +/// after the instance does. +#[derive(Debug, Clone, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ReadyCheck { + /// The instance reports Running. + Running, + /// The incus agent answers exec (VMs; always true for a container once running). + Agent, + /// The guest has a default route (IPv4 or IPv6). + DefaultRoute, + /// `getent passwd ` succeeds in the guest. + UserExists(String), + /// The path is writable by the service's `user` (else root). + PathWritable(String), + /// This argv exits 0 in the guest (run as root). + Command(Vec), +} + +// YAML libraries disagree on externally tagged enums (serde_yaml_ng wants +// `!user_exists dev` tags), so the map form `{user_exists: dev}` is spelled out. +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +enum ReadyRepr { + Name(String), + UserExists { user_exists: String }, + PathWritable { path_writable: String }, + Command { command: Vec }, +} + +impl Serialize for ReadyCheck { + fn serialize(&self, s: S) -> Result { + match self { + ReadyCheck::Running => ReadyRepr::Name("running".into()), + ReadyCheck::DefaultRoute => ReadyRepr::Name("default_route".into()), + ReadyCheck::Agent => ReadyRepr::Name("agent".into()), + ReadyCheck::UserExists(u) => ReadyRepr::UserExists { + user_exists: u.clone(), + }, + ReadyCheck::PathWritable(p) => ReadyRepr::PathWritable { + path_writable: p.clone(), + }, + ReadyCheck::Command(c) => ReadyRepr::Command { + command: c.iter().cloned().map(flex::Scalar::String).collect(), + }, + } + .serialize(s) + } +} + +impl<'de> Deserialize<'de> for ReadyCheck { + fn deserialize>(d: D) -> Result { + use serde::de::Error as _; + let r = ReadyRepr::deserialize(d).map_err(|_| { + D::Error::custom( + "expected running, agent, default_route, {user_exists: USER}, {path_writable: PATH} or {command: [ARGV...]}", + ) + })?; + Ok(match r { + ReadyRepr::Name(n) => match n.as_str() { + "running" => ReadyCheck::Running, + "default_route" => ReadyCheck::DefaultRoute, + "agent" => ReadyCheck::Agent, + other => { + return Err(D::Error::custom(format!( + "unknown readiness check {other:?} (running, agent, default_route, user_exists, path_writable, command)" + ))); + } + }, + ReadyRepr::UserExists { user_exists } => ReadyCheck::UserExists(user_exists), + ReadyRepr::PathWritable { path_writable } => ReadyCheck::PathWritable(path_writable), + ReadyRepr::Command { command } => { + ReadyCheck::Command(command.into_iter().map(flex::Scalar::into_string).collect()) + } + }) + } +} + +impl std::fmt::Display for ReadyCheck { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReadyCheck::Running => write!(f, "running"), + ReadyCheck::DefaultRoute => write!(f, "default_route"), + ReadyCheck::Agent => write!(f, "agent"), + ReadyCheck::UserExists(u) => write!(f, "user_exists({u})"), + ReadyCheck::PathWritable(p) => write!(f, "path_writable({p})"), + ReadyCheck::Command(c) => write!(f, "command({})", c.join(" ")), + } + } +} diff --git a/crates/isb-daemon/src/daemon/guide/cli.md b/crates/isb-daemon/src/daemon/guide/cli.md index d6466744..fd297511 100644 --- a/crates/isb-daemon/src/daemon/guide/cli.md +++ b/crates/isb-daemon/src/daemon/guide/cli.md @@ -18,7 +18,7 @@ Only the CLI: `isb up`/`down`/`plan` of a whole compose file, `isb host setup`, Docker compose's format (`services`, `container_name`, `environment`, `volumes`, `ports`, `user`, `working_dir`, `command`, `mem_limit`) plus incus keys: `type: vm`, `storage`, `idmap`, `ready`, `incus_profiles`, -`raw_config`, `raw_devices`, `egress`. +`raw_config`, `raw_devices`, `egress`, `expires`. ```yaml services: @@ -58,6 +58,10 @@ services: closed network that a package manager's registry joins; `isb x install NAME --from npm:PKG|pypi:PKG|sh:LINE` keeps a CLI in its own sandbox behind a host shim. +- **Every sandbox expires:** 7 days after its last `isb up` (in an org, the + org's `sandbox_expiry`), when `isb serve` deletes it. `expires: 4h` or + `--expires 4h` for a one-off, `expires: never` for one that must stay; + `isb ls` shows it, `isb expiry NAME --by 24h` or `--never` changes it. ## SDKs diff --git a/crates/isb-daemon/src/daemon/guide/sandboxes.md b/crates/isb-daemon/src/daemon/guide/sandboxes.md index e53b0248..861a0555 100644 --- a/crates/isb-daemon/src/daemon/guide/sandboxes.md +++ b/crates/isb-daemon/src/daemon/guide/sandboxes.md @@ -5,7 +5,8 @@ Linux machine, starts in seconds) or a VM. - `sandbox_create`: `spec` is one compose service with `container_name`, as an object or YAML (the `isb.yaml` format, topic `cli`). `expires` (default - 24h, at most 30d), `idle_timeout` (default 2h, or `none`). Remote callers' + 24h, at most 30d; or the spec's own `expires`), `idle_timeout` (default + 2h, or `none`). Remote callers' specs are refused privileged mode, raw config, host bind mounts outside the org's bind roots, and non-loopback ports. - `sandbox_exec`: `name`, `argv` (a list, never a shell string: use diff --git a/crates/isb-daemon/src/daemon/mod.rs b/crates/isb-daemon/src/daemon/mod.rs index dd1276b7..61ecd730 100644 --- a/crates/isb-daemon/src/daemon/mod.rs +++ b/crates/isb-daemon/src/daemon/mod.rs @@ -855,9 +855,11 @@ fn sandbox_create(d: &Daemon, a: Value, c: &Caller) -> Result { } // Short-lived by rule: the org's defaults unless the call says otherwise. let settings = d.sandboxes.settings(&org)?; + // The call's `expires`, else the spec's; the reconcile below leaves it. + let expires = a.expires.or(spec.expires.take()); let (expires_at, idle) = crate::sandbox_life::sandbox_deadlines( &settings, - a.expires.as_deref(), + expires.as_deref(), a.idle_timeout.as_deref(), now_secs(), )?; @@ -904,115 +906,13 @@ fn sandbox_create(d: &Daemon, a: Value, c: &Caller) -> Result { } /// What `isb.owner` says about a sandbox this caller creates. -fn owner_label(c: &Caller) -> String { +pub(super) fn owner_label(c: &Caller) -> String { match c { Caller::Superadmin(s) => s.label(), _ => format!("mcp:{}", caller_name(c)), } } -fn sandbox_extend(d: &Daemon, a: Value, c: &Caller) -> Result { - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct A { - name: String, - #[serde(default)] - by: Option, - #[serde(default)] - idle_timeout: Option, - #[serde(default)] - org: Option, - } - let org = arg_org(&a)?; - let a: A = args(a)?; - let oc = d.oc(&a.org)?; - let info = d.reach(c, &oc, &a.name)?; - let labels: BTreeMap = info - .config - .iter() - .filter_map(|(k, v)| k.strip_prefix("user.").map(|k| (k.to_string(), v.clone()))) - .collect(); - if crate::sandbox_life::kind_of(&labels) != "sandbox" { - return Err(Error::invalid(format!( - "{} is a {}, not a sandbox: it does not expire", - a.name, - crate::sandbox_life::kind_of(&labels) - ))); - } - // Its creator, or the org's admins. - let mine = labels - .get("isb.owner") - .is_some_and(|o| *o == owner_label(c)); - let admin = match c { - Caller::Local { .. } | Caller::Superadmin(_) => true, - Caller::User { principal } => { - principal.platform_admin - || principal - .role_in(&org) - .is_some_and(|r| r >= crate::auth::Role::Admin) - } - _ => false, - }; - if !mine && !admin { - return Err(Error::Forbidden(format!( - "{} was created by {}; its creator or the org's admins extend it", - a.name, - labels - .get("isb.owner") - .map(String::as_str) - .unwrap_or("someone else") - ))); - } - let now = now_secs(); - let mut patch = serde_json::Map::new(); - let current = labels - .get("isb.expires_at") - .and_then(|v| v.parse::().ok()); - let by = match &a.by { - Some(b) => crate::flex::parse_duration(b).map_err(Error::invalid)?, - None if a.idle_timeout.is_some() => Duration::ZERO, - None => Duration::from_secs(86400), - }; - let mut expires_at = current; - if !by.is_zero() { - let e = crate::sandbox_life::extended(current, by, now)?; - patch.insert( - crate::sandbox_life::KEY_EXPIRES_AT.into(), - json!(e.to_string()), - ); - expires_at = Some(e); - } - let mut idle = labels - .get("isb.idle_timeout") - .and_then(|v| v.parse::().ok()) - .filter(|s| *s > 0); - if let Some(t) = &a.idle_timeout { - idle = crate::sandbox_life::idle(t)?.map(|d| d.as_secs()); - patch.insert( - crate::sandbox_life::KEY_IDLE_TIMEOUT.into(), - json!(idle.unwrap_or(0).to_string()), - ); - } - oc.mutate( - "PATCH", - &format!("/1.0/instances/{}", crate::client::encode_segment(&a.name)), - Some(&json!({"config": patch})), - &format!("extend sandbox {}", a.name), - oc.timeouts.other, - )?; - d.sandboxes.mark_active(&org.incus_project(), &a.name); - Ok(json!({ - "name": a.name, - "expires_at": expires_at, - "idle_timeout": idle, - "message": format!( - "{} now expires {} from now.", - a.name, - crate::sandbox_life::human(expires_at.unwrap_or(now).saturating_sub(now)) - ), - })) -} - const OUTPUT_CAP: usize = 256 * 1024; fn cap(b: &[u8]) -> (String, bool) { diff --git a/crates/isb-daemon/src/daemon/sandboxes.rs b/crates/isb-daemon/src/daemon/sandboxes.rs index 20dca346..21a2bfac 100644 --- a/crates/isb-daemon/src/daemon/sandboxes.rs +++ b/crates/isb-daemon/src/daemon/sandboxes.rs @@ -1,8 +1,9 @@ //! Sandboxes in the daemon (docs/concepts/sandboxes.md#sandboxes-are-short-lived): //! the activity isb sees on instances (exec, terminals, CPU), the org's -//! sandbox defaults and the `sandbox_settings` tool, and the reaper that -//! deletes sandboxes past their expiry or idle timeout, only those isb gave -//! deadlines, never a replica, and records each in the history. +//! sandbox defaults, the `sandbox_settings` and `sandbox_extend` tools, and +//! the reaper that deletes sandboxes past their expiry or idle timeout, in +//! orgs and outside them, only those isb gave deadlines, never a replica, +//! and records each in the history. use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -12,9 +13,9 @@ use std::time::Duration; use serde::Deserialize; use serde_json::{Value, json}; -use super::{Daemon, args, obj}; +use super::{Daemon, arg_org, args, obj, owner_label}; use crate::client::{Client, encode_segment}; -use crate::error::Result; +use crate::error::{Error, Result}; use crate::org::OrgId; use crate::sandbox::Sandbox; use crate::sandbox_life::{self as life, Settings}; @@ -171,11 +172,11 @@ impl Sandboxes { } } - fn record(&self, org: &OrgId, object: &str, msg: String, d: Value) { + fn record(&self, org: Option<&OrgId>, project: &str, object: &str, msg: String, d: Value) { self.recorder.record(crate::history::NewRecord { source: "controller".into(), - org: Some(org.as_str().to_string()), - project: Some(org.incus_project()), + org: org.map(|o| o.as_str().to_string()), + project: Some(project.to_string()), kind: "sandbox.reaped".into(), object_type: Some("instance".into()), object: Some(object.to_string()), @@ -242,10 +243,11 @@ impl Sandboxes { else { continue; }; - let Some(org) = OrgId::from_incus_project(&i.project) else { - continue; - }; - let oc = crate::org::client(&self.client, &org); + // A plain sandbox (`isb create`, `isb up` without an org) lives + // in a project outside every org, and is reaped the same way. + let org = OrgId::from_incus_project(&i.project); + let oc = self.client.clone().project(&i.project); + let place = org.as_ref().map_or(i.project.as_str(), |o| o.as_str()); // Read it again: the sample may be stale, and only an instance // that still has the deadline it was sampled with is taken. let Ok(sb) = Sandbox::get(&oc, &i.name) else { @@ -262,9 +264,10 @@ impl Sandboxes { } match Sandbox::remove(&oc, &i.name, true) { Ok(()) => { - eprintln!("isb serve: reaped sandbox {org}/{} ({reason})", i.name); + eprintln!("isb serve: reaped sandbox {place}/{} ({reason})", i.name); self.record( - &org, + org.as_ref(), + &i.project, &i.name, format!("sandbox {} deleted: {reason}", i.name), json!({ @@ -276,7 +279,7 @@ impl Sandboxes { ); } Err(e) if e.is_not_found() => {} - Err(e) => eprintln!("isb serve: could not reap {org}/{}: {e}", i.name), + Err(e) => eprintln!("isb serve: could not reap {place}/{}: {e}", i.name), } self.activity .lock() @@ -298,6 +301,109 @@ pub fn project_has_disk_limit(client: &Client, org: &OrgId) -> bool { .is_some_and(|p| p["config"]["limits.disk"].as_str().is_some()) } +/// `sandbox_extend`: push a sandbox's expiry out, or change its idle timeout. +pub(super) fn sandbox_extend(d: &Daemon, a: Value, c: &Caller) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct A { + name: String, + #[serde(default)] + by: Option, + #[serde(default)] + idle_timeout: Option, + #[serde(default)] + org: Option, + } + let org = arg_org(&a)?; + let a: A = args(a)?; + let oc = d.oc(&a.org)?; + let info = d.reach(c, &oc, &a.name)?; + let labels: BTreeMap = info + .config + .iter() + .filter_map(|(k, v)| k.strip_prefix("user.").map(|k| (k.to_string(), v.clone()))) + .collect(); + if crate::sandbox_life::kind_of(&labels) != "sandbox" { + return Err(Error::invalid(format!( + "{} is a {}, not a sandbox: it does not expire", + a.name, + crate::sandbox_life::kind_of(&labels) + ))); + } + // Its creator, or the org's admins. + let mine = labels + .get("isb.owner") + .is_some_and(|o| *o == owner_label(c)); + let admin = match c { + Caller::Local { .. } | Caller::Superadmin(_) => true, + Caller::User { principal } => { + principal.platform_admin + || principal + .role_in(&org) + .is_some_and(|r| r >= crate::auth::Role::Admin) + } + _ => false, + }; + if !mine && !admin { + return Err(Error::Forbidden(format!( + "{} was created by {}; its creator or the org's admins extend it", + a.name, + labels + .get("isb.owner") + .map(String::as_str) + .unwrap_or("someone else") + ))); + } + let now = now(); + let mut patch = serde_json::Map::new(); + let current = labels + .get("isb.expires_at") + .and_then(|v| v.parse::().ok()); + let by = match &a.by { + Some(b) => crate::flex::parse_duration(b).map_err(Error::invalid)?, + None if a.idle_timeout.is_some() => Duration::ZERO, + None => Duration::from_secs(86400), + }; + let mut expires_at = current; + if !by.is_zero() { + let e = crate::sandbox_life::extended(current, by, now)?; + patch.insert( + crate::sandbox_life::KEY_EXPIRES_AT.into(), + json!(e.to_string()), + ); + expires_at = Some(e); + } + let mut idle = labels + .get("isb.idle_timeout") + .and_then(|v| v.parse::().ok()) + .filter(|s| *s > 0); + if let Some(t) = &a.idle_timeout { + idle = crate::sandbox_life::idle(t)?.map(|d| d.as_secs()); + patch.insert( + crate::sandbox_life::KEY_IDLE_TIMEOUT.into(), + json!(idle.unwrap_or(0).to_string()), + ); + } + oc.mutate( + "PATCH", + &format!("/1.0/instances/{}", crate::client::encode_segment(&a.name)), + Some(&json!({"config": patch})), + &format!("extend sandbox {}", a.name), + oc.timeouts.other, + )?; + d.sandboxes.mark_active(&org.incus_project(), &a.name); + Ok(json!({ + "name": a.name, + "expires_at": expires_at, + "idle_timeout": idle, + "message": format!( + "{} now expires {} from now.", + a.name, + crate::sandbox_life::human(expires_at.unwrap_or(now).saturating_sub(now)) + ), + })) +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct SettingsArgs { diff --git a/crates/isb-daemon/src/daemon/tools.rs b/crates/isb-daemon/src/daemon/tools.rs index c6a4caa9..8a30441e 100644 --- a/crates/isb-daemon/src/daemon/tools.rs +++ b/crates/isb-daemon/src/daemon/tools.rs @@ -649,6 +649,8 @@ pub(super) fn sandbox_list_tool(r: &mut Registry, d: &Arc, ann: &Ann) -> "created_at": created, "age_secs": created.map(|c| now.saturating_sub(c as u64)), "expires_at": num(crate::sandbox_life::KEY_EXPIRES_AT), + // As stored: unix seconds, or `never`. + "expires": i.config.get(crate::sandbox_life::KEY_EXPIRES_AT), "idle_timeout": num(crate::sandbox_life::KEY_IDLE_TIMEOUT), "last_active": d.sandboxes.last_seen(&project, &i.name), "cpus": i.config.get("limits.cpu"), @@ -748,7 +750,7 @@ pub(super) fn sandbox_extend_tool(r: &mut Registry, d: &Arc, ann: &Ann) &["name"] ), ann.write, - sandbox_extend + super::sandboxes::sandbox_extend ); Ok(()) } diff --git a/crates/isb-daemon/src/sandbox_life.rs b/crates/isb-daemon/src/sandbox_life.rs index 46659a4a..549db0dd 100644 --- a/crates/isb-daemon/src/sandbox_life.rs +++ b/crates/isb-daemon/src/sandbox_life.rs @@ -17,7 +17,7 @@ use crate::error::{Error, Result}; use crate::org::OrgId; /// Instance config keys (`user.*`) isb keeps on sandboxes. -pub const KEY_EXPIRES_AT: &str = "user.isb.expires_at"; +pub use crate::sandbox::expiry::KEY_EXPIRES_AT; pub const KEY_IDLE_TIMEOUT: &str = "user.isb.idle_timeout"; /// The longest a sandbox may live from now, by default or extended. @@ -106,6 +106,11 @@ pub fn put_settings(state: &Path, org: &OrgId, s: &Settings) -> Result<()> { /// A sandbox lifetime: a duration up to [`MAX_SANDBOX_LIFETIME`]. pub fn lifetime(s: &str) -> Result { + if matches!(s.trim(), "never" | "none" | "off") { + return Err(Error::invalid(format!( + "expiry {s:?}: a sandbox made through isb serve lives at most 30 days; `never` is for the host's own (`isb create`, `isb up`)" + ))); + } let d = crate::flex::parse_duration(s.trim()) .map_err(|e| Error::invalid(format!("expiry {s:?}: {e}")))?; if d.is_zero() || d > MAX_SANDBOX_LIFETIME { @@ -163,26 +168,7 @@ pub fn extended(current: Option, by: Duration, now: u64) -> Result { Ok(want) } -/// `90061` -> `1d 1h`: the two largest units. -pub fn human(secs: u64) -> String { - let units = [(86400, "d"), (3600, "h"), (60, "m"), (1, "s")]; - let mut parts = Vec::new(); - let mut rest = secs; - for (n, u) in units { - if rest >= n { - parts.push(format!("{}{u}", rest / n)); - rest %= n; - } - if parts.len() == 2 { - break; - } - } - if parts.is_empty() { - "0s".into() - } else { - parts.join(" ") - } -} +pub use crate::sandbox::expiry::human; /// What kind of instance this is to isb, from its `user.*` config (keys /// without the `user.` prefix, as the metrics sample has them). @@ -296,6 +282,7 @@ mod tests { let (exp, idle) = sandbox_deadlines(&s, Some("1h"), Some("none"), 0).unwrap(); assert_eq!((exp, idle), (3600, None)); assert!(sandbox_deadlines(&s, Some("40d"), None, 0).is_err()); + assert!(sandbox_deadlines(&s, Some("never"), None, 0).is_err()); assert!(sandbox_deadlines(&s, None, Some("10s"), 0).is_err()); // From the later of now and the current expiry. assert_eq!( diff --git a/docs/concepts/sandboxes.md b/docs/concepts/sandboxes.md index 7c7ef80e..03ca3df3 100644 --- a/docs/concepts/sandboxes.md +++ b/docs/concepts/sandboxes.md @@ -180,9 +180,41 @@ load balancer and rolling updates, deploy the same file as a - **`isb prune --label KEY --missing-path`** deletes sandboxes whose `KEY` label names a host path that no longer exists (a deleted worktree). It is a dry run unless given `-y`. -- Sandboxes made by `isb create` and `isb up` on the host have no expiry. - Sandboxes made through the daemon do: see - [Sandboxes are short-lived](#sandboxes-are-short-lived). +- **Every sandbox expires** unless it says `never`, so one nobody comes back + to goes away on its own: see [Expiry](#expiry). + +## Expiry + +A sandbox's deadline is kept on the instance as `user.isb.expires_at`, and +`isb serve`'s reaper deletes the sandbox once it passes, wherever it lives: +in an org, or a plain sandbox in incus' `default` project. + +| Made by | Lives, unless told otherwise | Set it with | +|---|---|---| +| `isb create`, `isb up`, the Python and TypeScript SDKs, without an org | 7 days from its last `isb up` | `expires:` in the file, `--expires`; `never` to keep it | +| the same, with `--org` | the org's `sandbox_expiry` (24 h), from its last `isb up` | the same | +| `sandbox_create` (MCP, API, web UI) | the org's `sandbox_expiry` (24 h), at most 30 days, plus an idle timeout | the call's `expires` or the spec's; see [below](#sandboxes-are-short-lived) | + +- **A lease, not a timer.** Each `isb up` (or `isb create --ensure`) renews + the deadline to now plus the lifetime, so a sandbox someone keeps using + stays, and one left behind by a finished task or a deleted worktree is + gone a week later. An `up` never shortens a later deadline. +- **Seeing and changing it.** `isb ls` has an EXPIRES column (`in 6d 23h`, + `never`, or `-` for none). `isb expiry NAME --in 24h` sets it from now, + `--by 24h` pushes it out, `--never` clears it. Through the daemon, + `sandbox_list` and `sandbox_extend`. +- **What goes.** The instance and its root disk. Named volumes stay, as they + do after `isb rm` and `isb down`: they are shared by name and outlive their + sandboxes by design. `isb down --volumes` or `isb volume rm` removes them. +- **Sandboxes without a deadline keep none.** The default applies when isb + creates a sandbox. One made before isb had expiries, or by hand, has no + `user.isb.expires_at`, is never reaped, and an `isb up` does not give it + one until its file says `expires:`. +- Stack replicas and builds never expire: their controller owns them. + Neither do the sandboxes `isbx` keeps, nor ones made through the Rust API + without an `expires` ([isb from code](../guides/sdk.md)). +- Nothing is reaped while `isb serve` is not running; the reaper takes what + has expired when it starts. ## Sandboxes are short-lived @@ -190,7 +222,7 @@ Every sandbox made through `isb serve` (`sandbox_create`) gets deadlines, kept on the instance as `user.isb.expires_at` and `user.isb.idle_timeout`: - **an expiry**: the org's `sandbox_expiry` (default 24 h; at most 30 days), - or the call's `expires`; + or the call's `expires`, or the spec's (`never` is refused); - **an idle timeout**: the org's `sandbox_idle` (default 2 h), or the call's `idle_timeout`, or `none`. Idle means no exec or terminal through isb and under 2 % of a core of CPU since the daemon last saw it used (a daemon that @@ -206,9 +238,10 @@ the caller would get) or the org's admins may. `sandbox_list` `build`), creator, age, expiry, idle timeout, last activity, limits and use, and whether the caller made it (`mine`). -**The reaper** in the daemon checks every minute and deletes a sandbox past -either deadline, recording `sandbox.reaped` in the history with the reason. -It only takes instances that carry isb's deadlines and are neither a stack -replica nor a build, re-reads each before deleting it, and skips one with a -terminal open. Sandboxes made by `isb create` and `isb up` on the host have -no deadlines and are never reaped. +**The reaper** in the daemon checks every minute, in every incus project, +and deletes a sandbox past either deadline, recording `sandbox.reaped` in +the history with the reason. It only takes instances that carry isb's +deadlines and are neither a stack replica nor a build, re-reads each before +deleting it, and skips one with a terminal open. Idle timeouts are only set +through the daemon, which sees the exec and terminals that count as use; +`isb create` and `isb up` set an expiry alone. diff --git a/docs/guides/sdk.md b/docs/guides/sdk.md index 8e9427a7..6d9e1653 100644 --- a/docs/guides/sdk.md +++ b/docs/guides/sdk.md @@ -155,6 +155,10 @@ fn main() -> isb::Result<()> { API docs are on [docs.rs/isb](https://docs.rs/isb). +The Rust API gives a sandbox no expiry unless asked: set the spec's +`expires`, or pass `EnsureOptions { default_expiry: Some(...), .. }` to the +`_with` calls to get the CLI's default. + ## What every SDK shares - **Converge, don't recreate.** `connect_or_create` / `connectOrCreate` @@ -170,6 +174,9 @@ API docs are on [docs.rs/isb](https://docs.rs/isb). terminal resizes. - **A service's `command`** is for the foreground CLI `isb up`; `Project.up` behaves like `isb up -d` and does not run it. +- **Sandboxes expire.** Python and TypeScript go through `isb rpc`, so a + sandbox they create gets the CLI's [expiry](../concepts/sandboxes.md#expiry) + (7 days, renewed by each `up`), unless the spec's `expires` says otherwise. - **Errors carry a stable code** (`not_found`, `already_exists`, `not_ready`, `connect`, ...) from [the rpc protocol](../reference/rpc.md#errors). diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f258b35a..8e73fc61 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -84,12 +84,13 @@ isb create NAME -i IMAGE [--vm] [--cpus N | --cpuset-cpus SET] [-m MEM] [-s POOL [-l KEY=VALUE]... [-e KEY=VALUE]... [-v SRC:GUEST[:OPTS]]... [-p PORT]... [--ready CHECK]... [--ready-timeout D] [-c KEY=VALUE]... [--profile P]... [--egress HOST[:PORT]|none]... [--secret NAME[=SECRET]@HOSTS]... - [--ensure] [--no-ready] + [--expires DURATION|never] [--ensure] [--no-ready] isb start NAME... start, and wait until running isb stop NAME... [-f] [-t 30s] clean shutdown (or kill with -f) within -t isb restart NAME... isb rm NAME... [-f] delete (-f stops a running one first); aliases remove, delete -isb ls [-l KEY[=VALUE]]... [--json] list, filtered by labels; alias list +isb ls [-l KEY[=VALUE]]... [--json] list, filtered by labels, with each one's expiry; alias list +isb expiry NAME... [--in D | --by D | --never] show or change when sandboxes are deleted isb inspect NAME [--json] one sandbox, by service or instance name isb exec TARGET [-u USER] [-w DIR] [-e K=V|K]... [-l] [-t|-T] [-i|-n] [--timeout D] -- ARGV... isb prune --label KEY --missing-path [-y] [--json] @@ -105,6 +106,15 @@ isb prune --label KEY --missing-path [-y] [--json] secret as a placeholder in `$NAME`, swapped for the real value on the wire to those hosts only. Both are [`egress`](compose.md#egress) in a compose file; [Sandbox egress and secrets](../guides/egress.md) has the guarantees. +- `--expires` is the sandbox's lifetime: 7 days by default, the org's + `sandbox_expiry` with `--org`, or `never` ([`expires`](compose.md#expires)). + `isb serve` deletes it once that passes. `create --ensure` renews it, as + `up` does. +- `expiry` prints when each sandbox expires; `--in 24h` sets the deadline + to that long from now, `--by 24h` pushes it out from the later of now and + the current one, `--never` clears it. Not for a stack replica or a build. + `ls`'s EXPIRES column shows `-` for a sandbox without a deadline (made + before isb had expiries, or by hand). - `start` and `restart` wait for `running` only, not a file's `ready` checks; a sandbox with an egress list also waits until its guest resolves names. @@ -316,8 +326,8 @@ sandbox's creator or the org's admins may. `settings` prints the org's defaults for new sandboxes and, with `--expiry` (at most 30d) or `--idle` (`none` turns idle reaping off), changes them (org admins and up). Over the API they are `sandbox_list`, `sandbox_extend` and `sandbox_settings`. The -top-level `isb create`, `ls`, `rm` work on this host's incus directly -instead. +top-level `isb create`, `ls`, `rm` and `expiry` work on this host's incus +directly instead. ## Secrets diff --git a/docs/reference/compose.md b/docs/reference/compose.md index 00a8db74..c2fff5af 100644 --- a/docs/reference/compose.md +++ b/docs/reference/compose.md @@ -53,10 +53,10 @@ Interpolation produces strings, so typed fields also accept strings: - Booleans (`privileged`, `read_only`, `external`, `login`) accept `true`/`false` or a string: `true`, `yes`, `on`, `1`, `false`, `no`, `off`, `0` or empty, case-insensitive. -- `cpus`, `cpuset`, `mem_limit`, `ready_timeout`, `user`, a mount's `owner` and +- `cpus`, `cpuset`, `mem_limit`, `ready_timeout`, `expires`, `user`, a mount's `owner` and a port's `target` and `published` accept a non-negative integer or a string. Floats are rejected. -- Durations (`ready_timeout`): a number with an optional unit `ms`, `s`/`sec`/ +- Durations (`ready_timeout`, `expires`): a number with an optional unit `ms`, `s`/`sec`/ `secs`, `m`/`min`/`mins`, `h`, `d`. No unit means seconds. Decimals work (`1.5m`). - The values of free-form string maps (`labels`, `environment`, `raw_config`, the properties in `raw_devices`, mount and port `options`, top-level volume @@ -1059,6 +1059,32 @@ egress: remove `egress`. Editing the list or the secrets later is live. Needs `isb serve` for its proxy. +### `expires` + +How long the sandbox lives: a duration (`24h`, `7d`), or `never`. isb keeps +the deadline on the instance as `user.isb.expires_at` (unix seconds, or +`never`), and `isb serve` deletes the sandbox once it passes +([Expiry](../concepts/sandboxes.md#expiry)). + +```yaml +expires: 3d # deleted 3 days after the last `isb up` +expires: never # a tool container that should stay +``` + +- **Omitted:** 7 days for a plain sandbox (incus' `default` project, or any + project outside an org); in an org (`--org`), the org's `sandbox_expiry` + (24 h unless its admins changed it). +- **A lease.** Each `isb up` (and `isb create --ensure`) renews the deadline + to now plus the lifetime, so a sandbox someone keeps bringing up stays. It + never shortens a later deadline (one set with `isb expiry --in`), and the + default never adds one to an instance that has none: a sandbox made before + isb had expiries, or set to `never`, keeps no deadline until the file says + `expires:`. `expires: never` clears it. +- Not part of the plan: `isb plan` does not report a renewal as a change. +- Named volumes outlive the sandbox, as with `isb rm`; only the instance and + its root disk go. +- Stacks ignore it: a replica is its stack's, and never expires. + ### `raw_config` Map of string to string, set verbatim as instance config. Applied after every @@ -1141,7 +1167,7 @@ written, so it boots with the right devices. **Summary of `isb up` on an existing instance.** It will: create missing named volumes, set differing config keys, replace wrong devices, add missing ones, start a stopped instance, add searched ports, chown newly attached owned mounts, -and run readiness checks (skip them with `--no-ready`). It will not: recreate +renew the expiry ([`expires`](#expires)), and run readiness checks (skip them with `--no-ready`). It will not: recreate the instance, change its type, root pool, incus profiles or image, unset config keys, remove `raw.idmap`, restart the instance, touch a correct device, or remove any device without `--prune-devices`. @@ -1226,6 +1252,8 @@ The full form. `OPT` is one of the proxy options `nat`, `proxy_protocol`, `uid`, `gid`, `mode`, `security.uid`, `security.gid`; any other key is an error. Values cannot contain commas. +**`--expires DURATION|never`** The sandbox's [`expires`](#expires). + **`--ready CHECK`** (repeatable) `running`, `agent`, `default_route`, `user_exists=USER`, `path_writable=PATH`, `command=ARG[,ARG...]`. diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index 82633c12..1812a7f0 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -145,7 +145,7 @@ There is no `port-forward` tool: run `curl` with `app_exec`, or reach an instanc | Tool | Who | Does | |---|---|---| -| `sandbox_create` | member | Create or reconcile one sandbox from a service spec (an object or YAML, `container_name` set), with `expires` and `idle_timeout` (the org's defaults otherwise: 24h and 2h) and `wait_ready`. Remote callers' specs are held to the [remote-spec policy](../concepts/security.md#the-remote-spec-policy), and their sandboxes labelled `isb.owner=`. `spec.egress` confines the sandbox's network to a list of hosts (or none) and gives it [secrets that never enter the guest](../guides/egress.md); a secret the org does not hold is refused. | +| `sandbox_create` | member | Create or reconcile one sandbox from a service spec (an object or YAML, `container_name` set), with `expires` (else the spec's `expires`; at most 30 days, never `never`) and `idle_timeout` (the org's defaults otherwise: 24h and 2h) and `wait_ready`. Remote callers' specs are held to the [remote-spec policy](../concepts/security.md#the-remote-spec-policy), and their sandboxes labelled `isb.owner=`. `spec.egress` confines the sandbox's network to a list of hosts (or none) and gives it [secrets that never enter the guest](../guides/egress.md); a secret the org does not hold is refused. | | `sandbox_list` | viewer | Instances, filtered by labels and `kind` (`sandbox`, `replica`, `build`): creator, age, expiry, idle timeout, last activity, limits and use, and `mine`. Remote callers see only instances isb manages. | | `sandbox_exec` | member | Run argv in a sandbox: exit code, stdout, stderr (each capped at 256 KiB, keeping the end), optional `stdin` text, `user`, `cwd`, `env`, `timeout` (default 10m). | | `sandbox_extend` | the sandbox's creator, or admin | Push the expiry out (`by`, default 24h, at most 30 days from now) or change `idle_timeout`. | diff --git a/scripts/ratchet.txt b/scripts/ratchet.txt index a38bc8ed..e32a4c26 100644 --- a/scripts/ratchet.txt +++ b/scripts/ratchet.txt @@ -9,12 +9,11 @@ crates/isb-apps/src/notify/mod.rs 1263 crates/isb-apps/src/s3.rs 1263 crates/isb-core/src/ingress/caddy.rs 1130 crates/isb-core/src/machine.rs 1097 -crates/isb-core/src/plan.rs 1517 +crates/isb-core/src/plan.rs 1476 crates/isb-core/src/registry/oci.rs 1081 -crates/isb-core/src/sandbox.rs 1313 -crates/isb-core/src/spec.rs 1518 +crates/isb-core/src/sandbox.rs 1280 +crates/isb-core/src/spec.rs 1442 crates/isb-core/src/stack/controller.rs 2325 -crates/isb-daemon/src/daemon/mod.rs 1089 crates/isb-server/src/audit.rs 1153 crates/isb-server/src/auth/http.rs 1040 crates/isb-server/src/auth/mod.rs 1658 diff --git a/sdk/python/src/isb/_spec.py b/sdk/python/src/isb/_spec.py index 8d6b90b0..45951222 100644 --- a/sdk/python/src/isb/_spec.py +++ b/sdk/python/src/isb/_spec.py @@ -309,6 +309,12 @@ class SandboxSpec(TypedDict, total=False): environment: EnvMapOrList #: More exec defaults: an exec-only environment and the login shell. exec: ExecSpec + #: How long the sandbox lives (`24h`, `7d`), or `never`. Kept on the + #: instance as `user.isb.expires_at`; `isb serve` deletes it once that + #: passes. Each `isb up` renews it from now, and never shortens it. + #: Omitted: 7 days for a plain sandbox, the org's `sandbox_expiry` in an + #: org (docs/concepts/sandboxes.md#expiry). Stacks ignore it. + expires: Optional[IntOrString] #: A recurring health test, as in docker compose. `isb stack deploy` #: routes traffic only to healthy replicas and replaces unhealthy ones; #: `depends_on` can wait for it. @@ -543,6 +549,12 @@ class SandboxSpecFields(TypedDict, total=False): environment: EnvMapOrList #: More exec defaults: an exec-only environment and the login shell. exec: ExecSpec + #: How long the sandbox lives (`24h`, `7d`), or `never`. Kept on the + #: instance as `user.isb.expires_at`; `isb serve` deletes it once that + #: passes. Each `isb up` renews it from now, and never shortens it. + #: Omitted: 7 days for a plain sandbox, the org's `sandbox_expiry` in an + #: org (docs/concepts/sandboxes.md#expiry). Stacks ignore it. + expires: Optional[IntOrString] #: A recurring health test, as in docker compose. `isb stack deploy` #: routes traffic only to healthy replicas and replaces unhealthy ones; #: `depends_on` can wait for it. diff --git a/sdk/typescript/src/spec.ts b/sdk/typescript/src/spec.ts index 5e08c7d6..277729d5 100644 --- a/sdk/typescript/src/spec.ts +++ b/sdk/typescript/src/spec.ts @@ -402,6 +402,14 @@ export interface SandboxSpec { entrypoint?: Command | null; environment?: EnvMapOrList; exec?: ExecSpec; + /** + * How long the sandbox lives (`24h`, `7d`), or `never`. Kept on the + * instance as `user.isb.expires_at`; `isb serve` deletes it once that + * passes. Each `isb up` renews it from now, and never shortens it. + * Omitted: 7 days for a plain sandbox, the org's `sandbox_expiry` in an + * org (docs/concepts/sandboxes.md#expiry). Stacks ignore it. + */ + expires?: IntOrString | null; /** * A recurring health test, as in docker compose. `isb stack deploy` * routes traffic only to healthy replicas and replaces unhealthy ones; diff --git a/src/bin/isb.rs b/src/bin/isb.rs index d8f19c0c..9ed81a71 100644 --- a/src/bin/isb.rs +++ b/src/bin/isb.rs @@ -23,6 +23,8 @@ mod audit; mod auth; #[path = "isb/data.rs"] mod data; +#[path = "isb/expiry.rs"] +mod expiry; #[path = "isb/host.rs"] mod host; #[path = "isb/instances.rs"] @@ -171,6 +173,9 @@ enum Cmd { #[arg(short, long)] force: bool, }, + /// Show sandboxes' expiry, or change it: `--in 24h` from now, `--by 24h` + /// later, `--never` (docs/concepts/sandboxes.md#expiry). + Expiry(expiry::ExpiryArgs), /// List sandboxes, optionally filtered by label (`key` or `key=value`). #[command(alias = "list")] Ls { @@ -595,6 +600,7 @@ fn run(ctx: &Ctx, cmd: Cmd) -> Result { Cmd::Audit(c) => audit_cmd(c), Cmd::History(a) => history_cmd(a), Cmd::Create(a) => create(ctx, a), + Cmd::Expiry(a) => expiry::expiry(ctx, a), Cmd::Start { names } => each(ctx, names, |c, n| Sandbox::get(c, n)?.start()), Cmd::Stop { names, @@ -603,30 +609,7 @@ fn run(ctx: &Ctx, cmd: Cmd) -> Result { } => each(ctx, names, |c, n| Sandbox::get(c, n)?.stop(force, timeout)), Cmd::Restart { names } => each(ctx, names, |c, n| Sandbox::get(c, n)?.restart()), Cmd::Rm { names, force } => each(ctx, names, |c, n| Sandbox::remove(c, n, force)), - Cmd::Ls { labels, json } => { - let filters: Vec = labels.iter().map(|l| LabelFilter::parse(l)).collect(); - let list = Sandbox::list_with(&ctx.client(None), &filters)?; - if json { - print_json(&list); - } else { - let mut rows = vec![vec![ - "NAME".into(), - "STATUS".into(), - "TYPE".into(), - "LABELS".into(), - ]]; - for i in &list { - rows.push(vec![ - i.name.clone(), - i.status.to_uppercase(), - i.instance_type.clone(), - labels_brief(i), - ]); - } - table(rows); - } - Ok(0) - } + Cmd::Ls { labels, json } => ls(ctx, labels, json), Cmd::Ps { services, json, .. } => ps(ctx, services, json), Cmd::Inspect { name, json, .. } => { let c = ctx.client(None); @@ -656,32 +639,10 @@ fn run(ctx: &Ctx, cmd: Cmd) -> Result { missing_path, yes, json, - } => { - if !missing_path { - return Err(Error::Invalid( - "prune needs a criterion: --missing-path".into(), - )); - } - let c = ctx.client(None); - let mut rep = ctx.report(); - let mut say = |l: &str| { - if json { rep(l) } else { println!("{l}") } - }; - let items = sandbox::prune_missing_path(&c, &label, !yes, &mut say)?; - if json { - print_json(&items); - } else if items.is_empty() { - println!("nothing to prune"); - } else if !yes { - println!( - "dry run; re-run with -y to delete ({} sandbox(es))", - items.len() - ); - } - Ok(0) - } + } => prune(ctx, &label, missing_path, yes, json), Cmd::Rpc => { - isb::rpc::serve(std::io::stdin().lock(), std::io::stdout(), ctx.client(None))?; + let (input, output) = (std::io::stdin().lock(), std::io::stdout()); + isb::rpc::serve_with(input, output, ctx.client(None), expiry::org_expiry)?; Ok(0) } Cmd::Schema => { diff --git a/src/bin/isb/expiry.rs b/src/bin/isb/expiry.rs new file mode 100644 index 00000000..2ee2b257 --- /dev/null +++ b/src/bin/isb/expiry.rs @@ -0,0 +1,135 @@ +//! `isb expiry`, and the expiry a sandbox the CLI makes gets by default +//! (docs/concepts/sandboxes.md#expiry). + +use isb::sandbox::expiry::{self as exp, KEY_EXPIRES_AT}; + +use super::*; + +#[derive(Args)] +pub(crate) struct ExpiryArgs { + /// Sandboxes (instance names). + #[arg(required = true)] + names: Vec, + /// Expire this long from now, e.g. 24h. + #[arg(long = "in", value_name = "DURATION", conflicts_with_all = ["by", "never"])] + in_: Option, + /// Push the expiry out by this long, from the later of now and the + /// current expiry, e.g. 24h. + #[arg(long, value_name = "DURATION", conflicts_with = "never")] + by: Option, + /// Never expire. + #[arg(long)] + never: bool, +} + +/// `isb serve`'s state directory on this host, as it picks it. +pub(crate) fn serve_state_dir() -> PathBuf { + std::env::var_os("ISB_SERVE_STATE_DIR") + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(isb::daemon::default_state_dir) +} + +/// An org's `sandbox_expiry`, read from `isb serve`'s state on this host. +pub(crate) fn org_expiry(org: &isb::org::OrgId) -> Option { + let s = isb::sandbox_life::settings(&serve_state_dir(), org).ok()?; + isb::sandbox_life::lifetime(&s.sandbox_expiry).ok() +} + +/// The lifetime of a new sandbox in `c`'s project whose spec gives none. +pub(crate) fn default_expiry(c: &Client) -> Option { + Some(exp::default_for(c.project_name(), org_expiry)) +} + +fn now() -> u64 { + isb::stack::now_secs() +} + +/// The stored deadline after this command, or `None` to only show it. +fn wanted(a: &ExpiryArgs, current: Option<&str>) -> Result> { + let life = |s: &str| match exp::Expires::parse(s)? { + exp::Expires::After(d) => Ok(d.as_secs()), + exp::Expires::Never => Err(Error::Invalid(format!("{s:?}: a duration; --never clears"))), + }; + if a.never { + return Ok(Some("never".into())); + } + if let Some(s) = &a.in_ { + return Ok(Some((now() + life(s)?).to_string())); + } + if let Some(s) = &a.by { + let at = current.and_then(|c| c.trim().parse::().ok()); + let from = at.unwrap_or(0).max(now()); + return Ok(Some((from + life(s)?).to_string())); + } + Ok(None) +} + +pub(crate) fn expiry(ctx: &Ctx, a: ExpiryArgs) -> Result { + let c = ctx.client(None); + for name in &a.names { + let info = Sandbox::get(&c, name)?.info()?; + if ["isb.stack", "isb.build"] + .iter() + .any(|k| info.labels.contains_key(*k)) + { + return Err(Error::Invalid(format!( + "{name} is a stack replica or a build: its controller removes it, it does not expire" + ))); + } + let current = info.config.get(KEY_EXPIRES_AT).map(String::as_str); + let shown = match wanted(&a, current)? { + Some(v) => { + sandbox::update_instance( + &c, + name, + &format!("set expiry of {name}"), + &mut |cfg, _| { + cfg.insert(KEY_EXPIRES_AT.into(), serde_json::json!(v)); + Ok(()) + }, + )?; + v + } + None => current.unwrap_or("-").to_string(), + }; + println!("{name}: expires {}", exp::describe(&shown, now())); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(in_: Option<&str>, by: Option<&str>, never: bool) -> ExpiryArgs { + ExpiryArgs { + names: vec!["x".into()], + in_: in_.map(String::from), + by: by.map(String::from), + never, + } + } + + #[test] + fn wanted_deadlines() { + let t = now(); + assert_eq!(wanted(&args(None, None, false), Some("5")).unwrap(), None); + assert_eq!( + wanted(&args(None, None, true), Some("5")) + .unwrap() + .as_deref(), + Some("never") + ); + let at = |v: Option| v.unwrap().parse::().unwrap(); + let in_day = at(wanted(&args(Some("1d"), None, false), None).unwrap()); + assert!((t + 86400..t + 86410).contains(&in_day)); + // --by counts from the later of now and the current deadline. + let later = (t + 10 * 86400).to_string(); + let by = at(wanted(&args(None, Some("1d"), false), Some(&later)).unwrap()); + assert_eq!(by, t + 11 * 86400); + let by = at(wanted(&args(None, Some("1h"), false), Some("never")).unwrap()); + assert!((t + 3600..t + 3610).contains(&by)); + assert!(wanted(&args(Some("never"), None, false), None).is_err()); + } +} diff --git a/src/bin/isb/instances.rs b/src/bin/isb/instances.rs index 617145b9..9e9bc6af 100644 --- a/src/bin/isb/instances.rs +++ b/src/bin/isb/instances.rs @@ -1,4 +1,4 @@ -//! `isb create`, `ps`, `exec` and the `volume`, `port` and `device` commands: sandboxes one at a time. +//! `isb create`, `ls`, `ps`, `prune`, `exec` and the `volume`, `port` and `device` commands: sandboxes one at a time. use super::*; @@ -71,6 +71,10 @@ pub(crate) struct CreateArgs { /// these hosts and nowhere else: `NAME[=SECRET]@host1,host2`. #[arg(long = "secret", value_name = "NAME@HOSTS")] pub(crate) secrets: Vec, + /// Delete the sandbox this long from now (`24h`, `7d`), or `never`. + /// Default: 7d, or the org's `sandbox_expiry` with --org. + #[arg(long, value_name = "DURATION|never")] + pub(crate) expires: Option, } #[derive(Args)] @@ -267,10 +271,12 @@ pub(crate) fn create(ctx: &Ctx, a: CreateArgs) -> Result { spec.profiles = Some(a.profiles); } spec.egress = egress_spec(&a.egress, &a.secrets)?; + spec.expires = a.expires; let c = ctx.client(None); let mut rep = ctx.report(); let opts = EnsureOptions { wait_ready: !a.no_ready, + default_expiry: super::expiry::default_expiry(&c), ..Default::default() }; if a.ensure { @@ -281,6 +287,66 @@ pub(crate) fn create(ctx: &Ctx, a: CreateArgs) -> Result { Ok(0) } +pub(crate) fn ls(ctx: &Ctx, labels: Vec, json: bool) -> Result { + let filters: Vec = labels.iter().map(|l| LabelFilter::parse(l)).collect(); + let list = Sandbox::list_with(&ctx.client(None), &filters)?; + if json { + print_json(&list); + return Ok(0); + } + let now = isb::stack::now_secs(); + let mut rows = vec![vec![ + "NAME".into(), + "STATUS".into(), + "TYPE".into(), + "EXPIRES".into(), + "LABELS".into(), + ]]; + for i in &list { + let expires = i.config.get(isb::sandbox::expiry::KEY_EXPIRES_AT); + rows.push(vec![ + i.name.clone(), + i.status.to_uppercase(), + i.instance_type.clone(), + expires.map_or_else(|| "-".into(), |v| isb::sandbox::expiry::describe(v, now)), + labels_brief(i), + ]); + } + table(rows); + Ok(0) +} + +pub(crate) fn prune( + ctx: &Ctx, + label: &str, + missing_path: bool, + yes: bool, + json: bool, +) -> Result { + if !missing_path { + return Err(Error::Invalid( + "prune needs a criterion: --missing-path".into(), + )); + } + let c = ctx.client(None); + let mut rep = ctx.report(); + let mut say = |l: &str| { + if json { rep(l) } else { println!("{l}") } + }; + let items = sandbox::prune_missing_path(&c, label, !yes, &mut say)?; + if json { + print_json(&items); + } else if items.is_empty() { + println!("nothing to prune"); + } else if !yes { + println!( + "dry run; re-run with -y to delete ({} sandbox(es))", + items.len() + ); + } + Ok(0) +} + /// `--egress` and `--secret` as the spec's `egress:` field. fn egress_spec(allow: &[String], secrets: &[String]) -> Result> { use isb::egress::{EgressSecretSpec, EgressSpec}; diff --git a/src/bin/isb/project.rs b/src/bin/isb/project.rs index d86525bd..9b265f30 100644 --- a/src/bin/isb/project.rs +++ b/src/bin/isb/project.rs @@ -50,11 +50,7 @@ pub(crate) fn read_store_secrets(ctx: &Ctx, p: &mut Project) -> Result<()> { } else { let config = isb::secrets::SecretsConfig::load(&isb::secrets::SecretsConfig::default_path())?; - // The daemon's state dir, as `isb serve` picks it. - let state = std::env::var_os("ISB_SERVE_STATE_DIR") - .filter(|s| !s.is_empty()) - .map(PathBuf::from) - .unwrap_or_else(isb::daemon::default_state_dir); + let state = super::expiry::serve_state_dir(); let secrets = isb::secrets::Secrets::open_existing( &state, &isb::secrets::KeySources::from_env(), @@ -76,16 +72,18 @@ pub(crate) fn read_store_secrets(ctx: &Ctx, p: &mut Project) -> Result<()> { pub(crate) fn up(ctx: &Ctx, services: Vec, flags: UpFlags) -> Result { let mut p = ctx.load()?; read_store_secrets(ctx, &mut p)?; + let c = ctx.client(None); let opts = EnsureOptions { diff: DiffOptions { prune_devices: flags.prune_devices, }, wait_ready: !flags.no_ready, + default_expiry: super::expiry::default_expiry(&compose::client_for(&c, &p)), ..Default::default() }; let mut rep = ctx.report(); rep(&format!("using {}", p.files_display())); - let ups = compose::up_handles(&ctx.client(None), &p, &services, opts, &mut rep)?; + let ups = compose::up_handles(&c, &p, &services, opts, &mut rep)?; if flags.json { let r: Vec<_> = ups.iter().map(|(_, r, _)| r).collect(); print_json(&r); From f6d010c8aacb974569bf9123f01a1d1c70662a21 Mon Sep 17 00:00:00 2001 From: Stephan Fitzpatrick Date: Mon, 26 Oct 2026 01:02:06 +0000 Subject: [PATCH 2/2] isb 3.2.0 --- Cargo.lock | 14 +++++++------- Cargo.toml | 14 +++++++------- docs/reference/rpc.md | 4 ++-- sdk/python/pyproject.toml | 2 +- sdk/python/src/isb/__init__.py | 2 +- sdk/python/tests/test_unit.py | 6 +++--- sdk/python/uv.lock | 2 +- sdk/typescript/bun.lock | 4 ++-- sdk/typescript/npm/linux-arm64/package.json | 2 +- sdk/typescript/npm/linux-x64/package.json | 2 +- sdk/typescript/package.json | 6 +++--- 11 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa4fd427..023933f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1329,7 +1329,7 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "isb" -version = "3.1.2" +version = "3.2.0" dependencies = [ "age", "clap", @@ -1351,7 +1351,7 @@ dependencies = [ [[package]] name = "isb-apps" -version = "3.1.2" +version = "3.2.0" dependencies = [ "age", "base64 0.23.1", @@ -1375,7 +1375,7 @@ dependencies = [ [[package]] name = "isb-core" -version = "3.1.2" +version = "3.2.0" dependencies = [ "age", "base64 0.23.1", @@ -1402,7 +1402,7 @@ dependencies = [ [[package]] name = "isb-daemon" -version = "3.1.2" +version = "3.2.0" dependencies = [ "age", "httparse", @@ -1422,7 +1422,7 @@ dependencies = [ [[package]] name = "isb-egress" -version = "3.1.2" +version = "3.2.0" dependencies = [ "base64 0.23.1", "httparse", @@ -1435,7 +1435,7 @@ dependencies = [ [[package]] name = "isb-server" -version = "3.1.2" +version = "3.2.0" dependencies = [ "argon2", "base64 0.23.1", @@ -1458,7 +1458,7 @@ dependencies = [ [[package]] name = "isb-tui" -version = "3.1.2" +version = "3.2.0" dependencies = [ "isb-core", "libc", diff --git a/Cargo.toml b/Cargo.toml index 581f86a7..c8a83e9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ members = ["crates/*"] default-members = [".", "crates/*"] [workspace.package] -version = "3.1.2" +version = "3.2.0" edition = "2024" rust-version = "1.87" license = "MIT" @@ -62,12 +62,12 @@ repository = "https://github.com/execution-associates/isb" homepage = "https://github.com/execution-associates/isb" [workspace.dependencies] -isb-core = { path = "crates/isb-core", version = "=3.1.2" } -isb-server = { path = "crates/isb-server", version = "=3.1.2" } -isb-apps = { path = "crates/isb-apps", version = "=3.1.2" } -isb-daemon = { path = "crates/isb-daemon", version = "=3.1.2" } -isb-tui = { path = "crates/isb-tui", version = "=3.1.2" } -isb-egress = { path = "crates/isb-egress", version = "=3.1.2" } +isb-core = { path = "crates/isb-core", version = "=3.2.0" } +isb-server = { path = "crates/isb-server", version = "=3.2.0" } +isb-apps = { path = "crates/isb-apps", version = "=3.2.0" } +isb-daemon = { path = "crates/isb-daemon", version = "=3.2.0" } +isb-tui = { path = "crates/isb-tui", version = "=3.2.0" } +isb-egress = { path = "crates/isb-egress", version = "=3.2.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/docs/reference/rpc.md b/docs/reference/rpc.md index 68b1de9b..42150752 100644 --- a/docs/reference/rpc.md +++ b/docs/reference/rpc.md @@ -19,7 +19,7 @@ it; relative paths resolve against that directory. When it starts, the server writes one hello line: ```json -{"isb": "3.1.2", "protocol": 1} +{"isb": "3.2.0", "protocol": 1} ``` A client checks `protocol` and refuses versions it does not know. @@ -205,7 +205,7 @@ All take `files` (default `./isb.yaml` plus `./isb.override.yaml` if present), ## Example session ```text -<- {"isb":"3.1.2","protocol":1} +<- {"isb":"3.2.0","protocol":1} -> {"id":1,"method":"sandbox.ensure","params":{"spec":{"name":"web","image":"dev-base","cpus":2}}} <- {"id":1,"event":"progress","data":"web: creating from dev-base"} <- {"id":1,"event":"progress","data":"web: starting"} diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 52da5aca..51c61865 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "isb-sdk" -version = "3.1.2" +version = "3.2.0" description = "Python SDK for isb: declarative incus sandboxes (containers and VMs)" readme = "README.md" license = "MIT" diff --git a/sdk/python/src/isb/__init__.py b/sdk/python/src/isb/__init__.py index 6d57423f..fdac2f8b 100644 --- a/sdk/python/src/isb/__init__.py +++ b/sdk/python/src/isb/__init__.py @@ -62,7 +62,7 @@ async def main(): from ._util import Duration from .volumes import Volumes -__version__ = "3.1.2" +__version__ = "3.2.0" __all__ = [ "PROTOCOL", diff --git a/sdk/python/tests/test_unit.py b/sdk/python/tests/test_unit.py index 243e4574..4ffd8c70 100644 --- a/sdk/python/tests/test_unit.py +++ b/sdk/python/tests/test_unit.py @@ -222,7 +222,7 @@ async def test_death_fails_pending_requests(self) -> None: path = fake_server( self.tmp.name, """\ - echo '{"isb":"3.1.2","protocol":1}' + echo '{"isb":"3.2.0","protocol":1}' read line echo "boom" >&2 exit 3 @@ -243,7 +243,7 @@ async def test_close_fails_pending(self) -> None: path = fake_server( self.tmp.name, """\ - echo '{"isb":"3.1.2","protocol":1}' + echo '{"isb":"3.2.0","protocol":1}' cat >/dev/null """, ) @@ -261,7 +261,7 @@ async def test_events_and_out_of_order_replies(self) -> None: path = fake_server( self.tmp.name, """\ - echo '{"isb":"3.1.2","protocol":1}' + echo '{"isb":"3.2.0","protocol":1}' read a read b echo 'not json' diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 171200de..2a153183 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -72,7 +72,7 @@ wheels = [ [[package]] name = "isb-sdk" -version = "3.1.2" +version = "3.2.0" source = { editable = "." } [package.dev-dependencies] diff --git a/sdk/typescript/bun.lock b/sdk/typescript/bun.lock index 3692b65a..18eab9a2 100644 --- a/sdk/typescript/bun.lock +++ b/sdk/typescript/bun.lock @@ -12,8 +12,8 @@ "typescript": "^5.9.0", }, "optionalDependencies": { - "@execution-associates/isb-linux-arm64": "3.1.2", - "@execution-associates/isb-linux-x64": "3.1.2", + "@execution-associates/isb-linux-arm64": "3.2.0", + "@execution-associates/isb-linux-x64": "3.2.0", }, }, }, diff --git a/sdk/typescript/npm/linux-arm64/package.json b/sdk/typescript/npm/linux-arm64/package.json index b1e0a692..0cc2864d 100644 --- a/sdk/typescript/npm/linux-arm64/package.json +++ b/sdk/typescript/npm/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@execution-associates/isb-linux-arm64", - "version": "3.1.2", + "version": "3.2.0", "description": "The static isb binary for Linux arm64 (aarch64-unknown-linux-musl), used by @execution-associates/isb", "license": "MIT", "repository": { diff --git a/sdk/typescript/npm/linux-x64/package.json b/sdk/typescript/npm/linux-x64/package.json index 56394cb3..17004ce4 100644 --- a/sdk/typescript/npm/linux-x64/package.json +++ b/sdk/typescript/npm/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@execution-associates/isb-linux-x64", - "version": "3.1.2", + "version": "3.2.0", "description": "The static isb binary for Linux x64 (x86_64-unknown-linux-musl), used by @execution-associates/isb", "license": "MIT", "repository": { diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 419382c8..9df1f2d9 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@execution-associates/isb", - "version": "3.1.2", + "version": "3.2.0", "description": "TypeScript SDK for isb: declarative incus sandboxes (containers and VMs)", "license": "MIT", "type": "module", @@ -51,8 +51,8 @@ "prepack": "bun run build" }, "optionalDependencies": { - "@execution-associates/isb-linux-arm64": "3.1.2", - "@execution-associates/isb-linux-x64": "3.1.2" + "@execution-associates/isb-linux-arm64": "3.2.0", + "@execution-associates/isb-linux-x64": "3.2.0" }, "devDependencies": { "@biomejs/biome": "^2.2.0",