wai-quantum-service: sandboxed workers, IPv6 clients counted by /64, a per-client memory cap #464
5 changed files with 163 additions and 12 deletions
|
|
@ -1092,6 +1092,24 @@ wire format may change and such changes are recorded here.
|
||||||
- **`tools/ops-gpu-bench`** times each capability's decode with its graphs on the CPU and on each adapter, and reads the energy counters the host has.
|
- **`tools/ops-gpu-bench`** times each capability's decode with its graphs on the CPU and on each adapter, and reads the energy counters the host has.
|
||||||
- **CI:** `wai-ops-gpu.yml` runs the Metal and browser legs on the `macos` runner, and requires on a runner with no GPU that the device tests skip without `WAI_OPS_GPU_REQUIRE` and fail with it.
|
- **CI:** `wai-ops-gpu.yml` runs the Metal and browser legs on the `macos` runner, and requires on a runner with no GPU that the device tests skip without `WAI_OPS_GPU_REQUIRE` and fail with it.
|
||||||
|
|
||||||
|
### wai-quantum-service: sandboxed workers, and limits no client can multiply
|
||||||
|
|
||||||
|
- **Workers run sandboxed** (macOS). Each job's worker runs under `sandbox-exec`: no network,
|
||||||
|
no file writes, and no program but itself. It reads its job on stdin and writes its result on
|
||||||
|
stdout, so it needs none of them. A flaw in parsing a submitted circuit then reaches neither
|
||||||
|
the host's files nor the network nor a shell. The pipes it was given stay writable, and its
|
||||||
|
energy is read as before. `WAI_QSVC_SANDBOX=0` turns it off for debugging.
|
||||||
|
- **An IPv6 client is its /64.** Limits were counted per address, and a subscriber is usually
|
||||||
|
given a whole /64, so rotating through it handed out fresh limits with every request. A
|
||||||
|
client is now an IPv4 address or an IPv6 /64. An IPv4 address carried in IPv6 counts as
|
||||||
|
itself.
|
||||||
|
- **One client holds at most one job's worth of memory.** Three active jobs from one client
|
||||||
|
could hold the whole 96 GiB budget. A client's queued and running jobs may now hold together
|
||||||
|
at most `WAI_QSVC_CLIENT_GB`, by default the size one job may take. Any client can still run
|
||||||
|
its largest job, and no client holds the rest.
|
||||||
|
- `GET /v1` and the MCP server publish `memory_per_client_bytes`. `GET /v1` also publishes how
|
||||||
|
a client is counted and whether workers are sandboxed.
|
||||||
|
|
||||||
### wai-quantum 0.6.14 and wai-quantum-cli 0.1.2: contraction trees that sweep along the line, and the design checks on the job service
|
### wai-quantum 0.6.14 and wai-quantum-cli 0.1.2: contraction trees that sweep along the line, and the design checks on the job service
|
||||||
|
|
||||||
0.6.13 was not published on its own: this release carries it to the package registry, and `wai-quantum-cli` 0.1.2 requires it.
|
0.6.13 was not published on its own: this release carries it to the package registry, and `wai-quantum-cli` 0.1.2 requires it.
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,10 @@ bash smoke.sh http://127.0.0.1:8241
|
||||||
|
|
||||||
Each job runs in a child process of the same binary (`worker`), at a lower scheduling priority than the host's other work, and is watched for its resident memory and its time.
|
Each job runs in a child process of the same binary (`worker`), at a lower scheduling priority than the host's other work, and is watched for its resident memory and its time.
|
||||||
|
|
||||||
|
On macOS the worker runs in a sandbox (`sandbox-exec`): no network, no file writes, and no program but itself. It reads its job on stdin and writes its result on stdout, so it needs none of them, and a flaw in parsing a submitted circuit then reaches neither the host's files, nor the network, nor a shell. `WAI_QSVC_SANDBOX=0` turns the sandbox off, for debugging.
|
||||||
|
|
||||||
|
Limits are counted per client. A client is an IPv4 address, or the /64 an IPv6 address is in: one subscriber is usually given a /64 whole, so counting single addresses would hand out fresh limits to anyone who rotates through theirs. Behind the front proxy, the client is the first `X-Forwarded-For` address, which the proxy sets from the edge network's own client-address header rather than passing on what the request carried.
|
||||||
|
|
||||||
These variables set the limits:
|
These variables set the limits:
|
||||||
|
|
||||||
| variable | default | meaning |
|
| variable | default | meaning |
|
||||||
|
|
@ -173,11 +177,13 @@ These variables set the limits:
|
||||||
| `WAI_QSVC_MIN_FREE_PCT` | 15 | share of the host's memory a starting job must leave free |
|
| `WAI_QSVC_MIN_FREE_PCT` | 15 | share of the host's memory a starting job must leave free |
|
||||||
| `WAI_QSVC_JOB_SECS` | 900 | the longest a job may run |
|
| `WAI_QSVC_JOB_SECS` | 900 | the longest a job may run |
|
||||||
| `WAI_QSVC_CLIENT_ACTIVE` | 3 | queued or running jobs one client may hold |
|
| `WAI_QSVC_CLIENT_ACTIVE` | 3 | queued or running jobs one client may hold |
|
||||||
|
| `WAI_QSVC_CLIENT_GB` | the job size | memory one client's queued and running jobs may hold together |
|
||||||
| `WAI_QSVC_CLIENT_BURST` | 30 | submissions one client may make per window |
|
| `WAI_QSVC_CLIENT_BURST` | 30 | submissions one client may make per window |
|
||||||
| `WAI_QSVC_CLIENT_WINDOW_SECS` | 600 | the window |
|
| `WAI_QSVC_CLIENT_WINDOW_SECS` | 600 | the window |
|
||||||
| `WAI_QSVC_MAX_QUEUE` | 256 | jobs waiting at once |
|
| `WAI_QSVC_MAX_QUEUE` | 256 | jobs waiting at once |
|
||||||
| `WAI_QSVC_RETAIN_SECS` | 86400 | how long results are kept |
|
| `WAI_QSVC_RETAIN_SECS` | 86400 | how long results are kept |
|
||||||
| `WAI_QSVC_TRUSTED_PROXIES` | `127.0.0.1,::1` | peers whose `X-Forwarded-For` names the client |
|
| `WAI_QSVC_TRUSTED_PROXIES` | `127.0.0.1,::1` | peers whose `X-Forwarded-For` names the client |
|
||||||
|
| `WAI_QSVC_SANDBOX` | 1 | run each worker sandboxed (macOS); 0 runs it unsandboxed |
|
||||||
| `WAI_QSVC_KEY` | `~/.config/wai-quantum-service/signing.key` | the signing key, created on first start |
|
| `WAI_QSVC_KEY` | `~/.config/wai-quantum-service/signing.key` | the signing key, created on first start |
|
||||||
|
|
||||||
`wai-quantum-service run job.json` runs one job in the current process and prints its plan and its result.
|
`wai-quantum-service run job.json` runs one job in the current process and prints its plan and its result.
|
||||||
|
|
|
||||||
|
|
@ -71,15 +71,33 @@ fn error(status: u16, why: &str) -> Response {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The client a request is from: the first `X-Forwarded-For` address when the
|
/// The client a request is from: the first `X-Forwarded-For` address when the
|
||||||
/// peer is a trusted proxy, otherwise the peer.
|
/// peer is a trusted proxy, otherwise the peer, as [`identity`] counts it.
|
||||||
fn client(app: &App, peer: SocketAddr, headers: &HeaderMap) -> String {
|
fn client(app: &App, peer: SocketAddr, headers: &HeaderMap) -> String {
|
||||||
if app.trusted.contains(&peer.ip())
|
if app.trusted.contains(&peer.ip())
|
||||||
&& let Some(first) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()).and_then(|v| v.split(',').next())
|
&& let Some(first) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()).and_then(|v| v.split(',').next())
|
||||||
&& let Ok(ip) = first.trim().parse::<IpAddr>()
|
&& let Ok(ip) = first.trim().parse::<IpAddr>()
|
||||||
{
|
{
|
||||||
return ip.to_string();
|
return identity(ip);
|
||||||
|
}
|
||||||
|
identity(peer.ip())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name a client's limits are counted under: an IPv4 address, or the /64
|
||||||
|
/// an IPv6 address is in. A subscriber is usually given a /64 whole and may
|
||||||
|
/// use any address in it, so counting addresses would let one client take a
|
||||||
|
/// fresh set of limits with every request. An IPv4 address carried in IPv6
|
||||||
|
/// counts as itself.
|
||||||
|
fn identity(ip: IpAddr) -> String {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => v4.to_string(),
|
||||||
|
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
|
||||||
|
Some(v4) => v4.to_string(),
|
||||||
|
None => {
|
||||||
|
let s = v6.segments();
|
||||||
|
format!("{}/64", std::net::Ipv6Addr::new(s[0], s[1], s[2], s[3], 0, 0, 0, 0))
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
peer.ip().to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn describe(State(app): State<Shared>) -> Response {
|
async fn describe(State(app): State<Shared>) -> Response {
|
||||||
|
|
@ -97,6 +115,9 @@ async fn describe(State(app): State<Shared>) -> Response {
|
||||||
"job_memory_bytes": c.job_mib << 20,
|
"job_memory_bytes": c.job_mib << 20,
|
||||||
"job_seconds": c.job_timeout.as_secs(),
|
"job_seconds": c.job_timeout.as_secs(),
|
||||||
"active_jobs_per_client": c.per_client_active,
|
"active_jobs_per_client": c.per_client_active,
|
||||||
|
"memory_per_client_bytes": c.per_client_mib << 20,
|
||||||
|
"client": "an IPv4 address, or an IPv6 /64",
|
||||||
|
"worker_sandbox": c.sandbox && cfg!(target_os = "macos"),
|
||||||
"submissions_per_window": c.per_client_burst,
|
"submissions_per_window": c.per_client_burst,
|
||||||
"window_seconds": c.per_client_window.as_secs(),
|
"window_seconds": c.per_client_window.as_secs(),
|
||||||
"queue": c.max_queue,
|
"queue": c.max_queue,
|
||||||
|
|
@ -280,3 +301,25 @@ pub fn router(app: Shared) -> Router {
|
||||||
.layer(DefaultBodyLimit::max(MAX_BODY))
|
.layer(DefaultBodyLimit::max(MAX_BODY))
|
||||||
.with_state(app)
|
.with_state(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::identity;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
fn id(s: &str) -> String {
|
||||||
|
identity(s.parse::<IpAddr>().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ipv6_client_is_its_64_and_ipv4_is_itself() {
|
||||||
|
// Every address in one /64 is one client, however it rotates.
|
||||||
|
assert_eq!(id("2001:db8:aa:bb:1:2:3:4"), "2001:db8:aa:bb::/64");
|
||||||
|
assert_eq!(id("2001:db8:aa:bb:ffff:0:dead:beef"), "2001:db8:aa:bb::/64");
|
||||||
|
assert_ne!(id("2001:db8:aa:bc::1"), id("2001:db8:aa:bb::1"));
|
||||||
|
assert_eq!(id("203.0.113.7"), "203.0.113.7");
|
||||||
|
// An IPv4 address carried in IPv6 is the IPv4 client, not a /64.
|
||||||
|
assert_eq!(id("::ffff:203.0.113.7"), "203.0.113.7");
|
||||||
|
assert_eq!(id("::1"), "::/64");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@
|
||||||
//! - **Isolation.** Each job runs in a child process (this binary's `worker`
|
//! - **Isolation.** Each job runs in a child process (this binary's `worker`
|
||||||
//! mode), at a lower scheduling priority, polled for its resident memory.
|
//! mode), at a lower scheduling priority, polled for its resident memory.
|
||||||
//! One that passes its admitted memory or its time limit is killed, and the
|
//! One that passes its admitted memory or its time limit is killed, and the
|
||||||
//! job fails saying which.
|
//! job fails saying which. On macOS the worker runs sandboxed: no network,
|
||||||
|
//! no file writes, no further processes ([`worker_profile`]).
|
||||||
|
//! - **Fairness.** One client may hold a few jobs at once, and together no
|
||||||
|
//! more memory than one job may take ([`Config::per_client_mib`]), so no
|
||||||
|
//! client holds the whole budget. A client is an IPv4 address or an IPv6
|
||||||
|
//! /64 (`http::client`), since one subscriber is usually given a /64 whole.
|
||||||
//! - **Energy.** A finished worker is read before it is reaped, and its
|
//! - **Energy.** A finished worker is read before it is reaped, and its
|
||||||
//! result carries the CPU energy its process spent, with the counter's
|
//! result carries the CPU energy its process spent, with the counter's
|
||||||
//! class and declared uncertainty, inside the signed body ([`energy`]).
|
//! class and declared uncertainty, inside the signed body ([`energy`]).
|
||||||
|
|
@ -55,6 +60,12 @@ pub struct Config {
|
||||||
pub max_queue: usize,
|
pub max_queue: usize,
|
||||||
/// Queued or running jobs one client may hold.
|
/// Queued or running jobs one client may hold.
|
||||||
pub per_client_active: usize,
|
pub per_client_active: usize,
|
||||||
|
/// The memory one client's queued and running jobs may hold together,
|
||||||
|
/// MiB: one job's worth by default, so no client holds the whole budget.
|
||||||
|
pub per_client_mib: u64,
|
||||||
|
/// Whether each worker runs in a sandbox with no network, no file writes
|
||||||
|
/// and no further processes (macOS; see [`worker_profile`]).
|
||||||
|
pub sandbox: bool,
|
||||||
/// Submissions one client may make in a window.
|
/// Submissions one client may make in a window.
|
||||||
pub per_client_burst: usize,
|
pub per_client_burst: usize,
|
||||||
pub per_client_window: Duration,
|
pub per_client_window: Duration,
|
||||||
|
|
@ -68,18 +79,22 @@ impl Config {
|
||||||
/// `WAI_QSVC_BUDGET_GB` (96), `WAI_QSVC_JOB_GB` (72), `WAI_QSVC_WORKERS`
|
/// `WAI_QSVC_BUDGET_GB` (96), `WAI_QSVC_JOB_GB` (72), `WAI_QSVC_WORKERS`
|
||||||
/// (4), `WAI_QSVC_MIN_FREE_PCT` (15), `WAI_QSVC_JOB_SECS` (900),
|
/// (4), `WAI_QSVC_MIN_FREE_PCT` (15), `WAI_QSVC_JOB_SECS` (900),
|
||||||
/// `WAI_QSVC_RETAIN_SECS` (86400), `WAI_QSVC_MAX_QUEUE` (256),
|
/// `WAI_QSVC_RETAIN_SECS` (86400), `WAI_QSVC_MAX_QUEUE` (256),
|
||||||
/// `WAI_QSVC_CLIENT_ACTIVE` (3), `WAI_QSVC_CLIENT_BURST` (30 in
|
/// `WAI_QSVC_CLIENT_ACTIVE` (3), `WAI_QSVC_CLIENT_GB` (the job size),
|
||||||
/// `WAI_QSVC_CLIENT_WINDOW_SECS`, 600).
|
/// `WAI_QSVC_CLIENT_BURST` (30 in `WAI_QSVC_CLIENT_WINDOW_SECS`, 600),
|
||||||
|
/// `WAI_QSVC_SANDBOX` (1; 0 runs workers unsandboxed).
|
||||||
pub fn from_env() -> Config {
|
pub fn from_env() -> Config {
|
||||||
|
let job_gb = env_num("WAI_QSVC_JOB_GB", 72u64);
|
||||||
Config {
|
Config {
|
||||||
budget_mib: env_num("WAI_QSVC_BUDGET_GB", 96u64) * 1024,
|
budget_mib: env_num("WAI_QSVC_BUDGET_GB", 96u64) * 1024,
|
||||||
job_mib: env_num("WAI_QSVC_JOB_GB", 72u64) * 1024,
|
job_mib: job_gb * 1024,
|
||||||
workers: env_num("WAI_QSVC_WORKERS", 4usize).max(1),
|
workers: env_num("WAI_QSVC_WORKERS", 4usize).max(1),
|
||||||
min_free_pct: env_num("WAI_QSVC_MIN_FREE_PCT", 15u32),
|
min_free_pct: env_num("WAI_QSVC_MIN_FREE_PCT", 15u32),
|
||||||
job_timeout: Duration::from_secs(env_num("WAI_QSVC_JOB_SECS", 900u64)),
|
job_timeout: Duration::from_secs(env_num("WAI_QSVC_JOB_SECS", 900u64)),
|
||||||
retain: Duration::from_secs(env_num("WAI_QSVC_RETAIN_SECS", 86_400u64)),
|
retain: Duration::from_secs(env_num("WAI_QSVC_RETAIN_SECS", 86_400u64)),
|
||||||
max_queue: env_num("WAI_QSVC_MAX_QUEUE", 256usize),
|
max_queue: env_num("WAI_QSVC_MAX_QUEUE", 256usize),
|
||||||
per_client_active: env_num("WAI_QSVC_CLIENT_ACTIVE", 3usize),
|
per_client_active: env_num("WAI_QSVC_CLIENT_ACTIVE", 3usize),
|
||||||
|
per_client_mib: env_num("WAI_QSVC_CLIENT_GB", job_gb) * 1024,
|
||||||
|
sandbox: env_num("WAI_QSVC_SANDBOX", 1u8) != 0,
|
||||||
per_client_burst: env_num("WAI_QSVC_CLIENT_BURST", 30usize),
|
per_client_burst: env_num("WAI_QSVC_CLIENT_BURST", 30usize),
|
||||||
per_client_window: Duration::from_secs(env_num("WAI_QSVC_CLIENT_WINDOW_SECS", 600u64)),
|
per_client_window: Duration::from_secs(env_num("WAI_QSVC_CLIENT_WINDOW_SECS", 600u64)),
|
||||||
}
|
}
|
||||||
|
|
@ -244,10 +259,18 @@ impl Service {
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let jobs = self.jobs.lock().unwrap();
|
let jobs = self.jobs.lock().unwrap();
|
||||||
let active = jobs.values().filter(|j| j.client == client && j.status.active()).count();
|
let mine = || jobs.values().filter(|j| j.client == client && j.status.active());
|
||||||
|
let active = mine().count();
|
||||||
if active >= self.config.per_client_active {
|
if active >= self.config.per_client_active {
|
||||||
return Err(Refused(429, format!("{active} of your jobs are queued or running; wait for one to finish (at most {} at once)", self.config.per_client_active)));
|
return Err(Refused(429, format!("{active} of your jobs are queued or running; wait for one to finish (at most {} at once)", self.config.per_client_active)));
|
||||||
}
|
}
|
||||||
|
let held: u64 = mine().map(|j| j.plan.memory_bytes.div_ceil(1 << 20)).sum();
|
||||||
|
if held + job_mib > self.config.per_client_mib {
|
||||||
|
return Err(Refused(
|
||||||
|
429,
|
||||||
|
format!("your queued and running jobs already hold about {held} MiB, and this one needs {job_mib} MiB; one client may hold at most {} MiB at once", self.config.per_client_mib),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.count(client)?;
|
self.count(client)?;
|
||||||
let id = job_id();
|
let id = job_id();
|
||||||
|
|
@ -428,7 +451,7 @@ impl Service {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(e) => return (Err(format!("the service could not find itself: {e}")), 0),
|
Err(e) => return (Err(format!("the service could not find itself: {e}")), 0),
|
||||||
};
|
};
|
||||||
let mut cmd = low_priority(&exe);
|
let mut cmd = low_priority(&exe, self.config.sandbox);
|
||||||
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
|
cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
|
||||||
let mut child = match cmd.spawn() {
|
let mut child = match cmd.spawn() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
|
|
@ -512,10 +535,18 @@ impl Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The worker command at a lower priority than the host's other work.
|
/// The worker command at a lower priority than the host's other work.
|
||||||
fn low_priority(exe: &std::path::Path) -> tokio::process::Command {
|
fn low_priority(exe: &std::path::Path, sandbox: bool) -> tokio::process::Command {
|
||||||
let mut cmd = if cfg!(target_os = "macos") {
|
let mut cmd = if cfg!(target_os = "macos") {
|
||||||
let mut c = tokio::process::Command::new("/usr/sbin/taskpolicy");
|
let mut c = tokio::process::Command::new("/usr/sbin/taskpolicy");
|
||||||
c.args(["-c", "utility", "/usr/bin/nice", "-n", "10"]).arg(exe);
|
c.args(["-c", "utility", "/usr/bin/nice", "-n", "10"]);
|
||||||
|
if sandbox {
|
||||||
|
// The rule names the path the exec resolves to, so the worker is
|
||||||
|
// started by that same path.
|
||||||
|
let exe = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf());
|
||||||
|
c.args(["/usr/bin/sandbox-exec", "-p"]).arg(worker_profile(&exe)).arg(exe);
|
||||||
|
} else {
|
||||||
|
c.arg(exe);
|
||||||
|
}
|
||||||
c
|
c
|
||||||
} else {
|
} else {
|
||||||
let mut c = tokio::process::Command::new("nice");
|
let mut c = tokio::process::Command::new("nice");
|
||||||
|
|
@ -526,6 +557,20 @@ fn low_priority(exe: &std::path::Path) -> tokio::process::Command {
|
||||||
cmd
|
cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sandbox a worker runs in on macOS. A worker reads its job on stdin and
|
||||||
|
/// writes its result on stdout, so it needs no network, writes no file and
|
||||||
|
/// starts no process: a flaw in parsing a submitted circuit then reaches
|
||||||
|
/// neither the host's files nor the network nor a shell. Network, file writes
|
||||||
|
/// (bar `/dev/null`) and fork are denied; exec is allowed for the worker
|
||||||
|
/// binary alone, which `sandbox-exec` starts once the profile is in force.
|
||||||
|
/// The pipes it was given stay writable, since they are already open.
|
||||||
|
pub fn worker_profile(exe: &std::path::Path) -> String {
|
||||||
|
let path = exe.to_string_lossy().replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
|
format!(
|
||||||
|
"(version 1)(allow default)(deny network*)(deny file-write*)(allow file-write-data (literal \"/dev/null\"))(deny process-fork)(deny process-exec)(allow process-exec (literal \"{path}\"))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn kill(pid: u32) {
|
fn kill(pid: u32) {
|
||||||
if pid > 0 {
|
if pid > 0 {
|
||||||
let _ = std::process::Command::new("kill").args(["-9", &pid.to_string()]).status();
|
let _ = std::process::Command::new("kill").args(["-9", &pid.to_string()]).status();
|
||||||
|
|
@ -603,11 +648,50 @@ mod tests {
|
||||||
retain: Duration::from_secs(60),
|
retain: Duration::from_secs(60),
|
||||||
max_queue: 4,
|
max_queue: 4,
|
||||||
per_client_active: 2,
|
per_client_active: 2,
|
||||||
|
per_client_mib: 512,
|
||||||
|
sandbox: true,
|
||||||
per_client_burst: 3,
|
per_client_burst: 3,
|
||||||
per_client_window: Duration::from_secs(60),
|
per_client_window: Duration::from_secs(60),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_client_holds_at_most_one_jobs_worth_of_memory_at_once() {
|
||||||
|
let s = Service::new(Config { per_client_active: 5, per_client_burst: 10, ..config() });
|
||||||
|
assert!(s.submit("a", json!({}), plan(300 << 20)).is_ok());
|
||||||
|
// 300 + 300 MiB would pass the 512 MiB a client may hold.
|
||||||
|
let Err(Refused(code, why)) = s.submit("a", json!({}), plan(300 << 20)) else { panic!("admitted past the client's memory") };
|
||||||
|
assert_eq!(code, 429);
|
||||||
|
assert!(why.contains("300 MiB") && why.contains("512 MiB"), "{why}");
|
||||||
|
// What still fits is admitted, and another client is not affected.
|
||||||
|
assert!(s.submit("a", json!({}), plan(200 << 20)).is_ok());
|
||||||
|
assert!(s.submit("b", json!({}), plan(500 << 20)).is_ok());
|
||||||
|
// A cancelled job gives its memory back.
|
||||||
|
let first: Vec<String> = s.jobs.lock().unwrap().values().filter(|j| j.client == "a" && j.plan.memory_bytes == 300 << 20).map(|j| j.id.clone()).collect();
|
||||||
|
assert_eq!(s.cancel(&first[0]), Some(true));
|
||||||
|
assert!(s.submit("a", json!({}), plan(300 << 20)).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn the_worker_sandbox_denies_files_network_and_processes() {
|
||||||
|
let run = |exe: &str, args: &[&str]| {
|
||||||
|
std::process::Command::new("/usr/bin/sandbox-exec").arg("-p").arg(worker_profile(std::path::Path::new(exe))).arg(exe).args(args).output().expect("sandbox-exec runs")
|
||||||
|
};
|
||||||
|
let probe = std::env::temp_dir().join(format!("wai-qsvc-sandbox-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_file(&probe);
|
||||||
|
let out = run("/usr/bin/touch", &[probe.to_str().unwrap()]);
|
||||||
|
assert!(!out.status.success() && !probe.exists(), "a sandboxed worker wrote a file");
|
||||||
|
let out = run("/usr/bin/curl", &["-s", "-m", "5", "-o", "/dev/null", "-w", "%{http_code}", "https://example.com"]);
|
||||||
|
assert_eq!(String::from_utf8_lossy(&out.stdout), "000", "a sandboxed worker reached the network");
|
||||||
|
// /bin/sh hands off to bash, a second exec, which the profile refuses.
|
||||||
|
let out = run("/bin/sh", &["-c", "echo ran"]);
|
||||||
|
assert!(!String::from_utf8_lossy(&out.stdout).contains("ran"), "a sandboxed worker started another program");
|
||||||
|
// What a worker does need: writing its result to the pipe it was given.
|
||||||
|
let out = run("/bin/echo", &["result"]);
|
||||||
|
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "result");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_job_over_the_per_job_cap_is_refused_with_its_estimate() {
|
fn a_job_over_the_per_job_cap_is_refused_with_its_estimate() {
|
||||||
let s = Service::new(config());
|
let s = Service::new(config());
|
||||||
|
|
|
||||||
|
|
@ -278,7 +278,7 @@ async fn call(app: &App, who: &str, name: &str, args: Value, base: &str) -> Valu
|
||||||
"amplitudes": "bit strings with one character per qubit, qubit n-1 first",
|
"amplitudes": "bit strings with one character per qubit, qubit n-1 first",
|
||||||
"observables": "Pauli strings such as \"Z0 Z1\" or {\"pauli\": \"X3\", \"coeff\": 0.5}; qubits count from 0"
|
"observables": "Pauli strings such as \"Z0 Z1\" or {\"pauli\": \"X3\", \"coeff\": 0.5}; qubits count from 0"
|
||||||
},
|
},
|
||||||
"limits": { "job_seconds": c.job_timeout.as_secs(), "active_jobs_per_client": c.per_client_active, "submissions_per_window": c.per_client_burst, "window_seconds": c.per_client_window.as_secs() },
|
"limits": { "job_seconds": c.job_timeout.as_secs(), "active_jobs_per_client": c.per_client_active, "memory_per_client_bytes": c.per_client_mib << 20, "submissions_per_window": c.per_client_burst, "window_seconds": c.per_client_window.as_secs() },
|
||||||
"load": { "queued": queued, "running": running },
|
"load": { "queued": queued, "running": running },
|
||||||
"api": discovery::index(base, &app.console),
|
"api": discovery::index(base, &app.console),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue