feat: 本地 uv 与托管 Python 环境管理
- 新增 uv 管理:只认程序目录副本或指定路径,支持从科大镜像下载并按 sha256 校验 - uv 环境变量(UV_*)可配置,默认相对程序目录(python / uv-cache),同时注入 uv 调用与任务进程 - 新增托管虚拟环境与 Python 版本管理:用 uv 下载解释器、预创建环境、查看已装包 - 新建任务保存时自动创建 .venv,运行期只校验、不再自动装依赖;镜像只在装包时使用 - 前端改为顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗 - 发布包构建后去掉工作区继承的低完整性标签 - 修复调度集成测试并发时误取 skipped 运行的问题
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_app() -> (axum::Router, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("test app should start");
|
||||
(app, temp_dir)
|
||||
}
|
||||
|
||||
async fn auth_token(app: &axum::Router) -> String {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/session")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
|
||||
json["token"]
|
||||
.as_str()
|
||||
.expect("token should be a string")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn send(
|
||||
app: &axum::Router,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
token: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
}
|
||||
let request = if let Some(body) = body {
|
||||
builder
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body).expect("body should serialize"),
|
||||
))
|
||||
.expect("request should build")
|
||||
} else {
|
||||
builder.body(Body::empty()).expect("request should build")
|
||||
};
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
|
||||
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_env_list_returns_an_array() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send(&app, Method::GET, "/api/v1/python/envs", None, None).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.is_array(), "envs should be an array: {body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_env_create_validates_input() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/envs",
|
||||
Some(&token),
|
||||
Some(json!({ "name": "../escape" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "name");
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/envs",
|
||||
Some(&token),
|
||||
Some(json!({ "name": "data-science", "python_version": "latest" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "python_version");
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/envs",
|
||||
Some(&token),
|
||||
Some(json!({ "name": "data-science", "packages": ["--index-url=http://x"] })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "packages");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_managed_env_returns_not_found() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::GET,
|
||||
"/api/v1/python/envs/absent/packages",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["code"], "ENV_NOT_FOUND");
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::DELETE,
|
||||
"/api/v1/python/envs/absent",
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["code"], "ENV_NOT_FOUND");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_env_mutations_require_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/envs",
|
||||
None,
|
||||
Some(json!({ "name": "no-token" })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
Reference in New Issue
Block a user