feat(sandbox): model execution outcomes and limit attribution
This commit is contained in:
+27
-1
@@ -50,4 +50,30 @@ cargo run --locked -p kf-sandbox -- validate --spec crates/sandbox/tests/fixture
|
||||
环境变量仅接受 `LANG`/`LC_ALL` 的 `C`、`C.UTF-8` 和 `TZ=UTC`。
|
||||
|
||||
这些检查尚未验证工件归属、实际文件类型、符号链接、身份与隔离能力;初始化器必须继续完成这些检查,
|
||||
保留独立 VM 中的执行、安全和资源验收。执行结果、限额归因与清理状态的契约将在后续阶段补齐。
|
||||
保留独立 VM 中的执行、安全和资源验收。
|
||||
|
||||
## 本地执行结果与归因
|
||||
|
||||
`outcome.rs` 定义可信监督器的本地结果记录,含退出码/信号、计量、输出工件、启动阶段错误、
|
||||
取消、限额观察及独立清理状态。`ExecutionReport::classify` 校验记录与已校验规格的一致性,
|
||||
返回执行分类;退出码 0 仅代表程序正常退出,Accepted 仍由引擎比较输出后确定。
|
||||
这些类型尚未接入实际监督器,也没有改变平台跨服务协议。
|
||||
|
||||
计量必须显式区分 `measured` 与 `unavailable`,读取失败、计数器重置及非法数据不能当作零。
|
||||
CPU 和内存统计覆盖从纳入任务 cgroup 开始的初始化、线程和后代;CPU 不扣除估算的初始化费用。
|
||||
墙钟使用 exec 放行至确认任务树为空并完成最终快照之间的单调时间。
|
||||
|
||||
主归因先报告基础设施/计量/清理故障,同时保留全部限额证据;正常监督下按首次观察时间选择
|
||||
取消或限额,相同时取消优先,限额依次为内存、CPU、墙钟、进程、stdout、stderr、文件和工作目录。
|
||||
这是监督器观察顺序,不能宣称是内核事件的真实先后。重复限额、越界时间和不支持的证据拒绝分类。
|
||||
|
||||
SIGKILL、内存峰值或单独的 `oom_kill` 不会推断任务内存超限。初版内存归因要求可信采集器核对
|
||||
任务 cgroup 的配置及基线,再提供 `max`、`oom`、`oom_kill`、`oom_group_kill` 的正增量;
|
||||
计数器在相同任务 cgroup 上采集,覆盖其后代。
|
||||
参见 [cgroup v2 内存事件](https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files)。
|
||||
FD、栈和其他信号仍按原退出状态记录,缺少专门证据时不猜测原因。
|
||||
|
||||
输出保留字节数受限,`observed_bytes` 在截断时只是已读下界,`reached_eof` 单独记录。
|
||||
超限输出须有对应证据;缺失计量、无说明的截断或不完整读取报告基础设施失败。
|
||||
工件只能导出规格已申请的路径,单文件与已导出总量均受限;这些字段检查不能替代实际文件安全检查。
|
||||
准备失败和清理失败分别保留,真实进程树终止、资源回收与 VM 验收仍待后续阶段实现。
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
//! JS-01 defines execution contracts; JS-02..04 add the launcher, isolation and limits.
|
||||
//! Until that work is verified, the CLI cannot execute any user program.
|
||||
|
||||
pub mod outcome;
|
||||
pub mod probe;
|
||||
pub mod spec;
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Local supervisor observations, not a judge verdict or execution authority.
|
||||
//! Only trusted collectors may supply these records; schema validation cannot
|
||||
//! authenticate a cgroup, a counter delta, or an output artifact.
|
||||
|
||||
use crate::spec::{ResourceLimits, ValidatedSpec};
|
||||
use kf_contracts::SchemaVersion;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Measurement<T> {
|
||||
Measured { value: T },
|
||||
Unavailable { reason: MeasurementFailure },
|
||||
}
|
||||
|
||||
impl<T> Measurement<T> {
|
||||
fn value(&self) -> Option<&T> {
|
||||
match self {
|
||||
Self::Measured { value } => Some(value),
|
||||
Self::Unavailable { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MeasurementFailure {
|
||||
NotStarted,
|
||||
ReadFailed,
|
||||
CounterReset,
|
||||
InvalidData,
|
||||
}
|
||||
|
||||
/// Checked differences from the fresh task cgroup's enrollment baseline.
|
||||
/// oom_kill alone includes global OOM and is deliberately not sufficient.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MemoryEvents {
|
||||
pub max: u64,
|
||||
pub oom: u64,
|
||||
pub oom_kill: u64,
|
||||
pub oom_group_kill: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResourceUsage {
|
||||
/// Entire task cgroup from enrollment, including sandbox initialization
|
||||
/// and descendants; never subtract estimated initialization overhead.
|
||||
pub cpu_time_us: Measurement<u64>,
|
||||
/// Monotonic time from opening the exec gate through confirming an empty
|
||||
/// task tree and taking its final accounting snapshot.
|
||||
pub wall_time_us: Measurement<u64>,
|
||||
pub memory_peak_bytes: Measurement<u64>,
|
||||
pub memory_events: Measurement<MemoryEvents>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Termination {
|
||||
NotStarted,
|
||||
Exited { code: u8 },
|
||||
Signaled { signal: u8, core_dumped: bool },
|
||||
WaitFailed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionStage {
|
||||
Validation,
|
||||
Preparation,
|
||||
Cgroup,
|
||||
Namespaces,
|
||||
RootFilesystem,
|
||||
Identity,
|
||||
Seccomp,
|
||||
Exec,
|
||||
Wait,
|
||||
Accounting,
|
||||
Output,
|
||||
ArtifactExport,
|
||||
Cleanup,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorCode {
|
||||
CapabilityUnavailable,
|
||||
OperationFailed,
|
||||
ProtocolFailed,
|
||||
MeasurementUnavailable,
|
||||
OutputReadFailed,
|
||||
ArtifactExportFailed,
|
||||
CleanupIncomplete,
|
||||
}
|
||||
|
||||
/// Stable codes only; no host path, input content or unbounded error message.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExecutionError {
|
||||
pub stage: ExecutionStage,
|
||||
pub code: ErrorCode,
|
||||
pub errno: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CleanupState {
|
||||
NotNeeded,
|
||||
Complete,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OutputCapture {
|
||||
pub artifact_id: Option<String>,
|
||||
/// Lower bound when collection stopped; not an invented final total.
|
||||
pub observed_bytes: Measurement<u64>,
|
||||
pub retained_bytes: u64,
|
||||
pub truncated: bool,
|
||||
pub reached_eof: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OutputArtifact {
|
||||
pub path: String,
|
||||
pub artifact_id: String,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// Declaration order is the tie-break for equal observation timestamps.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LimitKind {
|
||||
Memory,
|
||||
CpuTime,
|
||||
WallTime,
|
||||
Processes,
|
||||
Stdout,
|
||||
Stderr,
|
||||
FileSize,
|
||||
WorkdirSize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum LimitEvidence {
|
||||
/// Requires task-local memory.max OOM and group-kill counter increments;
|
||||
/// the task must have verified memory.oom.group=1 and its configured cap.
|
||||
Memory,
|
||||
CpuTime {
|
||||
observed_us: u64,
|
||||
},
|
||||
WallTime {
|
||||
observed_us: u64,
|
||||
},
|
||||
Processes {
|
||||
max_events_delta: u64,
|
||||
},
|
||||
Stdout {
|
||||
observed_bytes: u64,
|
||||
},
|
||||
Stderr {
|
||||
observed_bytes: u64,
|
||||
},
|
||||
FileSize {
|
||||
observed_bytes: u64,
|
||||
},
|
||||
WorkdirSize {
|
||||
observed_bytes: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl LimitEvidence {
|
||||
pub fn kind(&self) -> LimitKind {
|
||||
match self {
|
||||
Self::Memory => LimitKind::Memory,
|
||||
Self::CpuTime { .. } => LimitKind::CpuTime,
|
||||
Self::WallTime { .. } => LimitKind::WallTime,
|
||||
Self::Processes { .. } => LimitKind::Processes,
|
||||
Self::Stdout { .. } => LimitKind::Stdout,
|
||||
Self::Stderr { .. } => LimitKind::Stderr,
|
||||
Self::FileSize { .. } => LimitKind::FileSize,
|
||||
Self::WorkdirSize { .. } => LimitKind::WorkdirSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LimitObservation {
|
||||
/// First supervisor observation after exec-gate release, not estimated
|
||||
/// kernel event time; post-exit counter evidence uses its read timestamp.
|
||||
pub at_wall_time_us: u64,
|
||||
pub evidence: LimitEvidence,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExecutionReport {
|
||||
pub schema_version: SchemaVersion,
|
||||
pub run_id: String,
|
||||
pub termination: Termination,
|
||||
pub usage: ResourceUsage,
|
||||
pub stdout: OutputCapture,
|
||||
pub stderr: OutputCapture,
|
||||
pub artifacts: Vec<OutputArtifact>,
|
||||
pub limits: Vec<LimitObservation>,
|
||||
pub cancelled_at_wall_time_us: Option<u64>,
|
||||
pub errors: Vec<ExecutionError>,
|
||||
pub cleanup: CleanupState,
|
||||
}
|
||||
|
||||
/// Execution classification only. There is intentionally no Accepted variant.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ExecutionOutcome {
|
||||
NotStarted,
|
||||
InfrastructureFailure,
|
||||
Cancelled,
|
||||
LimitExceeded { primary: LimitKind },
|
||||
Exited { code: u8 },
|
||||
Signaled { signal: u8, core_dumped: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ReportError(pub &'static str);
|
||||
|
||||
impl std::fmt::Display for ReportError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ReportError {}
|
||||
|
||||
impl ExecutionReport {
|
||||
/// Consistency checks on trusted observations. This does not verify OS
|
||||
/// isolation and never changes the CLI's refusal to execute.
|
||||
pub fn classify(&self, spec: &ValidatedSpec) -> Result<ExecutionOutcome, ReportError> {
|
||||
let limits = &spec.spec().limits;
|
||||
if self.run_id != spec.spec().run_id || self.errors.len() > 16 || self.limits.len() > 8 {
|
||||
return Err(ReportError("report_identity_or_size"));
|
||||
}
|
||||
if self
|
||||
.errors
|
||||
.iter()
|
||||
.any(|error| error.errno.is_some_and(|errno| errno == 0 || errno > 4095))
|
||||
{
|
||||
return Err(ReportError("errno"));
|
||||
}
|
||||
if matches!(
|
||||
self.termination,
|
||||
Termination::Signaled {
|
||||
signal: 0 | 65..,
|
||||
..
|
||||
}
|
||||
) {
|
||||
return Err(ReportError("signal"));
|
||||
}
|
||||
if (self.cleanup == CleanupState::Failed)
|
||||
!= self
|
||||
.errors
|
||||
.iter()
|
||||
.any(|error| error.stage == ExecutionStage::Cleanup)
|
||||
{
|
||||
return Err(ReportError("cleanup"));
|
||||
}
|
||||
check_capture(&self.stdout, limits.stdout_bytes)?;
|
||||
check_capture(&self.stderr, limits.stderr_bytes)?;
|
||||
let mut artifact_paths = std::collections::BTreeSet::new();
|
||||
let mut artifact_bytes = 0_u64;
|
||||
for artifact in &self.artifacts {
|
||||
artifact_bytes = artifact_bytes
|
||||
.checked_add(artifact.bytes)
|
||||
.ok_or(ReportError("artifacts"))?;
|
||||
if !spec.spec().outputs.contains(&artifact.path)
|
||||
|| !artifact_paths.insert(artifact.path.as_str())
|
||||
|| !artifact_id(&artifact.artifact_id)
|
||||
|| artifact.bytes > limits.file_bytes
|
||||
|| artifact_bytes > limits.workdir_bytes
|
||||
{
|
||||
return Err(ReportError("artifacts"));
|
||||
}
|
||||
}
|
||||
let mut observed_kinds = std::collections::BTreeSet::new();
|
||||
for observation in &self.limits {
|
||||
if !observed_kinds.insert(observation.evidence.kind())
|
||||
|| !self.within_wall_time(observation.at_wall_time_us)
|
||||
|| matches!(observation.evidence, LimitEvidence::WallTime { observed_us }
|
||||
if observed_us > observation.at_wall_time_us)
|
||||
|| !self.confirmed(&observation.evidence, limits)
|
||||
{
|
||||
return Err(ReportError("limit_evidence"));
|
||||
}
|
||||
}
|
||||
if self
|
||||
.cancelled_at_wall_time_us
|
||||
.is_some_and(|time| !self.within_wall_time(time))
|
||||
{
|
||||
return Err(ReportError("cancellation_time"));
|
||||
}
|
||||
if self.termination == Termination::NotStarted {
|
||||
if !self.limits.is_empty()
|
||||
|| self.cancelled_at_wall_time_us.is_some()
|
||||
|| !self.artifacts.is_empty()
|
||||
|| self.stdout.retained_bytes != 0
|
||||
|| self.stderr.retained_bytes != 0
|
||||
|| self
|
||||
.stdout
|
||||
.observed_bytes
|
||||
.value()
|
||||
.is_some_and(|value| *value != 0)
|
||||
|| self
|
||||
.stderr
|
||||
.observed_bytes
|
||||
.value()
|
||||
.is_some_and(|value| *value != 0)
|
||||
{
|
||||
return Err(ReportError("not_started"));
|
||||
}
|
||||
return if !self.errors.iter().any(|error| {
|
||||
matches!(
|
||||
error.stage,
|
||||
ExecutionStage::Validation
|
||||
| ExecutionStage::Preparation
|
||||
| ExecutionStage::Cgroup
|
||||
| ExecutionStage::Namespaces
|
||||
| ExecutionStage::RootFilesystem
|
||||
| ExecutionStage::Identity
|
||||
| ExecutionStage::Seccomp
|
||||
| ExecutionStage::Exec
|
||||
)
|
||||
}) {
|
||||
Err(ReportError("missing_start_error"))
|
||||
} else if self.cleanup == CleanupState::Failed {
|
||||
Ok(ExecutionOutcome::InfrastructureFailure)
|
||||
} else {
|
||||
Ok(ExecutionOutcome::NotStarted)
|
||||
};
|
||||
}
|
||||
if self.cleanup == CleanupState::NotNeeded {
|
||||
return Err(ReportError("cleanup"));
|
||||
}
|
||||
// Collector/cleanup failure takes precedence, while the report retains
|
||||
// all observed limits, termination and outputs for diagnosis.
|
||||
if !self.errors.is_empty()
|
||||
|| self.termination == Termination::WaitFailed
|
||||
|| self.usage.cpu_time_us.value().is_none()
|
||||
|| self.usage.wall_time_us.value().is_none()
|
||||
|| self.usage.memory_peak_bytes.value().is_none()
|
||||
|| self.usage.memory_events.value().is_none()
|
||||
|| self.stdout.observed_bytes.value().is_none()
|
||||
|| self.stderr.observed_bytes.value().is_none()
|
||||
|| !self.stdout.reached_eof && !self.stdout.truncated
|
||||
|| !self.stderr.reached_eof && !self.stderr.truncated
|
||||
|| (self.stdout.truncated || self.stderr.truncated)
|
||||
&& self.limits.is_empty()
|
||||
&& self.cancelled_at_wall_time_us.is_none()
|
||||
|| self
|
||||
.stdout
|
||||
.observed_bytes
|
||||
.value()
|
||||
.is_some_and(|bytes| *bytes > limits.stdout_bytes)
|
||||
&& !observed_kinds.contains(&LimitKind::Stdout)
|
||||
|| self
|
||||
.stderr
|
||||
.observed_bytes
|
||||
.value()
|
||||
.is_some_and(|bytes| *bytes > limits.stderr_bytes)
|
||||
&& !observed_kinds.contains(&LimitKind::Stderr)
|
||||
{
|
||||
return Ok(ExecutionOutcome::InfrastructureFailure);
|
||||
}
|
||||
let primary = self
|
||||
.limits
|
||||
.iter()
|
||||
.min_by_key(|observation| (observation.at_wall_time_us, observation.evidence.kind()));
|
||||
// Cancellation wins an exact-time tie with a limit. No later cleanup
|
||||
// kill signal may replace the reason first observed by supervision.
|
||||
if self
|
||||
.cancelled_at_wall_time_us
|
||||
.is_some_and(|cancelled| primary.is_none_or(|limit| cancelled <= limit.at_wall_time_us))
|
||||
{
|
||||
return Ok(ExecutionOutcome::Cancelled);
|
||||
}
|
||||
if let Some(primary) = primary {
|
||||
return Ok(ExecutionOutcome::LimitExceeded {
|
||||
primary: primary.evidence.kind(),
|
||||
});
|
||||
}
|
||||
Ok(match self.termination {
|
||||
Termination::Exited { code } => ExecutionOutcome::Exited { code },
|
||||
Termination::Signaled {
|
||||
signal,
|
||||
core_dumped,
|
||||
} => ExecutionOutcome::Signaled {
|
||||
signal,
|
||||
core_dumped,
|
||||
},
|
||||
Termination::NotStarted | Termination::WaitFailed => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
fn within_wall_time(&self, time: u64) -> bool {
|
||||
self.usage
|
||||
.wall_time_us
|
||||
.value()
|
||||
.is_some_and(|wall| time <= *wall)
|
||||
}
|
||||
|
||||
fn confirmed(&self, evidence: &LimitEvidence, limits: &ResourceLimits) -> bool {
|
||||
match *evidence {
|
||||
LimitEvidence::Memory => self.usage.memory_events.value().is_some_and(|events| {
|
||||
events.max > 0 && events.oom > 0 && events.oom_group_kill > 0 && events.oom_kill > 0
|
||||
}),
|
||||
LimitEvidence::CpuTime { observed_us } => {
|
||||
observed_us >= limits.cpu_time_us
|
||||
&& self
|
||||
.usage
|
||||
.cpu_time_us
|
||||
.value()
|
||||
.is_some_and(|total| observed_us <= *total)
|
||||
}
|
||||
LimitEvidence::WallTime { observed_us } => {
|
||||
observed_us >= limits.wall_time_us
|
||||
&& self
|
||||
.usage
|
||||
.wall_time_us
|
||||
.value()
|
||||
.is_some_and(|total| observed_us <= *total)
|
||||
}
|
||||
LimitEvidence::Processes { max_events_delta } => max_events_delta > 0,
|
||||
LimitEvidence::Stdout { observed_bytes } => {
|
||||
observed_bytes > limits.stdout_bytes
|
||||
&& self
|
||||
.stdout
|
||||
.observed_bytes
|
||||
.value()
|
||||
.is_some_and(|total| observed_bytes <= *total)
|
||||
}
|
||||
LimitEvidence::Stderr { observed_bytes } => {
|
||||
observed_bytes > limits.stderr_bytes
|
||||
&& self
|
||||
.stderr
|
||||
.observed_bytes
|
||||
.value()
|
||||
.is_some_and(|total| observed_bytes <= *total)
|
||||
}
|
||||
LimitEvidence::FileSize { observed_bytes } => observed_bytes > limits.file_bytes,
|
||||
LimitEvidence::WorkdirSize { observed_bytes } => observed_bytes > limits.workdir_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn artifact_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
}
|
||||
|
||||
fn check_capture(capture: &OutputCapture, limit: u64) -> Result<(), ReportError> {
|
||||
if capture.retained_bytes > limit
|
||||
|| capture
|
||||
.artifact_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| !artifact_id(id))
|
||||
|| capture.retained_bytes > 0 && capture.artifact_id.is_none()
|
||||
|| capture.observed_bytes.value().is_some_and(|observed| {
|
||||
capture.retained_bytes > *observed
|
||||
|| (capture.retained_bytes < *observed && !capture.truncated)
|
||||
|| (*observed > limit && !capture.truncated)
|
||||
})
|
||||
{
|
||||
return Err(ReportError("output_capture"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"run_id": "run-001",
|
||||
"termination": {"kind": "exited", "code": 0},
|
||||
"usage": {
|
||||
"cpu_time_us": {"state": "measured", "value": 1000},
|
||||
"wall_time_us": {"state": "measured", "value": 2000},
|
||||
"memory_peak_bytes": {"state": "measured", "value": 4096},
|
||||
"memory_events": {"state": "measured", "value": {"max": 0, "oom": 0, "oom_kill": 0, "oom_group_kill": 0}}
|
||||
},
|
||||
"stdout": {"artifact_id": null, "observed_bytes": {"state": "measured", "value": 0}, "retained_bytes": 0, "truncated": false, "reached_eof": true},
|
||||
"stderr": {"artifact_id": null, "observed_bytes": {"state": "measured", "value": 0}, "retained_bytes": 0, "truncated": false, "reached_eof": true},
|
||||
"artifacts": [],
|
||||
"limits": [],
|
||||
"cancelled_at_wall_time_us": null,
|
||||
"errors": [],
|
||||
"cleanup": "complete"
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
use kf_sandbox::outcome::*;
|
||||
use kf_sandbox::spec::{ExecutionSpec, SpecPolicy, ValidatedSpec};
|
||||
|
||||
fn spec() -> ValidatedSpec {
|
||||
serde_json::from_str::<ExecutionSpec>(include_str!("fixtures/spec.valid.json"))
|
||||
.unwrap()
|
||||
.validate(&SpecPolicy::default())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn report() -> ExecutionReport {
|
||||
serde_json::from_str(include_str!("fixtures/outcome.exited.json")).unwrap()
|
||||
}
|
||||
|
||||
fn measured<T>(value: T) -> Measurement<T> {
|
||||
Measurement::Measured { value }
|
||||
}
|
||||
|
||||
fn unavailable<T>() -> Measurement<T> {
|
||||
Measurement::Unavailable {
|
||||
reason: MeasurementFailure::ReadFailed,
|
||||
}
|
||||
}
|
||||
|
||||
fn observation(time: u64, evidence: LimitEvidence) -> LimitObservation {
|
||||
LimitObservation {
|
||||
at_wall_time_us: time,
|
||||
evidence,
|
||||
}
|
||||
}
|
||||
|
||||
fn error(stage: ExecutionStage) -> ExecutionError {
|
||||
ExecutionError {
|
||||
stage,
|
||||
code: ErrorCode::OperationFailed,
|
||||
errno: Some(5),
|
||||
}
|
||||
}
|
||||
|
||||
fn cpu_limited() -> ExecutionReport {
|
||||
let mut result = report();
|
||||
result.usage.cpu_time_us = measured(spec().spec().limits.cpu_time_us);
|
||||
result.limits.push(observation(
|
||||
1000,
|
||||
LimitEvidence::CpuTime {
|
||||
observed_us: spec().spec().limits.cpu_time_us,
|
||||
},
|
||||
));
|
||||
result.termination = Termination::Signaled {
|
||||
signal: 9,
|
||||
core_dumped: false,
|
||||
};
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exited_fixture_roundtrips_without_a_judge_verdict() {
|
||||
let mut result = report();
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::Exited { code: 0 })
|
||||
);
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(!json.contains("accepted"));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ExecutionReport>(&json).unwrap(),
|
||||
result
|
||||
);
|
||||
result.termination = Termination::Exited { code: 23 };
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::Exited { code: 23 })
|
||||
);
|
||||
result.termination = Termination::Signaled {
|
||||
signal: 11,
|
||||
core_dumped: true,
|
||||
};
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::Signaled {
|
||||
signal: 11,
|
||||
core_dumped: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_versions_states_and_out_of_range_exit_codes_are_rejected() {
|
||||
let valid = serde_json::to_value(report()).unwrap();
|
||||
for (pointer, value) in [
|
||||
("/schema_version", serde_json::json!(2)),
|
||||
("/termination/kind", serde_json::json!("accepted")),
|
||||
("/termination/code", serde_json::json!(256)),
|
||||
("/usage/cpu_time_us/state", serde_json::json!("estimated")),
|
||||
("/usage/memory_peak_bytes/value", serde_json::json!(-1)),
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
*invalid.pointer_mut(pointer).unwrap() = value;
|
||||
assert!(
|
||||
serde_json::from_value::<ExecutionReport>(invalid).is_err(),
|
||||
"{pointer}"
|
||||
);
|
||||
}
|
||||
let mut invalid = valid.clone();
|
||||
invalid["usage"]["host_path"] = serde_json::json!("/secret");
|
||||
assert!(serde_json::from_value::<ExecutionReport>(invalid).is_err());
|
||||
let mut invalid = valid;
|
||||
invalid["stdout"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("observed_bytes");
|
||||
assert!(serde_json::from_value::<ExecutionReport>(invalid).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_measurements_are_infrastructure_failures_instead_of_zero_usage() {
|
||||
for field in 0..6 {
|
||||
let mut result = report();
|
||||
match field {
|
||||
0 => result.usage.cpu_time_us = unavailable(),
|
||||
1 => result.usage.wall_time_us = unavailable(),
|
||||
2 => result.usage.memory_peak_bytes = unavailable(),
|
||||
3 => {
|
||||
result.usage.memory_events = Measurement::Unavailable {
|
||||
reason: MeasurementFailure::CounterReset,
|
||||
}
|
||||
}
|
||||
4 => result.stdout.observed_bytes = unavailable(),
|
||||
_ => result.stderr.observed_bytes = unavailable(),
|
||||
}
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::InfrastructureFailure)
|
||||
);
|
||||
assert!(
|
||||
serde_json::to_string(&result)
|
||||
.unwrap()
|
||||
.contains("unavailable")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigkill_and_global_oom_counter_alone_do_not_prove_task_memory_limit() {
|
||||
let mut result = report();
|
||||
result.termination = Termination::Signaled {
|
||||
signal: 9,
|
||||
core_dumped: false,
|
||||
};
|
||||
result.usage.memory_events = measured(MemoryEvents {
|
||||
max: 0,
|
||||
oom: 0,
|
||||
oom_kill: 1,
|
||||
oom_group_kill: 0,
|
||||
});
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::Signaled {
|
||||
signal: 9,
|
||||
core_dumped: false
|
||||
})
|
||||
);
|
||||
result.limits.push(observation(1000, LimitEvidence::Memory));
|
||||
assert_eq!(result.classify(&spec()), Err(ReportError("limit_evidence")));
|
||||
result.usage.memory_events = measured(MemoryEvents {
|
||||
max: 1,
|
||||
oom: 1,
|
||||
oom_kill: 1,
|
||||
oom_group_kill: 1,
|
||||
});
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::LimitExceeded {
|
||||
primary: LimitKind::Memory
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_limits_use_first_observation_then_stable_tie_break_and_keep_evidence() {
|
||||
let mut result = cpu_limited();
|
||||
let wall_limit = spec().spec().limits.wall_time_us;
|
||||
result.usage.wall_time_us = measured(wall_limit + 2);
|
||||
result.limits[0].at_wall_time_us = wall_limit + 1;
|
||||
result.limits.push(observation(
|
||||
wall_limit,
|
||||
LimitEvidence::WallTime {
|
||||
observed_us: spec().spec().limits.wall_time_us,
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::LimitExceeded {
|
||||
primary: LimitKind::WallTime
|
||||
})
|
||||
);
|
||||
result.limits[1].at_wall_time_us = wall_limit + 1;
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::LimitExceeded {
|
||||
primary: LimitKind::CpuTime
|
||||
})
|
||||
);
|
||||
result.limits.reverse();
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::LimitExceeded {
|
||||
primary: LimitKind::CpuTime
|
||||
})
|
||||
);
|
||||
assert_eq!(result.limits.len(), 2);
|
||||
assert!(matches!(
|
||||
result.termination,
|
||||
Termination::Signaled { signal: 9, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_wins_before_or_at_limit_but_cannot_replace_an_earlier_limit() {
|
||||
let mut result = cpu_limited();
|
||||
for (time, expected) in [
|
||||
(999, ExecutionOutcome::Cancelled),
|
||||
(1000, ExecutionOutcome::Cancelled),
|
||||
(
|
||||
1001,
|
||||
ExecutionOutcome::LimitExceeded {
|
||||
primary: LimitKind::CpuTime,
|
||||
},
|
||||
),
|
||||
] {
|
||||
result.cancelled_at_wall_time_us = Some(time);
|
||||
assert_eq!(result.classify(&spec()), Ok(expected));
|
||||
}
|
||||
result.cancelled_at_wall_time_us = Some(2001);
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Err(ReportError("cancellation_time"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_and_wait_failures_cannot_be_reported_as_success_or_hide_observed_limits() {
|
||||
let mut result = cpu_limited();
|
||||
result.cleanup = CleanupState::Failed;
|
||||
assert_eq!(result.classify(&spec()), Err(ReportError("cleanup")));
|
||||
result.errors.push(error(ExecutionStage::Cleanup));
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::InfrastructureFailure)
|
||||
);
|
||||
assert_eq!(result.limits.len(), 1);
|
||||
let mut result = report();
|
||||
result.termination = Termination::WaitFailed;
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::InfrastructureFailure)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_failure_requires_a_stage_error_and_separately_preserves_cleanup_failure() {
|
||||
let mut result = report();
|
||||
result.termination = Termination::NotStarted;
|
||||
result.cleanup = CleanupState::NotNeeded;
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Err(ReportError("missing_start_error"))
|
||||
);
|
||||
result.errors.push(error(ExecutionStage::Seccomp));
|
||||
assert_eq!(result.classify(&spec()), Ok(ExecutionOutcome::NotStarted));
|
||||
result.cleanup = CleanupState::Failed;
|
||||
result.errors.push(error(ExecutionStage::Cleanup));
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::InfrastructureFailure)
|
||||
);
|
||||
result.limits.push(observation(
|
||||
1,
|
||||
LimitEvidence::Processes {
|
||||
max_events_delta: 1,
|
||||
},
|
||||
));
|
||||
assert_eq!(result.classify(&spec()), Err(ReportError("not_started")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_truncated_output_is_a_lower_bound_and_requires_matching_limit_evidence() {
|
||||
let mut result = report();
|
||||
let limit = spec().spec().limits.stdout_bytes;
|
||||
result.stdout = OutputCapture {
|
||||
artifact_id: Some("stdout-001".into()),
|
||||
observed_bytes: measured(limit + 1),
|
||||
retained_bytes: limit,
|
||||
truncated: true,
|
||||
reached_eof: false,
|
||||
};
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::InfrastructureFailure)
|
||||
);
|
||||
result.limits.push(observation(
|
||||
1000,
|
||||
LimitEvidence::Stdout {
|
||||
observed_bytes: limit + 1,
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::LimitExceeded {
|
||||
primary: LimitKind::Stdout
|
||||
})
|
||||
);
|
||||
result.stdout.truncated = false;
|
||||
assert_eq!(result.classify(&spec()), Err(ReportError("output_capture")));
|
||||
result.stdout.truncated = true;
|
||||
result.stdout.retained_bytes = limit + 1;
|
||||
assert_eq!(result.classify(&spec()), Err(ReportError("output_capture")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_streams_and_silent_truncation_are_not_successful_execution() {
|
||||
let mut result = report();
|
||||
result.stderr.reached_eof = false;
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::InfrastructureFailure)
|
||||
);
|
||||
result.stderr.truncated = true;
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::InfrastructureFailure)
|
||||
);
|
||||
result.cancelled_at_wall_time_us = Some(1000);
|
||||
assert_eq!(result.classify(&spec()), Ok(ExecutionOutcome::Cancelled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_exports_must_match_requested_paths_and_cannot_exceed_file_caps() {
|
||||
let mut request =
|
||||
serde_json::from_str::<ExecutionSpec>(include_str!("fixtures/spec.valid.json")).unwrap();
|
||||
request.outputs = vec!["main.out".into()];
|
||||
let validated = request.validate(&SpecPolicy::default()).unwrap();
|
||||
let mut result = report();
|
||||
result.artifacts.push(OutputArtifact {
|
||||
path: "main.out".into(),
|
||||
artifact_id: "file-001".into(),
|
||||
bytes: 7,
|
||||
});
|
||||
assert!(result.classify(&validated).is_ok());
|
||||
for invalid in [
|
||||
OutputArtifact {
|
||||
path: "../main.out".into(),
|
||||
..result.artifacts[0].clone()
|
||||
},
|
||||
OutputArtifact {
|
||||
artifact_id: "/host/file".into(),
|
||||
..result.artifacts[0].clone()
|
||||
},
|
||||
OutputArtifact {
|
||||
bytes: validated.spec().limits.file_bytes + 1,
|
||||
..result.artifacts[0].clone()
|
||||
},
|
||||
] {
|
||||
result.artifacts[0] = invalid;
|
||||
assert_eq!(result.classify(&validated), Err(ReportError("artifacts")));
|
||||
result.artifacts[0] = OutputArtifact {
|
||||
path: "main.out".into(),
|
||||
artifact_id: "file-001".into(),
|
||||
bytes: 7,
|
||||
};
|
||||
}
|
||||
result.artifacts.push(result.artifacts[0].clone());
|
||||
assert_eq!(result.classify(&validated), Err(ReportError("artifacts")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_observation_values_times_duplicates_identity_and_signals_are_rejected() {
|
||||
for evidence in [
|
||||
LimitEvidence::CpuTime { observed_us: 1 },
|
||||
LimitEvidence::WallTime { observed_us: 1 },
|
||||
LimitEvidence::Processes {
|
||||
max_events_delta: 0,
|
||||
},
|
||||
LimitEvidence::FileSize { observed_bytes: 0 },
|
||||
LimitEvidence::WorkdirSize { observed_bytes: 0 },
|
||||
LimitEvidence::Stderr { observed_bytes: 0 },
|
||||
] {
|
||||
let mut result = report();
|
||||
result.limits.push(observation(1000, evidence));
|
||||
assert_eq!(result.classify(&spec()), Err(ReportError("limit_evidence")));
|
||||
}
|
||||
let mut result = cpu_limited();
|
||||
result.limits[0].at_wall_time_us = u64::MAX;
|
||||
assert_eq!(result.classify(&spec()), Err(ReportError("limit_evidence")));
|
||||
let mut result = cpu_limited();
|
||||
result.limits.push(result.limits[0].clone());
|
||||
assert_eq!(result.classify(&spec()), Err(ReportError("limit_evidence")));
|
||||
result = report();
|
||||
result.run_id = "other-run".into();
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Err(ReportError("report_identity_or_size"))
|
||||
);
|
||||
result = report();
|
||||
result.termination = Termination::Signaled {
|
||||
signal: 0,
|
||||
core_dumped: false,
|
||||
};
|
||||
assert_eq!(result.classify(&spec()), Err(ReportError("signal")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_denial_and_measured_capacity_evidence_are_classified_without_signal_guessing() {
|
||||
let limits = &spec().spec().limits.clone();
|
||||
for (evidence, primary) in [
|
||||
(
|
||||
LimitEvidence::Processes {
|
||||
max_events_delta: 1,
|
||||
},
|
||||
LimitKind::Processes,
|
||||
),
|
||||
(
|
||||
LimitEvidence::FileSize {
|
||||
observed_bytes: limits.file_bytes + 1,
|
||||
},
|
||||
LimitKind::FileSize,
|
||||
),
|
||||
(
|
||||
LimitEvidence::WorkdirSize {
|
||||
observed_bytes: limits.workdir_bytes + 1,
|
||||
},
|
||||
LimitKind::WorkdirSize,
|
||||
),
|
||||
] {
|
||||
let mut result = report();
|
||||
result.limits.push(observation(1000, evidence));
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::LimitExceeded { primary })
|
||||
);
|
||||
}
|
||||
let mut result = report();
|
||||
result.stderr = OutputCapture {
|
||||
artifact_id: Some("stderr-001".into()),
|
||||
observed_bytes: measured(limits.stderr_bytes + 1),
|
||||
retained_bytes: limits.stderr_bytes,
|
||||
truncated: true,
|
||||
reached_eof: true,
|
||||
};
|
||||
result.limits.push(observation(
|
||||
1000,
|
||||
LimitEvidence::Stderr {
|
||||
observed_bytes: limits.stderr_bytes + 1,
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::LimitExceeded {
|
||||
primary: LimitKind::Stderr
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_evidence_cannot_claim_a_threshold_before_its_observation_time() {
|
||||
let mut result = report();
|
||||
let limit = spec().spec().limits.wall_time_us;
|
||||
result.usage.wall_time_us = measured(limit);
|
||||
result.limits.push(observation(
|
||||
limit - 1,
|
||||
LimitEvidence::WallTime { observed_us: limit },
|
||||
));
|
||||
assert_eq!(result.classify(&spec()), Err(ReportError("limit_evidence")));
|
||||
result.limits[0].at_wall_time_us = limit;
|
||||
assert_eq!(
|
||||
result.classify(&spec()),
|
||||
Ok(ExecutionOutcome::LimitExceeded {
|
||||
primary: LimitKind::WallTime
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_files_together_cannot_exceed_workdir_budget() {
|
||||
let mut request =
|
||||
serde_json::from_str::<ExecutionSpec>(include_str!("fixtures/spec.valid.json")).unwrap();
|
||||
request.outputs = (0..5).map(|index| format!("file-{index}")).collect();
|
||||
let validated = request.validate(&SpecPolicy::default()).unwrap();
|
||||
let mut result = report();
|
||||
for index in 0..5 {
|
||||
result.artifacts.push(OutputArtifact {
|
||||
path: format!("file-{index}"),
|
||||
artifact_id: format!("artifact-{index}"),
|
||||
bytes: validated.spec().limits.file_bytes,
|
||||
});
|
||||
}
|
||||
assert_eq!(result.classify(&validated), Err(ReportError("artifacts")));
|
||||
result.artifacts.pop();
|
||||
assert_eq!(
|
||||
result.classify(&validated),
|
||||
Ok(ExecutionOutcome::Exited { code: 0 })
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user