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
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,20 @@ members = ["crates/*"]
default-members = [".", "crates/*"]

[workspace.package]
version = "3.1.2"
version = "3.2.0"
edition = "2024"
rust-version = "1.87"
license = "MIT"
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"
Expand Down
8 changes: 6 additions & 2 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
55 changes: 7 additions & 48 deletions crates/isb-core/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>;

Expand Down Expand Up @@ -372,60 +375,15 @@ pub struct Desired {
/// can be written while it is stopped.
#[serde(skip)]
pub before_start: Option<BeforeStart>,
/// The spec's `expires` (crate::sandbox::expiry).
#[serde(skip)]
pub expires: Option<crate::sandbox::expiry::Expires>,
}

/// Named-volume definitions available to a sandbox (from a compose file's
/// top-level `volumes:`).
pub type VolumeDefs = BTreeMap<String, crate::spec::NamedVolumeSpec>;

/// 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(':')?;
Expand Down Expand Up @@ -968,6 +926,7 @@ pub fn resolve(
.collect(),
egress,
before_start: None,
expires: spec.expires.as_deref().map(Expires::parse).transpose()?,
})
}

Expand Down
52 changes: 52 additions & 0 deletions crates/isb-core/src/plan/names.rs
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 30 additions & 8 deletions crates/isb-core/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,12 @@ struct Server {
out: Out,
client: Client,
execs: Mutex<HashMap<String, ExecSlot>>,
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<std::time::Duration>;

#[derive(Deserialize)]
struct Request {
#[serde(default)]
Expand All @@ -75,11 +79,22 @@ pub fn serve<R: BufRead, W: Write + Send + 'static>(
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<R: BufRead, W: Write + Send + 'static>(
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();
Expand Down Expand Up @@ -139,6 +154,13 @@ impl Server {
}
}

fn default_expiry(&self, c: &Client) -> Option<std::time::Duration> {
Some(sandbox::expiry::default_for(
c.project_name(),
self.org_expiry,
))
}

fn client(&self, project: &Option<String>) -> Client {
match project {
Some(p) => self.client.clone().project(p),
Expand All @@ -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)?;
Expand All @@ -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)?;
Expand Down Expand Up @@ -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}))
Expand Down Expand Up @@ -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)?;
}
Expand Down
Loading
Loading