初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,647 @@
|
||||
//! HTTP 路由、请求 / 响应模型与 handler。
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{header, HeaderValue};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
use crate::auth::AuthPayload;
|
||||
use crate::error::ApiError;
|
||||
use crate::executor::{ExecError, ExecOutcome, StreamEvent};
|
||||
use crate::AppState;
|
||||
|
||||
/// 装配路由:GET /healthz、GET /metrics、POST /v1/exec、POST /v1/exec/stream、POST /v1/cancel。
|
||||
/// 受保护端点经司命授权中间件;`/healthz` 与 `/metrics` 不受保护。
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
let protected = Router::new()
|
||||
.route("/v1/exec", post(exec))
|
||||
.route("/v1/exec/stream", post(exec_stream))
|
||||
.route("/v1/cancel", post(cancel_exec))
|
||||
.route("/v1/file/read", post(file_read))
|
||||
.route("/v1/file/write", post(file_write))
|
||||
.route("/v1/file/list", post(file_list))
|
||||
.route("/v1/file/stat", post(file_stat))
|
||||
.route_layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
crate::auth::require_authorization,
|
||||
));
|
||||
Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/metrics", get(metrics_handler))
|
||||
.merge(protected)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
.layer(tower_http::limit::RequestBodyLimitLayer::new(2 * 1024 * 1024))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
pub async fn healthz(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let concurrent = state.executor.metrics().concurrent_value();
|
||||
let max_concurrent = state.config.max_concurrent;
|
||||
|
||||
// 收集预热池统计(如果有)
|
||||
let pool_stats = if let Some(pool) = &state.warm_pool {
|
||||
let metrics = pool.collect_metrics().await;
|
||||
let total: usize = metrics.values().map(|(idle, running, _)| (idle + running) as usize).sum();
|
||||
Some(serde_json::json!({
|
||||
"total_containers": total,
|
||||
"by_language": metrics.iter().map(|(lang, (idle, running, waiters))| {
|
||||
(lang.clone(), serde_json::json!({
|
||||
"idle": idle,
|
||||
"running": running,
|
||||
"waiters": waiters,
|
||||
}))
|
||||
}).collect::<serde_json::Map<_, _>>(),
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"service": "hunjing-sandbox",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"load": {
|
||||
"concurrent": concurrent,
|
||||
"max_concurrent": max_concurrent,
|
||||
"utilization_pct": if max_concurrent > 0 {
|
||||
(concurrent as f64 / max_concurrent as f64 * 100.0).round()
|
||||
} else { 0.0 },
|
||||
},
|
||||
"config": {
|
||||
"auth_enabled": state.config.auth_enabled,
|
||||
"docker_enabled": state.config.docker_enabled,
|
||||
"default_isolation": &state.config.isolation_default,
|
||||
},
|
||||
"warm_pool": pool_stats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Prometheus 指标(text/plain, version=0.0.4)。不鉴权,便于抓取。
|
||||
pub async fn metrics_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let body = state.executor.metrics().gather();
|
||||
(
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
|
||||
)],
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
/// 执行请求体。`argv`(命令式)与 `code`+`language`(代码片段式)二选一,同时或都不给出返回 400。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExecRequest {
|
||||
/// 命令参数数组,首个元素为可执行文件(命令模式)。
|
||||
#[serde(default)]
|
||||
pub argv: Option<Vec<String>>,
|
||||
/// 代码片段(code 模式);与 `argv` 互斥。沙箱转译为 argv 后走执行路径。
|
||||
#[serde(default)]
|
||||
pub code: Option<String>,
|
||||
/// code 模式语言,默认 `python`。P0 仅支持 python。
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stdin: Option<String>,
|
||||
#[serde(default)]
|
||||
pub timeout_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub workdir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub max_output_bytes: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub trace_id: Option<String>,
|
||||
/// 资源限额收紧(key: fsize/as/cpu/nproc/nofile → u64);只能比凭证授予/默认更严,不能放宽。
|
||||
#[serde(default)]
|
||||
pub rlimits: Option<BTreeMap<String, u64>>,
|
||||
/// 隔离策略:soft(默认,容器失败时降级 process)或 hard(容器失败直接报错,不降级)。
|
||||
#[serde(default)]
|
||||
pub isolation_policy: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExecResponse {
|
||||
pub ok: bool,
|
||||
pub exit_code: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub duration_ms: u128,
|
||||
pub timeout: bool,
|
||||
pub cancelled: bool,
|
||||
pub truncated: bool,
|
||||
pub trace_id: String,
|
||||
/// 实际生效的隔离模式(process/container)。P0 期间 container 降级为 process。
|
||||
pub isolation_mode: String,
|
||||
}
|
||||
|
||||
/// 取消请求体:按 trace_id 定位运行中任务。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CancelRequest {
|
||||
pub trace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CancelResponse {
|
||||
pub cancelled: bool,
|
||||
pub trace_id: String,
|
||||
}
|
||||
|
||||
/// 文件读取请求。`path` 为 base 内相对路径;`offset`/`limit` 字节级分页(可选)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FileReadRequest {
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub offset: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
/// 文件写入请求。`content_b64` 为 base64url-no-pad 编码(二进制安全,不经 shell)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FileWriteRequest {
|
||||
pub path: String,
|
||||
pub content_b64: String,
|
||||
}
|
||||
|
||||
/// 目录列出请求。`path` 默认 base 根(`.`)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FileListRequest {
|
||||
#[serde(default = "default_list_path")]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
fn default_list_path() -> String {
|
||||
".".into()
|
||||
}
|
||||
|
||||
/// 文件元信息请求。
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FileStatRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub async fn exec(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(auth): Extension<AuthPayload>,
|
||||
Json(req): Json<ExecRequest>,
|
||||
) -> Result<Json<ExecResponse>, ApiError> {
|
||||
validate_mutex(&req)?;
|
||||
let isolation = resolve_isolation(&state, &auth, req.isolation_policy.as_deref())?;
|
||||
let effective_mode = isolation.mode.clone();
|
||||
let allow_fallback = isolation.allow_fallback;
|
||||
|
||||
// 授权绑定校验:body_hash 按 code/argv 模式分别重算,必须与凭证一致(防 TTL 内重放)。
|
||||
if let Some(expected) = &auth.body_hash {
|
||||
let actual = compute_request_body_hash(&req);
|
||||
if &actual != expected {
|
||||
return Err(ApiError::Unauthorized(
|
||||
"请求内容与授权凭证不匹配(body_hash 校验失败)".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// code → argv 转译。translation 持有 TempPath,在本函数作用域内存活至 run().await 结束
|
||||
// (同步执行:run 返回时子进程已 open 脚本,drop 安全)。
|
||||
let translation = translate_if_code(&req).await?;
|
||||
let effective_argv: Vec<String> = match &translation {
|
||||
Some(t) => t.argv.clone(),
|
||||
None => req.argv.clone().unwrap(),
|
||||
};
|
||||
if effective_argv[0].trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("argv[0] 不能为空".into()));
|
||||
}
|
||||
|
||||
let trace_id = req.trace_id.clone().unwrap_or_else(generate_trace_id);
|
||||
|
||||
// 资源限额:凭证授予(司命 tool policy)+ 请求收紧,clamp 到全局天花板。
|
||||
let effective = crate::rlimits::compute_effective(
|
||||
&state.config.rlimits,
|
||||
auth.rlimit_overrides.as_ref(),
|
||||
req.rlimits.as_ref(),
|
||||
);
|
||||
|
||||
// 并发闸:超出上限时排队,避免资源耗尽。
|
||||
let _permit = state
|
||||
.semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|_| ApiError::Unavailable("沙箱并发闸已关闭".into()))?;
|
||||
|
||||
let env_pairs: Vec<(String, String)> = req.env.into_iter().collect();
|
||||
|
||||
let outcome: ExecOutcome = state
|
||||
.executor
|
||||
.run_routed(
|
||||
effective_argv,
|
||||
req.stdin,
|
||||
req.timeout_ms,
|
||||
req.workdir,
|
||||
env_pairs,
|
||||
req.max_output_bytes,
|
||||
trace_id.clone(),
|
||||
effective,
|
||||
&effective_mode,
|
||||
state.warm_pool.clone(),
|
||||
allow_fallback,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
ExecError::InvalidArgs(m) => ApiError::BadRequest(m),
|
||||
ExecError::Internal { user_msg, detail } => {
|
||||
tracing::error!(error = %detail, "沙箱执行内部错误");
|
||||
ApiError::Internal(user_msg.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Json(ExecResponse {
|
||||
ok: !outcome.timeout && !outcome.cancelled && outcome.exit_code == Some(0),
|
||||
exit_code: outcome.exit_code,
|
||||
stdout: outcome.stdout,
|
||||
stderr: outcome.stderr,
|
||||
duration_ms: outcome.duration_ms,
|
||||
timeout: outcome.timeout,
|
||||
cancelled: outcome.cancelled,
|
||||
truncated: outcome.stdout_truncated || outcome.stderr_truncated,
|
||||
trace_id,
|
||||
isolation_mode: effective_mode,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 流式执行:返回 SSE,实时推送 stdout / stderr 增量(base64 保真),末尾一个 done 事件。
|
||||
pub async fn exec_stream(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(auth): Extension<AuthPayload>,
|
||||
Json(req): Json<ExecRequest>,
|
||||
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<Event, axum::Error>>>, ApiError> {
|
||||
validate_mutex(&req)?;
|
||||
let isolation = resolve_isolation(&state, &auth, req.isolation_policy.as_deref())?;
|
||||
let effective_mode = isolation.mode.clone();
|
||||
let allow_fallback = isolation.allow_fallback;
|
||||
if let Some(expected) = &auth.body_hash {
|
||||
let actual = compute_request_body_hash(&req);
|
||||
if &actual != expected {
|
||||
return Err(ApiError::Unauthorized(
|
||||
"请求内容与授权凭证不匹配(body_hash 校验失败)".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let translation = translate_if_code(&req).await?;
|
||||
let effective_argv: Vec<String> = match &translation {
|
||||
Some(t) => t.argv.clone(),
|
||||
None => req.argv.clone().unwrap(),
|
||||
};
|
||||
if effective_argv[0].trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("argv[0] 不能为空".into()));
|
||||
}
|
||||
|
||||
let trace_id = req.trace_id.clone().unwrap_or_else(generate_trace_id);
|
||||
|
||||
// 资源限额:凭证授予(司命 tool policy)+ 请求收紧,clamp 到全局天花板。
|
||||
let effective = crate::rlimits::compute_effective(
|
||||
&state.config.rlimits,
|
||||
auth.rlimit_overrides.as_ref(),
|
||||
req.rlimits.as_ref(),
|
||||
);
|
||||
|
||||
let permit = state
|
||||
.semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|_| ApiError::Unavailable("沙箱并发闸已关闭".into()))?;
|
||||
|
||||
let env_pairs: Vec<(String, String)> = req.env.into_iter().collect();
|
||||
// Clone effective_mode before moving into SSE closure; run_stream_routed borrows the original.
|
||||
let mode_for_sse = effective_mode.clone();
|
||||
let rx = state
|
||||
.executor
|
||||
.run_stream_routed(
|
||||
effective_argv,
|
||||
req.stdin,
|
||||
req.timeout_ms,
|
||||
req.workdir,
|
||||
env_pairs,
|
||||
req.max_output_bytes,
|
||||
trace_id.clone(),
|
||||
effective,
|
||||
&effective_mode,
|
||||
state.warm_pool.clone(),
|
||||
Some(permit),
|
||||
allow_fallback,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
ExecError::InvalidArgs(m) => ApiError::BadRequest(m),
|
||||
ExecError::Internal { user_msg, detail } => {
|
||||
tracing::error!(error = %detail, "沙箱执行内部错误");
|
||||
ApiError::Internal(user_msg.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let stream = ReceiverStream::new(rx).map(move |event| {
|
||||
let _hold_script = &translation; // 持有 TempPath 至流结束
|
||||
let ev = match event {
|
||||
StreamEvent::Stdout(b) => Event::default()
|
||||
.event("stdout")
|
||||
.data(BASE64_URL_SAFE_NO_PAD.encode(&b)),
|
||||
StreamEvent::Stderr(b) => Event::default()
|
||||
.event("stderr")
|
||||
.data(BASE64_URL_SAFE_NO_PAD.encode(&b)),
|
||||
StreamEvent::Done(o) => {
|
||||
let payload = serde_json::json!({
|
||||
"exit_code": o.exit_code,
|
||||
"duration_ms": o.duration_ms,
|
||||
"timeout": o.timeout,
|
||||
"cancelled": o.cancelled,
|
||||
"truncated": o.stdout_truncated || o.stderr_truncated,
|
||||
"trace_id": trace_id,
|
||||
"isolation_mode": mode_for_sse,
|
||||
});
|
||||
Event::default().event("done").data(payload.to_string())
|
||||
}
|
||||
};
|
||||
Ok::<_, axum::Error>(ev)
|
||||
});
|
||||
|
||||
Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
|
||||
}
|
||||
|
||||
/// 取消运行中的任务(按 trace_id)。
|
||||
///
|
||||
/// 安全权衡:cancel 仅中止任务、不执行新命令,危害有限,故凭证不绑定 trace_id
|
||||
/// (验签 + 过期即可),与 exec 的 body_hash 强绑定不同。
|
||||
pub async fn cancel_exec(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(_auth): Extension<AuthPayload>,
|
||||
Json(req): Json<CancelRequest>,
|
||||
) -> Result<Json<CancelResponse>, ApiError> {
|
||||
if req.trace_id.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("trace_id 不能为空".into()));
|
||||
}
|
||||
let cancelled = state.executor.cancel(&req.trace_id).await;
|
||||
if cancelled {
|
||||
Ok(Json(CancelResponse {
|
||||
cancelled: true,
|
||||
trace_id: req.trace_id,
|
||||
}))
|
||||
} else {
|
||||
Err(ApiError::NotFound(format!(
|
||||
"任务不存在或已完成: {}",
|
||||
req.trace_id
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 文件 IO 端点(不经 shell,二进制安全)
|
||||
// ==============================
|
||||
|
||||
/// `POST /v1/file/read`:读取 base 内文件,返回 base64 内容 + 元信息。
|
||||
pub async fn file_read(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(auth): Extension<AuthPayload>,
|
||||
Json(req): Json<FileReadRequest>,
|
||||
) -> Result<Json<crate::fileio::FileReadOutcome>, ApiError> {
|
||||
verify_file_op_hash(&auth, "read", &req.path)?;
|
||||
let outcome = crate::fileio::file_read(
|
||||
state.executor.base(),
|
||||
state.executor.base_canon(),
|
||||
&req.path,
|
||||
req.offset,
|
||||
req.limit,
|
||||
state.config.max_file_bytes,
|
||||
)
|
||||
.await
|
||||
.map_err(file_error_to_api)?;
|
||||
Ok(Json(outcome))
|
||||
}
|
||||
|
||||
/// `POST /v1/file/write`:写入 base 内文件(覆盖),content 为 base64url-no-pad。
|
||||
pub async fn file_write(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(auth): Extension<AuthPayload>,
|
||||
Json(req): Json<FileWriteRequest>,
|
||||
) -> Result<Json<crate::fileio::FileWriteOutcome>, ApiError> {
|
||||
verify_file_op_hash(&auth, "write", &req.path)?;
|
||||
let content = BASE64_URL_SAFE_NO_PAD
|
||||
.decode(req.content_b64.as_bytes())
|
||||
.map_err(|_| ApiError::BadRequest("content_b64 不是合法 base64url-no-pad".into()))?;
|
||||
let outcome = crate::fileio::file_write(
|
||||
state.executor.base(),
|
||||
state.executor.base_canon(),
|
||||
&req.path,
|
||||
&content,
|
||||
state.config.max_file_bytes,
|
||||
)
|
||||
.await
|
||||
.map_err(file_error_to_api)?;
|
||||
Ok(Json(outcome))
|
||||
}
|
||||
|
||||
/// `POST /v1/file/list`:列出 base 内目录条目(按名字排序)。
|
||||
pub async fn file_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(auth): Extension<AuthPayload>,
|
||||
Json(req): Json<FileListRequest>,
|
||||
) -> Result<Json<crate::fileio::FileListOutcome>, ApiError> {
|
||||
verify_file_op_hash(&auth, "list", &req.path)?;
|
||||
let outcome = crate::fileio::file_list(
|
||||
state.executor.base(),
|
||||
state.executor.base_canon(),
|
||||
&req.path,
|
||||
)
|
||||
.await
|
||||
.map_err(file_error_to_api)?;
|
||||
Ok(Json(outcome))
|
||||
}
|
||||
|
||||
/// `POST /v1/file/stat`:返回 base 内文件/目录元信息。
|
||||
pub async fn file_stat(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(auth): Extension<AuthPayload>,
|
||||
Json(req): Json<FileStatRequest>,
|
||||
) -> Result<Json<crate::fileio::FileStatOutcome>, ApiError> {
|
||||
verify_file_op_hash(&auth, "stat", &req.path)?;
|
||||
let outcome = crate::fileio::file_stat(
|
||||
state.executor.base(),
|
||||
state.executor.base_canon(),
|
||||
&req.path,
|
||||
)
|
||||
.await
|
||||
.map_err(file_error_to_api)?;
|
||||
Ok(Json(outcome))
|
||||
}
|
||||
|
||||
/// 校验文件操作凭证绑定:凭证 file_op_hash 必须与 {op, path} 摘要一致,防重放任意文件操作。
|
||||
fn verify_file_op_hash(auth: &AuthPayload, op: &str, path: &str) -> Result<(), ApiError> {
|
||||
if let Some(expected) = &auth.file_op_hash {
|
||||
let actual = crate::auth::compute_file_op_hash(op, path);
|
||||
if &actual != expected {
|
||||
return Err(ApiError::Unauthorized(
|
||||
"文件操作与授权凭证不匹配(file_op_hash 校验失败)".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 文件操作错误 → API 错误映射(路径问题 4xx,IO 内部错误脱敏后 500)。
|
||||
fn file_error_to_api(e: crate::fileio::FileError) -> ApiError {
|
||||
use crate::fileio::FileError;
|
||||
match e {
|
||||
FileError::InvalidPath(m) => ApiError::BadRequest(m),
|
||||
FileError::NotFound => ApiError::NotFound("文件或目录不存在".into()),
|
||||
FileError::WrongType { expected, actual } => {
|
||||
ApiError::BadRequest(format!("类型不符:期望 {expected},实际 {actual}"))
|
||||
}
|
||||
FileError::TooLarge { limit, actual } => {
|
||||
ApiError::BadRequest(format!("内容超过大小上限 {limit} 字节(实际 {actual})"))
|
||||
}
|
||||
FileError::Io(e) => {
|
||||
tracing::error!(error = %e, "文件 IO 内部错误");
|
||||
ApiError::Internal("文件操作失败".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_trace_id() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("sbx_{nanos:x}")
|
||||
}
|
||||
|
||||
/// argv / code 互斥:同时或都不给出 → 400。
|
||||
fn validate_mutex(req: &ExecRequest) -> Result<(), ApiError> {
|
||||
match (req.argv.as_ref(), req.code.as_ref()) {
|
||||
(Some(_), Some(_)) => Err(ApiError::BadRequest("argv 与 code 不能同时指定".into())),
|
||||
(None, None) => Err(ApiError::BadRequest("必须指定 argv 或 code 之一".into())),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算请求内容摘要:code 模式按 {language, code, stdin},argv 模式按 {argv, stdin}。
|
||||
fn compute_request_body_hash(req: &ExecRequest) -> String {
|
||||
if let Some(code) = req.code.as_deref() {
|
||||
let lang = req.language.as_deref().unwrap_or("python");
|
||||
crate::auth::compute_body_hash_code(lang, code, &req.stdin)
|
||||
} else {
|
||||
let argv = req.argv.as_deref().unwrap_or_default();
|
||||
crate::auth::compute_body_hash(argv, &req.stdin)
|
||||
}
|
||||
}
|
||||
|
||||
/// code 模式转译为 argv;argv 模式返回 None。返回的 `CodeTranslation` 持有 `TempPath`,
|
||||
/// 调用方负责存活至执行结束(同步 exec 由作用域持有;流式 exec_stream 由 SSE 闭包持有)。
|
||||
async fn translate_if_code(
|
||||
req: &ExecRequest,
|
||||
) -> Result<Option<crate::codemode::CodeTranslation>, ApiError> {
|
||||
if let Some(code) = req.code.as_deref() {
|
||||
let lang = req.language.as_deref().unwrap_or("python");
|
||||
crate::codemode::translate(lang, code)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(ApiError::BadRequest)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 生效隔离模式与降级策略。
|
||||
struct IsolationConfig {
|
||||
/// 实际执行模式:"process" 或 "container"。
|
||||
mode: String,
|
||||
/// 容器执行失败时是否允许降级为进程模式。
|
||||
allow_fallback: bool,
|
||||
}
|
||||
|
||||
/// 解析生效隔离模式与降级策略。
|
||||
///
|
||||
/// `auth.isolation_mode` 为凭证授予的隔离要求;`policy` 来自请求体 `isolation_policy`
|
||||
/// ("hard"=不降级 / "soft"=降级 / None=默认降级)。
|
||||
fn resolve_isolation(
|
||||
state: &Arc<AppState>,
|
||||
auth: &AuthPayload,
|
||||
policy: Option<&str>,
|
||||
) -> Result<IsolationConfig, ApiError> {
|
||||
let hard = policy == Some("hard");
|
||||
let fallback = |mode: &str, downgrade: bool| {
|
||||
let m = mode.to_string();
|
||||
IsolationConfig {
|
||||
mode: m,
|
||||
allow_fallback: if downgrade { true } else { !hard },
|
||||
}
|
||||
};
|
||||
|
||||
match auth.isolation_mode.as_deref() {
|
||||
Some("container") => {
|
||||
if state.warm_pool.is_some() {
|
||||
Ok(fallback("container", false))
|
||||
} else if hard {
|
||||
Err(ApiError::Unavailable(
|
||||
"凭证要求 container 隔离但预热池未就绪,且 isolation_policy=hard 拒绝降级".into(),
|
||||
))
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tool = ?auth.tool,
|
||||
"凭证要求 container 隔离但预热池不可用,降级 process"
|
||||
);
|
||||
Ok(fallback("process", true))
|
||||
}
|
||||
}
|
||||
Some("process") => Ok(fallback("process", true)),
|
||||
Some(other) => {
|
||||
tracing::warn!(mode = other, "未知 isolation_mode,降级 process");
|
||||
Ok(fallback("process", true))
|
||||
}
|
||||
None => {
|
||||
if state.config.isolation_default == "container" && state.warm_pool.is_some() {
|
||||
Ok(fallback("container", false))
|
||||
} else {
|
||||
Ok(fallback("process", true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exec_request_code_mode_deserializes() {
|
||||
let json = r#"{"language":"python","code":"print(1)"}"#;
|
||||
let req: ExecRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.code.as_deref(), Some("print(1)"));
|
||||
assert_eq!(req.language.as_deref(), Some("python"));
|
||||
assert!(req.argv.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_request_argv_mode_still_works() {
|
||||
let json = r#"{"argv":["python3","-c","print(1)"]}"#;
|
||||
let req: ExecRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
req.argv.as_deref().unwrap(),
|
||||
&[
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
"print(1)".to_string()
|
||||
]
|
||||
);
|
||||
assert!(req.code.is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user