feat: per-script Rhai sandbox overrides with admin ceiling
Adds optional per-script overrides for the six Rhai sandbox knobs
(max_operations, max_string_size, max_array_size, max_map_size,
max_call_levels, max_expr_depth). The executor merges its defaults
with each script's overrides on every call; the manager validates
overrides against an admin-set ceiling at write time, so the
executor trusts whatever is stored.
Storage chose JSONB on the existing scripts table over six new
columns: lets future knobs land as code-only changes, keeps the
sparse common case (most scripts override nothing) cheap to store
and serialize, and matches how the manager + executor pass the
config across the wire.
* 0002_sandbox.sql — ALTER TABLE scripts ADD COLUMN sandbox
JSONB NOT NULL DEFAULT '{}'
* shared::ScriptSandbox — six Option<u64> fields with
deny_unknown_fields so typos surface as 422
* Script.sandbox + ExecRequest.sandbox_overrides — typed end
to end; cluster mode just serializes the same struct
* executor-core::Limits::with_overrides — field-by-field
replacement; tests cover the override actually tightening
the live engine
* manager-core::SandboxCeiling — built-in conservative
defaults (10M ops, 1 MiB strings, 100k array/map, 128
call/expr depth); env vars override per knob, invalid
values warn-and-skip rather than blocking boot
* manager-core admin API — POST/PUT accept `sandbox`; values
above the ceiling return 422 with the specific field +
requested + ceiling; absent or `{}` keeps platform defaults
* picloud all-in-one — wires SandboxCeiling::from_env() into
AdminState
* memory_limit_mb stays in the schema, marked v1.3+ advisory
(no enforcement until OS-level isolation lands with
cluster-mode executors)
Verified live through Caddy:
* /version reports schema 2, product 0.3.0
* Script with max_operations: 500 → 507 on a 10k-iteration loop
* Same script after PUT raising to 1M → succeeds, returns 10000
* POST with max_operations: 1_000_000_000 → 422 (exceeds ceiling)
Tests:
* 13 executor-core unit tests (added 2 for override semantics)
* 20 integration tests (added 6 for sandbox CRUD + ceiling +
unknown-field rejection + executor honoring overrides)
* default cargo test --workspace stays green (integration tests
remain #[ignore]'d until DATABASE_URL is set)
Bumps:
* schema 1 → 2
* product 0.2.0 → 0.3.0
* SDK unchanged (scripts see nothing new)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -49,10 +49,13 @@ impl Engine {
|
|||||||
|
|
||||||
/// Execute `source` against `req`. Op-budget protection comes from
|
/// Execute `source` against `req`. Op-budget protection comes from
|
||||||
/// Rhai's `set_max_operations`; wall-clock enforcement is the
|
/// Rhai's `set_max_operations`; wall-clock enforcement is the
|
||||||
/// caller's responsibility.
|
/// caller's responsibility. Per-script sandbox overrides on the
|
||||||
|
/// request replace the engine's defaults field-by-field; the
|
||||||
|
/// manager already clamped them against the admin ceiling.
|
||||||
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
||||||
|
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
||||||
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
let engine = build_engine(self.limits, Some(logs.clone()));
|
let engine = build_engine(effective_limits, Some(logs.clone()));
|
||||||
|
|
||||||
let ast = engine
|
let ast = engine
|
||||||
.compile(source)
|
.compile(source)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
use picloud_shared::ScriptSandbox;
|
||||||
|
|
||||||
/// Resource and capability limits applied to every script execution.
|
/// Resource and capability limits applied to every script execution.
|
||||||
///
|
///
|
||||||
/// Defaults are conservative and safe to expose to untrusted Rhai sources.
|
/// Defaults are conservative and safe to expose to untrusted Rhai sources.
|
||||||
/// Per-script overrides (e.g. higher operation budgets) come from the
|
/// Per-script overrides (via `Limits::with_overrides`) replace individual
|
||||||
/// `Script` config and are clamped against these as upper bounds.
|
/// fields; the manager clamps every override against the admin ceiling at
|
||||||
|
/// write time, so the executor trusts what arrives in `ExecRequest`.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Limits {
|
pub struct Limits {
|
||||||
/// Hard cap on Rhai operations executed per invocation.
|
/// Hard cap on Rhai operations executed per invocation.
|
||||||
@@ -35,3 +38,37 @@ impl Default for Limits {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Limits {
|
||||||
|
/// Returns a new `Limits` with each field replaced by the matching
|
||||||
|
/// override if present, otherwise the existing field. Overrides
|
||||||
|
/// arrive as `u64` for JSONB round-tripping cleanliness; they're
|
||||||
|
/// narrowed to `usize` here, saturating on the unlikely overflow
|
||||||
|
/// (these caps come from admin-clamped writes, so the values are
|
||||||
|
/// always small).
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_overrides(&self, overrides: &ScriptSandbox) -> Self {
|
||||||
|
Self {
|
||||||
|
max_operations: overrides.max_operations.unwrap_or(self.max_operations),
|
||||||
|
max_string_size: overrides
|
||||||
|
.max_string_size
|
||||||
|
.map_or(self.max_string_size, narrow_usize),
|
||||||
|
max_array_size: overrides
|
||||||
|
.max_array_size
|
||||||
|
.map_or(self.max_array_size, narrow_usize),
|
||||||
|
max_map_size: overrides
|
||||||
|
.max_map_size
|
||||||
|
.map_or(self.max_map_size, narrow_usize),
|
||||||
|
max_call_levels: overrides
|
||||||
|
.max_call_levels
|
||||||
|
.map_or(self.max_call_levels, narrow_usize),
|
||||||
|
max_expr_depth: overrides
|
||||||
|
.max_expr_depth
|
||||||
|
.map_or(self.max_expr_depth, narrow_usize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn narrow_usize(v: u64) -> usize {
|
||||||
|
usize::try_from(v).unwrap_or(usize::MAX)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{ExecutionId, RequestId, ScriptId};
|
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -27,6 +27,12 @@ pub struct ExecRequest {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
pub headers: BTreeMap<String, String>,
|
pub headers: BTreeMap<String, String>,
|
||||||
pub body: serde_json::Value,
|
pub body: serde_json::Value,
|
||||||
|
|
||||||
|
/// Per-script sandbox overrides resolved by the manager. The
|
||||||
|
/// executor's default `Limits` get merged with these (per-field
|
||||||
|
/// override) before the Rhai engine is built.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sandbox_overrides: ScriptSandbox,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits, LogLevel};
|
use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits, LogLevel};
|
||||||
use picloud_shared::{ExecutionId, RequestId, ScriptId};
|
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn req(body: serde_json::Value) -> ExecRequest {
|
fn req(body: serde_json::Value) -> ExecRequest {
|
||||||
@@ -14,6 +14,7 @@ fn req(body: serde_json::Value) -> ExecRequest {
|
|||||||
path: "/test".into(),
|
path: "/test".into(),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
body,
|
body,
|
||||||
|
sandbox_overrides: ScriptSandbox::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +127,40 @@ fn enforces_operation_budget() {
|
|||||||
assert!(matches!(err, ExecError::OperationBudgetExceeded));
|
assert!(matches!(err, ExecError::OperationBudgetExceeded));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn per_request_sandbox_override_tightens_budget() {
|
||||||
|
// Engine default is 1M ops — the script below would finish.
|
||||||
|
// We override down to 500 ops on this single request; should fail.
|
||||||
|
let engine = engine();
|
||||||
|
let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
|
||||||
|
let r = ExecRequest {
|
||||||
|
sandbox_overrides: ScriptSandbox {
|
||||||
|
max_operations: Some(500),
|
||||||
|
..ScriptSandbox::default()
|
||||||
|
},
|
||||||
|
..req(json!(null))
|
||||||
|
};
|
||||||
|
let err = engine.execute(src, r).expect_err("override should tighten");
|
||||||
|
assert!(matches!(err, ExecError::OperationBudgetExceeded));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn override_only_replaces_specified_field() {
|
||||||
|
// Tight string size, default everything else. Strings > 32 chars
|
||||||
|
// should fail; loops up to default 1M ops should still pass.
|
||||||
|
let engine = engine();
|
||||||
|
let small_string_ok = r#"let s = "hello"; #{ statusCode: 200, body: s }"#;
|
||||||
|
let r1 = ExecRequest {
|
||||||
|
sandbox_overrides: ScriptSandbox {
|
||||||
|
max_string_size: Some(32),
|
||||||
|
..ScriptSandbox::default()
|
||||||
|
},
|
||||||
|
..req(json!(null))
|
||||||
|
};
|
||||||
|
let resp = engine.execute(small_string_ok, r1).unwrap();
|
||||||
|
assert_eq!(resp.body, json!("hello"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_error_is_mapped_to_runtime_variant() {
|
fn runtime_error_is_mapped_to_runtime_variant() {
|
||||||
let err = engine()
|
let err = engine()
|
||||||
|
|||||||
15
crates/manager-core/migrations/0002_sandbox.sql
Normal file
15
crates/manager-core/migrations/0002_sandbox.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Per-script Rhai sandbox overrides. Empty JSONB = use platform defaults.
|
||||||
|
-- Schema is shape-only: the manager validates field names + value ranges +
|
||||||
|
-- against the admin-set ceiling at write time. Storing JSONB lets us add
|
||||||
|
-- new knobs without a migration (and skip them cleanly on read).
|
||||||
|
--
|
||||||
|
-- Recognized fields:
|
||||||
|
-- max_operations u64
|
||||||
|
-- max_string_size u64
|
||||||
|
-- max_array_size u64
|
||||||
|
-- max_map_size u64
|
||||||
|
-- max_call_levels u64
|
||||||
|
-- max_expr_depth u64
|
||||||
|
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD COLUMN sandbox JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||||
@@ -11,12 +11,15 @@ use axum::{
|
|||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_shared::{ExecutionLog, Script, ScriptId, ScriptValidator, ValidationError};
|
use picloud_shared::{
|
||||||
|
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
||||||
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::repo::{
|
use crate::repo::{
|
||||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||||
};
|
};
|
||||||
|
use crate::sandbox::{CeilingError, SandboxCeiling};
|
||||||
|
|
||||||
/// State shared by control-plane handlers. Separates concerns so the
|
/// State shared by control-plane handlers. Separates concerns so the
|
||||||
/// manager can validate at upload time without depending on the
|
/// manager can validate at upload time without depending on the
|
||||||
@@ -25,6 +28,7 @@ pub struct AdminState<R, L> {
|
|||||||
pub repo: Arc<R>,
|
pub repo: Arc<R>,
|
||||||
pub logs: Arc<L>,
|
pub logs: Arc<L>,
|
||||||
pub validator: Arc<dyn ScriptValidator>,
|
pub validator: Arc<dyn ScriptValidator>,
|
||||||
|
pub sandbox_ceiling: SandboxCeiling,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R, L> Clone for AdminState<R, L> {
|
impl<R, L> Clone for AdminState<R, L> {
|
||||||
@@ -33,6 +37,7 @@ impl<R, L> Clone for AdminState<R, L> {
|
|||||||
repo: self.repo.clone(),
|
repo: self.repo.clone(),
|
||||||
logs: self.logs.clone(),
|
logs: self.logs.clone(),
|
||||||
validator: self.validator.clone(),
|
validator: self.validator.clone(),
|
||||||
|
sandbox_ceiling: self.sandbox_ceiling,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +75,11 @@ pub struct CreateScriptRequest {
|
|||||||
pub source: String,
|
pub source: String,
|
||||||
pub timeout_seconds: Option<i32>,
|
pub timeout_seconds: Option<i32>,
|
||||||
pub memory_limit_mb: Option<i32>,
|
pub memory_limit_mb: Option<i32>,
|
||||||
|
/// Sandbox overrides; absent or empty `{}` means "use platform
|
||||||
|
/// defaults". Each non-null field is checked against the admin
|
||||||
|
/// ceiling at write time.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sandbox: ScriptSandbox,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -83,6 +93,10 @@ pub struct UpdateScriptRequest {
|
|||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
pub timeout_seconds: Option<i32>,
|
pub timeout_seconds: Option<i32>,
|
||||||
pub memory_limit_mb: Option<i32>,
|
pub memory_limit_mb: Option<i32>,
|
||||||
|
/// `Some(sandbox)` replaces the stored overrides wholesale (use
|
||||||
|
/// `Some(ScriptSandbox::empty())` to clear them). Absent leaves
|
||||||
|
/// the stored value unchanged.
|
||||||
|
pub sandbox: Option<ScriptSandbox>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::option_option)]
|
#[allow(clippy::option_option)]
|
||||||
@@ -120,6 +134,7 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
Json(input): Json<CreateScriptRequest>,
|
Json(input): Json<CreateScriptRequest>,
|
||||||
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
||||||
state.validator.validate(&input.source)?;
|
state.validator.validate(&input.source)?;
|
||||||
|
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||||
let created = state
|
let created = state
|
||||||
.repo
|
.repo
|
||||||
.create(NewScript {
|
.create(NewScript {
|
||||||
@@ -128,6 +143,11 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
source: input.source,
|
source: input.source,
|
||||||
timeout_seconds: input.timeout_seconds,
|
timeout_seconds: input.timeout_seconds,
|
||||||
memory_limit_mb: input.memory_limit_mb,
|
memory_limit_mb: input.memory_limit_mb,
|
||||||
|
sandbox: if input.sandbox.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(input.sandbox)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
Ok((StatusCode::CREATED, Json(created)))
|
Ok((StatusCode::CREATED, Json(created)))
|
||||||
@@ -141,6 +161,9 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
if let Some(src) = input.source.as_deref() {
|
if let Some(src) = input.source.as_deref() {
|
||||||
state.validator.validate(src)?;
|
state.validator.validate(src)?;
|
||||||
}
|
}
|
||||||
|
if let Some(sb) = input.sandbox.as_ref() {
|
||||||
|
state.sandbox_ceiling.check(sb)?;
|
||||||
|
}
|
||||||
let updated = state
|
let updated = state
|
||||||
.repo
|
.repo
|
||||||
.update(
|
.update(
|
||||||
@@ -151,6 +174,7 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
source: input.source,
|
source: input.source,
|
||||||
timeout_seconds: input.timeout_seconds,
|
timeout_seconds: input.timeout_seconds,
|
||||||
memory_limit_mb: input.memory_limit_mb,
|
memory_limit_mb: input.memory_limit_mb,
|
||||||
|
sandbox: input.sandbox,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -205,6 +229,9 @@ pub enum ApiError {
|
|||||||
#[error("invalid script: {0}")]
|
#[error("invalid script: {0}")]
|
||||||
Invalid(#[from] ValidationError),
|
Invalid(#[from] ValidationError),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
Ceiling(#[from] CeilingError),
|
||||||
|
|
||||||
#[error("repository error: {0}")]
|
#[error("repository error: {0}")]
|
||||||
Repo(#[from] ScriptRepositoryError),
|
Repo(#[from] ScriptRepositoryError),
|
||||||
}
|
}
|
||||||
@@ -214,7 +241,9 @@ impl IntoResponse for ApiError {
|
|||||||
let (status, message) = match &self {
|
let (status, message) = match &self {
|
||||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
Self::Invalid(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
Self::Invalid(_) | Self::Ceiling(_) => {
|
||||||
|
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||||
|
}
|
||||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||||
(StatusCode::NOT_FOUND, self.to_string())
|
(StatusCode::NOT_FOUND, self.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod api;
|
|||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
|
pub mod sandbox;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
|
|
||||||
pub use api::{admin_router, AdminState};
|
pub use api::{admin_router, AdminState};
|
||||||
@@ -16,3 +17,4 @@ pub use repo::{
|
|||||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||||
RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||||
};
|
};
|
||||||
|
pub use sandbox::{CeilingError, SandboxCeiling};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||||
use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId};
|
use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -40,6 +40,9 @@ pub struct NewScript {
|
|||||||
pub source: String,
|
pub source: String,
|
||||||
pub timeout_seconds: Option<i32>,
|
pub timeout_seconds: Option<i32>,
|
||||||
pub memory_limit_mb: Option<i32>,
|
pub memory_limit_mb: Option<i32>,
|
||||||
|
/// Sandbox overrides; `None` means store an empty object (use
|
||||||
|
/// platform defaults at exec time).
|
||||||
|
pub sandbox: Option<ScriptSandbox>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inbound shape for update. `None` fields are left untouched.
|
/// Inbound shape for update. `None` fields are left untouched.
|
||||||
@@ -50,6 +53,9 @@ pub struct ScriptPatch {
|
|||||||
pub source: Option<String>,
|
pub source: Option<String>,
|
||||||
pub timeout_seconds: Option<i32>,
|
pub timeout_seconds: Option<i32>,
|
||||||
pub memory_limit_mb: Option<i32>,
|
pub memory_limit_mb: Option<i32>,
|
||||||
|
/// `Some(sandbox)` replaces the stored overrides wholesale (including
|
||||||
|
/// `Some(empty)` to clear them); `None` leaves them untouched.
|
||||||
|
pub sandbox: Option<ScriptSandbox>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresScriptRepository {
|
pub struct PostgresScriptRepository {
|
||||||
@@ -73,7 +79,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
let row = sqlx::query_as::<_, ScriptRow>(
|
let row = sqlx::query_as::<_, ScriptRow>(
|
||||||
"SELECT id, name, description, version, source, \
|
"SELECT id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, created_at, updated_at \
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
FROM scripts WHERE id = $1",
|
FROM scripts WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
@@ -85,7 +91,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||||
"SELECT id, name, description, version, source, \
|
"SELECT id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, created_at, updated_at \
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||||
FROM scripts ORDER BY name",
|
FROM scripts ORDER BY name",
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
@@ -94,17 +100,22 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||||
|
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
let res = sqlx::query_as::<_, ScriptRow>(
|
let res = sqlx::query_as::<_, ScriptRow>(
|
||||||
"INSERT INTO scripts (name, description, source, timeout_seconds, memory_limit_mb) \
|
"INSERT INTO scripts ( \
|
||||||
VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256)) \
|
name, description, source, \
|
||||||
|
timeout_seconds, memory_limit_mb, sandbox \
|
||||||
|
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \
|
||||||
RETURNING id, name, description, version, source, \
|
RETURNING id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, created_at, updated_at",
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(&input.name)
|
.bind(&input.name)
|
||||||
.bind(input.description.as_deref())
|
.bind(input.description.as_deref())
|
||||||
.bind(&input.source)
|
.bind(&input.source)
|
||||||
.bind(input.timeout_seconds)
|
.bind(input.timeout_seconds)
|
||||||
.bind(input.memory_limit_mb)
|
.bind(input.memory_limit_mb)
|
||||||
|
.bind(sandbox_json)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -128,6 +139,13 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
// COALESCE-based partial update: `NULL` parameters leave columns
|
// COALESCE-based partial update: `NULL` parameters leave columns
|
||||||
// untouched. Description is double-Optioned so callers can
|
// untouched. Description is double-Optioned so callers can
|
||||||
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||||
|
// Sandbox is replaced wholesale when present; per-field merging
|
||||||
|
// happens in the API layer (clearer semantics for a "PUT a new
|
||||||
|
// sandbox config" call).
|
||||||
|
let sandbox_json = patch
|
||||||
|
.sandbox
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||||
let row = sqlx::query_as::<_, ScriptRow>(
|
let row = sqlx::query_as::<_, ScriptRow>(
|
||||||
"UPDATE scripts SET \
|
"UPDATE scripts SET \
|
||||||
name = COALESCE($2, name), \
|
name = COALESCE($2, name), \
|
||||||
@@ -135,11 +153,12 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
source = COALESCE($5, source), \
|
source = COALESCE($5, source), \
|
||||||
timeout_seconds = COALESCE($6, timeout_seconds), \
|
timeout_seconds = COALESCE($6, timeout_seconds), \
|
||||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||||
|
sandbox = COALESCE($8, sandbox), \
|
||||||
version = version + 1, \
|
version = version + 1, \
|
||||||
updated_at = NOW() \
|
updated_at = NOW() \
|
||||||
WHERE id = $1 \
|
WHERE id = $1 \
|
||||||
RETURNING id, name, description, version, source, \
|
RETURNING id, name, description, version, source, \
|
||||||
timeout_seconds, memory_limit_mb, created_at, updated_at",
|
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(id.into_inner())
|
.bind(id.into_inner())
|
||||||
.bind(patch.name.as_deref())
|
.bind(patch.name.as_deref())
|
||||||
@@ -148,6 +167,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
.bind(patch.source.as_deref())
|
.bind(patch.source.as_deref())
|
||||||
.bind(patch.timeout_seconds)
|
.bind(patch.timeout_seconds)
|
||||||
.bind(patch.memory_limit_mb)
|
.bind(patch.memory_limit_mb)
|
||||||
|
.bind(sandbox_json)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -177,12 +197,18 @@ struct ScriptRow {
|
|||||||
source: String,
|
source: String,
|
||||||
timeout_seconds: i32,
|
timeout_seconds: i32,
|
||||||
memory_limit_mb: i32,
|
memory_limit_mb: i32,
|
||||||
|
sandbox: serde_json::Value,
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ScriptRow> for Script {
|
impl From<ScriptRow> for Script {
|
||||||
fn from(r: ScriptRow) -> Self {
|
fn from(r: ScriptRow) -> Self {
|
||||||
|
// Tolerate stale rows whose sandbox column predates a future
|
||||||
|
// schema migration: unknown fields are rejected by serde, so
|
||||||
|
// fall back to an empty ScriptSandbox rather than poisoning a
|
||||||
|
// list response.
|
||||||
|
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
||||||
Self {
|
Self {
|
||||||
id: r.id.into(),
|
id: r.id.into(),
|
||||||
name: r.name,
|
name: r.name,
|
||||||
@@ -191,6 +217,7 @@ impl From<ScriptRow> for Script {
|
|||||||
source: r.source,
|
source: r.source,
|
||||||
timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30),
|
timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30),
|
||||||
memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256),
|
memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256),
|
||||||
|
sandbox,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
}
|
}
|
||||||
|
|||||||
103
crates/manager-core/src/sandbox.rs
Normal file
103
crates/manager-core/src/sandbox.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//! Admin-set ceiling for per-script sandbox overrides.
|
||||||
|
//!
|
||||||
|
//! The orchestrator-core's default `Limits` is what scripts get when they
|
||||||
|
//! don't override. The ceiling here is the per-field maximum that a
|
||||||
|
//! script's override is allowed to request. Validation runs at write
|
||||||
|
//! time so the executor can trust whatever's stored.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use picloud_shared::ScriptSandbox;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Maximum allowed value per sandbox knob. Loaded from env vars at
|
||||||
|
/// startup (with conservative built-in defaults). A `None` field means
|
||||||
|
/// "unbounded" — only useful if the operator explicitly clears the
|
||||||
|
/// ceiling for a given knob (it must still fit `u64`).
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct SandboxCeiling {
|
||||||
|
pub max_operations: u64,
|
||||||
|
pub max_string_size: u64,
|
||||||
|
pub max_array_size: u64,
|
||||||
|
pub max_map_size: u64,
|
||||||
|
pub max_call_levels: u64,
|
||||||
|
pub max_expr_depth: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SandboxCeiling {
|
||||||
|
/// Conservative built-in ceiling. Matches the executor's defaults —
|
||||||
|
/// scripts can request anything between zero and this, but no
|
||||||
|
/// higher. Operators can widen via env vars (see `from_env`).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn conservative() -> Self {
|
||||||
|
Self {
|
||||||
|
max_operations: 10_000_000,
|
||||||
|
max_string_size: 1024 * 1024, // 1 MiB
|
||||||
|
max_array_size: 100_000,
|
||||||
|
max_map_size: 100_000,
|
||||||
|
max_call_levels: 128,
|
||||||
|
max_expr_depth: 128,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read overrides from env vars, falling back to `conservative()`
|
||||||
|
/// for any unset knob. Invalid values are ignored with a warning
|
||||||
|
/// (we'd rather start with the conservative default than refuse to
|
||||||
|
/// boot on a typo).
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let mut c = Self::conservative();
|
||||||
|
macro_rules! load {
|
||||||
|
($field:ident, $key:expr) => {
|
||||||
|
if let Ok(v) = env::var($key) {
|
||||||
|
match v.parse::<u64>() {
|
||||||
|
Ok(n) => c.$field = n,
|
||||||
|
Err(e) => tracing::warn!(env = $key, error = %e, "ignoring invalid sandbox ceiling value"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
load!(max_operations, "PICLOUD_SANDBOX_MAX_OPERATIONS");
|
||||||
|
load!(max_string_size, "PICLOUD_SANDBOX_MAX_STRING_SIZE");
|
||||||
|
load!(max_array_size, "PICLOUD_SANDBOX_MAX_ARRAY_SIZE");
|
||||||
|
load!(max_map_size, "PICLOUD_SANDBOX_MAX_MAP_SIZE");
|
||||||
|
load!(max_call_levels, "PICLOUD_SANDBOX_MAX_CALL_LEVELS");
|
||||||
|
load!(max_expr_depth, "PICLOUD_SANDBOX_MAX_EXPR_DEPTH");
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `Err` if any override exceeds the ceiling on the same
|
||||||
|
/// field. Empty overrides (`ScriptSandbox::empty()`) always pass.
|
||||||
|
pub fn check(&self, s: &ScriptSandbox) -> Result<(), CeilingError> {
|
||||||
|
check_field("max_operations", s.max_operations, self.max_operations)?;
|
||||||
|
check_field("max_string_size", s.max_string_size, self.max_string_size)?;
|
||||||
|
check_field("max_array_size", s.max_array_size, self.max_array_size)?;
|
||||||
|
check_field("max_map_size", s.max_map_size, self.max_map_size)?;
|
||||||
|
check_field("max_call_levels", s.max_call_levels, self.max_call_levels)?;
|
||||||
|
check_field("max_expr_depth", s.max_expr_depth, self.max_expr_depth)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_field(name: &'static str, value: Option<u64>, ceiling: u64) -> Result<(), CeilingError> {
|
||||||
|
if let Some(v) = value {
|
||||||
|
if v > ceiling {
|
||||||
|
return Err(CeilingError::Exceeded {
|
||||||
|
field: name,
|
||||||
|
requested: v,
|
||||||
|
ceiling,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone)]
|
||||||
|
pub enum CeilingError {
|
||||||
|
#[error("sandbox override `{field}` = {requested} exceeds admin ceiling of {ceiling}")]
|
||||||
|
Exceeded {
|
||||||
|
field: &'static str,
|
||||||
|
requested: u64,
|
||||||
|
ceiling: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -73,7 +73,8 @@ where
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(ApiError::NotFound(id))?;
|
.ok_or(ApiError::NotFound(id))?;
|
||||||
|
|
||||||
let req = build_exec_request(id, &script.name, &headers, &body)?;
|
let mut req = build_exec_request(id, &script.name, &headers, &body)?;
|
||||||
|
req.sandbox_overrides = script.sandbox;
|
||||||
let request_id = req.request_id;
|
let request_id = req.request_id;
|
||||||
let request_path = req.path.clone();
|
let request_path = req.path.clone();
|
||||||
let request_headers = req.headers.clone();
|
let request_headers = req.headers.clone();
|
||||||
@@ -138,6 +139,8 @@ fn build_exec_request(
|
|||||||
path: format!("/api/execute/{id}"),
|
path: format!("/api/execute/{id}"),
|
||||||
headers: hmap,
|
headers: hmap,
|
||||||
body: body_json,
|
body: body_json,
|
||||||
|
// Overwritten by the handler after the script is resolved.
|
||||||
|
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use axum::{routing::get, Json, Router};
|
|||||||
use picloud_executor_core::{Engine, Limits};
|
use picloud_executor_core::{Engine, Limits};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, migrations, AdminState, PostgresExecutionLogRepository, PostgresExecutionLogSink,
|
admin_router, migrations, AdminState, PostgresExecutionLogRepository, PostgresExecutionLogSink,
|
||||||
PostgresScriptRepository, RepoResolver,
|
PostgresScriptRepository, RepoResolver, SandboxCeiling,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::{data_plane_router, DataPlaneState, LocalExecutorClient};
|
use picloud_orchestrator_core::{data_plane_router, DataPlaneState, LocalExecutorClient};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
@@ -43,6 +43,7 @@ pub fn build_app(pool: PgPool) -> Router {
|
|||||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
repo: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||||
logs: log_repo,
|
logs: log_repo,
|
||||||
validator: engine as Arc<dyn ScriptValidator>,
|
validator: engine as Arc<dyn ScriptValidator>,
|
||||||
|
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||||
};
|
};
|
||||||
let data_plane = DataPlaneState {
|
let data_plane = DataPlaneState {
|
||||||
executor,
|
executor,
|
||||||
|
|||||||
@@ -283,6 +283,144 @@ async fn execution_logs_capture_invocations(pool: PgPool) {
|
|||||||
assert_eq!(entries[0]["data"], json!({ "marker": 7 }));
|
assert_eq!(entries[0]["data"], json!({ "marker": 7 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Sandbox overrides
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
|
||||||
|
let s = server(pool);
|
||||||
|
let created: Value = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&json!({ "name": "no-sandbox", "source": "1" }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(created["sandbox"], json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
|
||||||
|
let s = server(pool);
|
||||||
|
let created: Value = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&json!({
|
||||||
|
"name": "tight",
|
||||||
|
"source": "1",
|
||||||
|
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(
|
||||||
|
created["sandbox"],
|
||||||
|
json!({ "max_operations": 500, "max_string_size": 1024 })
|
||||||
|
);
|
||||||
|
|
||||||
|
let id = created["id"].as_str().unwrap();
|
||||||
|
let fetched: Value = s.get(&format!("/api/v1/admin/scripts/{id}")).await.json();
|
||||||
|
assert_eq!(
|
||||||
|
fetched["sandbox"],
|
||||||
|
json!({ "max_operations": 500, "max_string_size": 1024 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
|
||||||
|
// Default conservative ceiling caps max_operations at 10_000_000.
|
||||||
|
let s = server(pool);
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&json!({
|
||||||
|
"name": "too-loose",
|
||||||
|
"source": "1",
|
||||||
|
"sandbox": { "max_operations": 100_000_000 }
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert!(body["error"].as_str().unwrap().contains("max_operations"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
|
||||||
|
let s = server(pool);
|
||||||
|
let r = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&json!({
|
||||||
|
"name": "typo",
|
||||||
|
"source": "1",
|
||||||
|
"sandbox": { "max_operashuns": 500 }
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
// serde's deny_unknown_fields causes axum to reject with 422 or
|
||||||
|
// 400 depending on extractor; the routing is irrelevant here, just
|
||||||
|
// that it doesn't get stored silently.
|
||||||
|
assert!(
|
||||||
|
r.status_code() == axum::http::StatusCode::UNPROCESSABLE_ENTITY
|
||||||
|
|| r.status_code() == axum::http::StatusCode::BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
|
||||||
|
let s = server(pool);
|
||||||
|
// Tight max_operations on a loop the default would happily run.
|
||||||
|
let created: Value = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&json!({
|
||||||
|
"name": "tight-exec",
|
||||||
|
"source": "let n = 0; for i in 0..10000 { n += 1; } n",
|
||||||
|
"sandbox": { "max_operations": 500 }
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = created["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
let r = s
|
||||||
|
.post(&format!("/api/v1/execute/{id}"))
|
||||||
|
.json(&json!({}))
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::INSUFFICIENT_STORAGE);
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert!(body["error"].as_str().unwrap().contains("operation budget"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
|
||||||
|
let s = server(pool);
|
||||||
|
let created: Value = s
|
||||||
|
.post("/api/v1/admin/scripts")
|
||||||
|
.json(&json!({
|
||||||
|
"name": "patch-target",
|
||||||
|
"source": "1",
|
||||||
|
"sandbox": { "max_operations": 500, "max_string_size": 1024 }
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
let id = created["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// Replace with a single override; the other field disappears.
|
||||||
|
let updated: Value = s
|
||||||
|
.put(&format!("/api/v1/admin/scripts/{id}"))
|
||||||
|
.json(&json!({ "sandbox": { "max_array_size": 5000 } }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(updated["sandbox"], json!({ "max_array_size": 5000 }));
|
||||||
|
|
||||||
|
// Send empty object to clear all overrides.
|
||||||
|
let cleared: Value = s
|
||||||
|
.put(&format!("/api/v1/admin/scripts/{id}"))
|
||||||
|
.json(&json!({ "sandbox": {} }))
|
||||||
|
.await
|
||||||
|
.json();
|
||||||
|
assert_eq!(cleared["sandbox"], json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn execution_errors_are_still_logged(pool: PgPool) {
|
async fn execution_errors_are_still_logged(pool: PgPool) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod error;
|
|||||||
pub mod execution_log;
|
pub mod execution_log;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
|
pub mod sandbox;
|
||||||
pub mod script;
|
pub mod script;
|
||||||
pub mod validator;
|
pub mod validator;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
@@ -16,6 +17,7 @@ pub use error::Error;
|
|||||||
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
pub use execution_log::{ExecutionLog, ExecutionStatus};
|
||||||
pub use ids::{ExecutionId, RequestId, ScriptId};
|
pub use ids::{ExecutionId, RequestId, ScriptId};
|
||||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||||
|
pub use sandbox::ScriptSandbox;
|
||||||
pub use script::Script;
|
pub use script::Script;
|
||||||
pub use validator::{ScriptValidator, ValidationError};
|
pub use validator::{ScriptValidator, ValidationError};
|
||||||
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
||||||
|
|||||||
58
crates/shared/src/sandbox.rs
Normal file
58
crates/shared/src/sandbox.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//! Per-script overrides for the Rhai sandbox limits.
|
||||||
|
//!
|
||||||
|
//! All fields are optional: `None` means "use the executor's platform
|
||||||
|
//! default for this knob". Stored as JSONB in the `scripts.sandbox`
|
||||||
|
//! column so adding new knobs in the future is a code-only change.
|
||||||
|
//!
|
||||||
|
//! Values are clamped against the admin-set ceiling at write time
|
||||||
|
//! (in `manager-core`). The executor trusts what's stored: it merges
|
||||||
|
//! defaults + overrides per call and applies the result.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ScriptSandbox {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_operations: Option<u64>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_string_size: Option<u64>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_array_size: Option<u64>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_map_size: Option<u64>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_call_levels: Option<u64>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_expr_depth: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptSandbox {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
max_operations: None,
|
||||||
|
max_string_size: None,
|
||||||
|
max_array_size: None,
|
||||||
|
max_map_size: None,
|
||||||
|
max_call_levels: None,
|
||||||
|
max_expr_depth: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if every field is `None` (no overrides).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_empty(&self) -> bool {
|
||||||
|
self.max_operations.is_none()
|
||||||
|
&& self.max_string_size.is_none()
|
||||||
|
&& self.max_array_size.is_none()
|
||||||
|
&& self.max_map_size.is_none()
|
||||||
|
&& self.max_call_levels.is_none()
|
||||||
|
&& self.max_expr_depth.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::ScriptId;
|
use crate::{ScriptId, ScriptSandbox};
|
||||||
|
|
||||||
/// A user-uploaded Rhai script and its execution configuration.
|
/// A user-uploaded Rhai script and its execution configuration.
|
||||||
///
|
///
|
||||||
@@ -17,6 +17,16 @@ pub struct Script {
|
|||||||
pub source: String,
|
pub source: String,
|
||||||
|
|
||||||
pub timeout_seconds: u32,
|
pub timeout_seconds: u32,
|
||||||
|
|
||||||
|
/// Per-script overrides for Rhai sandbox limits. Empty = platform
|
||||||
|
/// defaults. Values are admin-ceiling-clamped at write time.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sandbox: ScriptSandbox,
|
||||||
|
|
||||||
|
/// **v1.3+ advisory only.** Real heap limits require OS-level
|
||||||
|
/// isolation (cgroups, process limits) which lands with cluster-mode
|
||||||
|
/// executor sandboxing. The field stays in the schema so we don't
|
||||||
|
/// have to add it back when that's built.
|
||||||
pub memory_limit_mb: u32,
|
pub memory_limit_mb: u32,
|
||||||
|
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picloud-dashboard",
|
"name": "picloud-dashboard",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -124,10 +124,10 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
|
|||||||
|
|
||||||
| | Version |
|
| | Version |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Product | `0.2.0` |
|
| Product | `0.3.0` |
|
||||||
| SDK | `1.0` |
|
| SDK | `1.0` |
|
||||||
| API | `1` |
|
| API | `1` |
|
||||||
| Schema | `1` (matches `migrations/0001_init.sql`) |
|
| Schema | `2` (matches `migrations/0002_sandbox.sql`) |
|
||||||
| Wire | `1` (reserved; cluster mode not implemented) |
|
| Wire | `1` (reserved; cluster mode not implemented) |
|
||||||
|
|
||||||
Read live from `GET /version` on any running instance.
|
Read live from `GET /version` on any running instance.
|
||||||
|
|||||||
Reference in New Issue
Block a user