feat: 本地 uv 与托管 Python 环境管理
- 新增 uv 管理:只认程序目录副本或指定路径,支持从科大镜像下载并按 sha256 校验 - uv 环境变量(UV_*)可配置,默认相对程序目录(python / uv-cache),同时注入 uv 调用与任务进程 - 新增托管虚拟环境与 Python 版本管理:用 uv 下载解释器、预创建环境、查看已装包 - 新建任务保存时自动创建 .venv,运行期只校验、不再自动装依赖;镜像只在装包时使用 - 前端改为顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗 - 发布包构建后去掉工作区继承的低完整性标签 - 修复调度集成测试并发时误取 skipped 运行的问题
This commit is contained in:
Generated
+87
@@ -263,6 +263,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -318,6 +324,15 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "croner"
|
||||
version = "4.0.1"
|
||||
@@ -455,6 +470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
@@ -553,6 +569,15 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
|
||||
dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
@@ -1234,6 +1259,18 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1835,6 +1872,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
@@ -2172,6 +2215,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -2187,6 +2231,7 @@ dependencies = [
|
||||
"utoipa-axum",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2450,6 +2495,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -2947,8 +2998,44 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f17fbc870bfac28c6228aed7d6e6df352e26bf7015c4313727e16a7a01ce5e"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap",
|
||||
"libc",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
@@ -14,6 +14,7 @@ futures-util = { version = "0.3.34", features = ["std"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
sha2 = "0.11.1"
|
||||
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "sqlite-bundled", "migrate", "chrono", "derive", "macros"] }
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.3", features = ["full"] }
|
||||
@@ -27,6 +28,7 @@ utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] }
|
||||
utoipa-axum = "0.3.0"
|
||||
uuid = { version = "1.28.0", features = ["v4"] }
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects"] }
|
||||
zip = { version = "9.4.1", default-features = false, features = ["deflate"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1.5"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- uv 可执行文件来自哪里:系统 PATH(system)、程序目录下的 uv 目录(managed),
|
||||
-- 或用户自己下载后指定的路径(custom)。只保留一行。
|
||||
CREATE TABLE IF NOT EXISTS uv_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
source TEXT NOT NULL DEFAULT 'system' CHECK (source IN ('system', 'managed', 'custom')),
|
||||
custom_path TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at)
|
||||
VALUES (1, 'system', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
@@ -0,0 +1,21 @@
|
||||
-- uv 只认平台自己的副本(程序目录 uv)或用户指定的路径,不再读系统 PATH。
|
||||
CREATE TABLE uv_settings_v2 (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
source TEXT NOT NULL CHECK (source IN ('managed', 'custom')),
|
||||
custom_path TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO uv_settings_v2 (id, source, custom_path, updated_at)
|
||||
SELECT id,
|
||||
CASE WHEN source = 'custom' THEN 'custom' ELSE 'managed' END,
|
||||
custom_path,
|
||||
updated_at
|
||||
FROM uv_settings;
|
||||
|
||||
DROP TABLE uv_settings;
|
||||
|
||||
ALTER TABLE uv_settings_v2 RENAME TO uv_settings;
|
||||
|
||||
INSERT OR IGNORE INTO uv_settings (id, source, custom_path, updated_at)
|
||||
VALUES (1, 'managed', NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
@@ -0,0 +1,2 @@
|
||||
-- uv 管理页允许额外注入 UV_* 环境变量(缓存目录、Python 安装目录、超时等)。
|
||||
ALTER TABLE uv_settings ADD COLUMN extra_env TEXT NOT NULL DEFAULT '{}';
|
||||
+42
-5
@@ -21,6 +21,11 @@ use crate::{
|
||||
self, CreatePythonVenvRequest, InterpreterKind, VenvProbeRequest, VenvProbeResponse,
|
||||
VenvProbeStatus, VenvSource,
|
||||
},
|
||||
python_envs::{
|
||||
__path_create_env, __path_delete_env, __path_install_python, __path_list_env_packages,
|
||||
__path_list_envs, __path_list_pythons, CreateManagedEnvRequest, InstallPythonRequest,
|
||||
ManagedEnvPackage, ManagedEnvSummary, ManagedPython,
|
||||
},
|
||||
python_mirrors::{
|
||||
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
||||
routes::{
|
||||
@@ -49,6 +54,10 @@ use crate::{
|
||||
__path_get_task, __path_list_tasks, __path_update_task,
|
||||
},
|
||||
},
|
||||
uv::{
|
||||
__path_download_uv, __path_get_uv_settings, __path_update_uv_settings,
|
||||
UpdateUvSettingsRequest, UvCandidate, UvSettingsResponse, UvSource,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
@@ -137,9 +146,11 @@ pub async fn preview_cron(
|
||||
)
|
||||
)]
|
||||
pub async fn probe_python_venv(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<VenvProbeRequest>,
|
||||
) -> ApiResult<Json<VenvProbeResponse>> {
|
||||
tokio::task::spawn_blocking(move || python_env::probe_venv(&request))
|
||||
let uv = crate::uv::current(&state.pool).await.program();
|
||||
tokio::task::spawn_blocking(move || python_env::probe_venv(&request, &uv))
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(ApiError::internal)
|
||||
@@ -165,10 +176,16 @@ pub async fn create_python_venv(
|
||||
tracing::warn!(error = %error, "读取默认 Python 镜像失败,uv 将使用自身配置");
|
||||
None
|
||||
});
|
||||
python_env::create_project_venv(&request.working_dir, mirror.as_ref())
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|message| ApiError::bad_request("VENV_CREATE_FAILED", message))
|
||||
let uv = crate::uv::current(&state.pool).await;
|
||||
python_env::create_project_venv(
|
||||
&request.working_dir,
|
||||
mirror.as_ref(),
|
||||
&uv.program(),
|
||||
&uv.env(),
|
||||
)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|message| ApiError::bad_request("VENV_CREATE_FAILED", message))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -255,12 +272,21 @@ pub async fn list_schedule(
|
||||
preview_cron,
|
||||
probe_python_venv,
|
||||
create_python_venv,
|
||||
get_uv_settings,
|
||||
update_uv_settings,
|
||||
download_uv,
|
||||
list_python_mirrors,
|
||||
get_python_mirror,
|
||||
create_python_mirror,
|
||||
update_python_mirror,
|
||||
delete_python_mirror,
|
||||
set_default_python_mirror,
|
||||
list_envs,
|
||||
create_env,
|
||||
list_env_packages,
|
||||
delete_env,
|
||||
list_pythons,
|
||||
install_python,
|
||||
list_directories,
|
||||
task_stats,
|
||||
list_schedule,
|
||||
@@ -293,6 +319,15 @@ pub async fn list_schedule(
|
||||
PythonMirrorResponse,
|
||||
CreatePythonMirrorRequest,
|
||||
UpdatePythonMirrorRequest,
|
||||
UvSettingsResponse,
|
||||
UpdateUvSettingsRequest,
|
||||
UvCandidate,
|
||||
UvSource,
|
||||
ManagedEnvSummary,
|
||||
ManagedEnvPackage,
|
||||
CreateManagedEnvRequest,
|
||||
ManagedPython,
|
||||
InstallPythonRequest,
|
||||
DirectoryEntry,
|
||||
DirectoryListingResponse,
|
||||
TaskStatsResponse,
|
||||
@@ -357,6 +392,8 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/v1/openapi.json", get(openapi_json))
|
||||
.merge(crate::tasks::routes::router())
|
||||
.merge(crate::python_mirrors::routes::router())
|
||||
.merge(crate::python_envs::router())
|
||||
.merge(crate::uv::router())
|
||||
.merge(crate::runs::routes::router())
|
||||
.route("/api/{*path}", any(api_not_found))
|
||||
// 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。
|
||||
|
||||
@@ -119,6 +119,16 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn env_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "ENV_NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mirror_conflict(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::CONFLICT,
|
||||
|
||||
@@ -5,12 +5,14 @@ pub mod directory_browser;
|
||||
pub mod error;
|
||||
pub mod process_job;
|
||||
pub mod python_env;
|
||||
pub mod python_envs;
|
||||
pub mod python_mirrors;
|
||||
pub mod runs;
|
||||
pub mod scheduler;
|
||||
pub mod security;
|
||||
pub mod state;
|
||||
pub mod tasks;
|
||||
pub mod uv;
|
||||
|
||||
use axum::Router;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
+81
-44
@@ -33,6 +33,28 @@ impl PreparedPython {
|
||||
}
|
||||
command.envs(&self.environment);
|
||||
}
|
||||
|
||||
/// 把 `directory` 插到 PATH 最前面,让任务脚本里的 `uv` 用平台副本。
|
||||
pub fn prepend_path(&mut self, directory: &Path) -> io::Result<()> {
|
||||
let base = self
|
||||
.environment
|
||||
.get("PATH")
|
||||
.map(OsString::from)
|
||||
.or_else(|| env::var_os("PATH"))
|
||||
.unwrap_or_default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut paths = Vec::new();
|
||||
for path in std::iter::once(directory.to_path_buf()).chain(env::split_paths(&base)) {
|
||||
let key = path.to_string_lossy().to_ascii_lowercase();
|
||||
if seen.insert(key) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
let joined = env::join_paths(paths).map_err(io::Error::other)?;
|
||||
self.environment
|
||||
.insert("PATH".to_owned(), joined.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare_script_environment(
|
||||
@@ -244,7 +266,7 @@ pub struct VenvProbeResponse {
|
||||
pub has_requirements: bool,
|
||||
/// 探测到的 requirements 文件名(仅根目录)
|
||||
pub requirements_files: Vec<String>,
|
||||
/// 没有虚拟环境时,是否具备用 uv 创建的条件
|
||||
/// 没有虚拟环境时,是否可以用 uv 创建(有 uv 即可,纯标准库脚本会得到一个空环境)
|
||||
pub can_create: bool,
|
||||
}
|
||||
|
||||
@@ -255,7 +277,7 @@ pub struct CreatePythonVenvRequest {
|
||||
pub working_dir: String,
|
||||
}
|
||||
|
||||
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
|
||||
pub fn probe_venv(request: &VenvProbeRequest, uv: &Path) -> VenvProbeResponse {
|
||||
let working_dir = request.working_dir.trim();
|
||||
let interpreter = {
|
||||
let trimmed = request.interpreter.trim();
|
||||
@@ -271,7 +293,7 @@ pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let interpreter_kind = classify_interpreter(interpreter);
|
||||
let uv = uv_available();
|
||||
let uv = crate::uv::probe_version(uv).is_some();
|
||||
let respond = |response: VenvProbeResponse| finalize_probe(response, working_dir, uv);
|
||||
|
||||
if working_dir.is_empty() {
|
||||
@@ -367,16 +389,12 @@ fn finalize_probe(
|
||||
response.has_pyproject = files.has_pyproject;
|
||||
response.has_requirements = !files.requirements.is_empty();
|
||||
response.requirements_files = files.requirements;
|
||||
response.can_create = response.status == VenvProbeStatus::Missing
|
||||
&& uv_available
|
||||
&& (response.has_pyproject || response.has_requirements);
|
||||
response.can_create = response.status == VenvProbeStatus::Missing && uv_available;
|
||||
if response.status == VenvProbeStatus::Missing {
|
||||
response.message = if response.can_create {
|
||||
"未检测到虚拟环境,可用 uv 从项目依赖文件创建 .venv".to_owned()
|
||||
} else if !uv_available {
|
||||
"未检测到虚拟环境;本机未安装 uv,请先手动创建 .venv".to_owned()
|
||||
"未检测到虚拟环境,保存任务时会用 uv 自动创建(首次较慢)".to_owned()
|
||||
} else {
|
||||
"未检测到虚拟环境,也没有 pyproject.toml / requirements.txt,无法自动创建".to_owned()
|
||||
"未检测到虚拟环境;uv 不可用,请先在「uv 管理」中配置,或手动创建 .venv".to_owned()
|
||||
};
|
||||
}
|
||||
response
|
||||
@@ -426,25 +444,12 @@ pub fn scan_project_files(working_dir: &str) -> ProjectFiles {
|
||||
files
|
||||
}
|
||||
|
||||
/// 本机 PATH 中是否有可用的 uv。
|
||||
pub fn uv_available() -> bool {
|
||||
let mut command = std::process::Command::new("uv");
|
||||
command
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
command.status().is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
/// 用 uv 创建/同步项目虚拟环境。优先返回已有环境。
|
||||
pub async fn create_project_venv(
|
||||
working_dir: &str,
|
||||
mirror: Option<&DefaultPythonMirror>,
|
||||
uv: &Path,
|
||||
uv_env: &BTreeMap<String, String>,
|
||||
) -> Result<VenvProbeResponse, String> {
|
||||
let working_directory =
|
||||
absolute_path(Path::new(working_dir)).map_err(|error| format!("工作目录无效: {error}"))?;
|
||||
@@ -457,25 +462,23 @@ pub async fn create_project_venv(
|
||||
interpreter: "python.exe".to_owned(),
|
||||
venv: None,
|
||||
};
|
||||
let existing = probe_venv(&probe_request);
|
||||
let existing = probe_venv(&probe_request, uv);
|
||||
if existing.status == VenvProbeStatus::Linked {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let files = scan_project_files(working_dir);
|
||||
if !files.has_pyproject && files.requirements.is_empty() {
|
||||
if crate::uv::probe_version(uv).is_none() {
|
||||
return Err(
|
||||
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt,无法创建".to_owned(),
|
||||
"uv 不可用,无法自动创建虚拟环境;请先在「uv 管理」中配置,或手动创建 .venv".to_owned(),
|
||||
);
|
||||
}
|
||||
if !uv_available() {
|
||||
return Err("本机 PATH 中找不到 uv,无法自动创建虚拟环境;请手动创建 .venv".to_owned());
|
||||
}
|
||||
|
||||
// 纯标准库脚本没有依赖文件:建一个空环境即可,运行期仍然走 .venv 里的 Python。
|
||||
if files.has_pyproject {
|
||||
run_uv(&working_directory, &["sync"], mirror).await?;
|
||||
run_uv(&working_directory, &["sync"], mirror, uv, uv_env).await?;
|
||||
} else {
|
||||
run_uv(&working_directory, &["venv", ".venv"], mirror).await?;
|
||||
run_uv(&working_directory, &["venv", ".venv"], mirror, uv, uv_env).await?;
|
||||
let python = working_directory
|
||||
.join(".venv")
|
||||
.join("Scripts")
|
||||
@@ -486,12 +489,14 @@ pub async fn create_project_venv(
|
||||
&working_directory,
|
||||
&["pip", "install", "--python", &python, "-r", requirement],
|
||||
mirror,
|
||||
uv,
|
||||
uv_env,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let probe = probe_venv(&probe_request);
|
||||
let probe = probe_venv(&probe_request, uv);
|
||||
if probe.status != VenvProbeStatus::Linked {
|
||||
return Err(format!(
|
||||
"uv 执行完成,但没有找到有效的 .venv:{}",
|
||||
@@ -501,12 +506,14 @@ pub async fn create_project_venv(
|
||||
Ok(probe)
|
||||
}
|
||||
|
||||
async fn run_uv(
|
||||
pub(crate) async fn run_uv(
|
||||
working_dir: &Path,
|
||||
args: &[&str],
|
||||
mirror: Option<&DefaultPythonMirror>,
|
||||
uv: &Path,
|
||||
uv_env: &BTreeMap<String, String>,
|
||||
) -> Result<String, String> {
|
||||
let mut command = Command::new("uv");
|
||||
let mut command = Command::new(uv);
|
||||
command
|
||||
.args(args)
|
||||
.current_dir(working_dir)
|
||||
@@ -526,6 +533,8 @@ async fn run_uv(
|
||||
command.env("PIP_TRUSTED_HOST", host);
|
||||
}
|
||||
}
|
||||
// 页面里配置的 UV_* 覆盖镜像注入与进程环境,用户显式设置优先。
|
||||
command.envs(uv_env);
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(0x0800_0000);
|
||||
|
||||
@@ -812,6 +821,30 @@ mod tests {
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepends_the_uv_directory_to_path() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut prepared = prepare_script_environment(
|
||||
temp.path().to_str().unwrap(),
|
||||
"python.exe",
|
||||
None,
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let uv_directory = temp.path().join("uv");
|
||||
|
||||
prepared.prepend_path(&uv_directory).unwrap();
|
||||
|
||||
let paths: Vec<_> = env::split_paths(&prepared.environment["PATH"]).collect();
|
||||
assert_eq!(paths.first(), Some(&uv_directory));
|
||||
// 重复插入要去重
|
||||
prepared.prepend_path(&uv_directory).unwrap();
|
||||
assert_eq!(
|
||||
env::split_paths(&prepared.environment["PATH"]).count(),
|
||||
paths.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_python_interpreter_is_not_modified() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -934,11 +967,14 @@ mod tests {
|
||||
}
|
||||
|
||||
fn probe(working_dir: &str, interpreter: &str, venv: Option<&str>) -> VenvProbeResponse {
|
||||
probe_venv(&VenvProbeRequest {
|
||||
working_dir: working_dir.to_owned(),
|
||||
interpreter: interpreter.to_owned(),
|
||||
venv: venv.map(str::to_owned),
|
||||
})
|
||||
probe_venv(
|
||||
&VenvProbeRequest {
|
||||
working_dir: working_dir.to_owned(),
|
||||
interpreter: interpreter.to_owned(),
|
||||
venv: venv.map(str::to_owned),
|
||||
},
|
||||
Path::new("uv"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -985,7 +1021,7 @@ mod tests {
|
||||
assert_eq!(response.status, VenvProbeStatus::Missing);
|
||||
assert_eq!(response.display_path.as_deref(), Some("python.exe"));
|
||||
assert!(response.venv_path.is_none());
|
||||
assert!(!response.can_create);
|
||||
assert_eq!(response.can_create, response.uv_available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1004,12 +1040,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_does_not_offer_uv_without_dependency_files() {
|
||||
fn probe_offers_uv_creation_even_without_dependency_files() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
|
||||
|
||||
assert!(!response.can_create);
|
||||
// 纯标准库脚本也能建一个空环境,所以这里只看 uv 是否可用。
|
||||
assert_eq!(response.can_create, response.uv_available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path as AxumPath, State},
|
||||
http::StatusCode,
|
||||
routing::{delete, get},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiJson, ApiResult},
|
||||
python_env::run_uv,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// 预创建的虚拟环境放在 `data/envs` 下,按目录名管理。
|
||||
const ENV_ROOT: &str = "data/envs";
|
||||
const MAX_NAME_CHARS: usize = 64;
|
||||
const MAX_PACKAGES: usize = 50;
|
||||
const MAX_PACKAGE_CHARS: usize = 200;
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ManagedEnvSummary {
|
||||
pub name: String,
|
||||
/// 虚拟环境根目录的绝对路径
|
||||
pub path: String,
|
||||
/// 取自 `pyvenv.cfg` 的 `version_info`
|
||||
pub python_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ManagedEnvPackage {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateManagedEnvRequest {
|
||||
/// 目录名,`[A-Za-z0-9._-]`,也是任务里引用它的标识
|
||||
pub name: String,
|
||||
/// 创建时使用的 Python 版本,例如 `3.11`;留空由 uv 决定
|
||||
#[serde(default)]
|
||||
pub python_version: Option<String>,
|
||||
/// 预装包,每行一个,例如 `numpy==2.1.0`
|
||||
#[serde(default)]
|
||||
pub packages: Vec<String>,
|
||||
}
|
||||
|
||||
fn root() -> PathBuf {
|
||||
std::path::absolute(Path::new(ENV_ROOT)).unwrap_or_else(|_| PathBuf::from(ENV_ROOT))
|
||||
}
|
||||
|
||||
fn validate_name(raw: &str) -> ApiResult<String> {
|
||||
let name = raw.trim();
|
||||
let valid = !name.is_empty()
|
||||
&& name.chars().count() <= MAX_NAME_CHARS
|
||||
&& !name.starts_with('.')
|
||||
&& name.chars().all(|character| {
|
||||
character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
|
||||
});
|
||||
if !valid {
|
||||
return Err(ApiError::validation(
|
||||
"name",
|
||||
"环境名只能包含字母、数字、点、下划线、连字符,且不能以点开头",
|
||||
));
|
||||
}
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
fn validate_python_version(raw: Option<&str>) -> ApiResult<Option<String>> {
|
||||
let Some(value) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.len() > 16
|
||||
|| !value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_digit() || character == '.')
|
||||
{
|
||||
return Err(ApiError::validation(
|
||||
"python_version",
|
||||
"Python 版本只填数字和点,例如 3.11 或 3.12.1",
|
||||
));
|
||||
}
|
||||
Ok(Some(value.to_owned()))
|
||||
}
|
||||
|
||||
fn validate_packages(packages: &[String]) -> ApiResult<Vec<String>> {
|
||||
if packages.len() > MAX_PACKAGES {
|
||||
return Err(ApiError::validation(
|
||||
"packages",
|
||||
format!("一次最多预装 {MAX_PACKAGES} 个包"),
|
||||
));
|
||||
}
|
||||
let mut cleaned = Vec::with_capacity(packages.len());
|
||||
for package in packages {
|
||||
let package = package.trim();
|
||||
if package.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// 以 - 开头会被 uv 当成参数,直接拒绝。
|
||||
if package.starts_with('-') {
|
||||
return Err(ApiError::validation(
|
||||
"packages",
|
||||
format!("包名不能以 - 开头: {package}"),
|
||||
));
|
||||
}
|
||||
if package.chars().count() > MAX_PACKAGE_CHARS
|
||||
|| package.chars().any(|character| character.is_control())
|
||||
{
|
||||
return Err(ApiError::validation(
|
||||
"packages",
|
||||
format!("包名过长或包含非法字符: {package}"),
|
||||
));
|
||||
}
|
||||
cleaned.push(package.to_owned());
|
||||
}
|
||||
Ok(cleaned)
|
||||
}
|
||||
|
||||
fn read_summary(name: &str, path: &Path) -> Option<ManagedEnvSummary> {
|
||||
let python = path.join("Scripts").join("python.exe");
|
||||
let config = path.join("pyvenv.cfg");
|
||||
if !python.is_file() || !config.is_file() {
|
||||
return None;
|
||||
}
|
||||
let python_version = fs::read_to_string(&config).ok().and_then(|text| {
|
||||
text.lines().find_map(|line| {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
(key.trim() == "version_info").then(|| value.trim().to_owned())
|
||||
})
|
||||
});
|
||||
Some(ManagedEnvSummary {
|
||||
name: name.to_owned(),
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
python_version,
|
||||
})
|
||||
}
|
||||
|
||||
fn env_path(name: &str) -> ApiResult<PathBuf> {
|
||||
let path = root().join(name);
|
||||
if !path.is_dir() {
|
||||
return Err(ApiError::env_not_found(format!("虚拟环境 {name} 不存在")));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/envs",
|
||||
tag = "python-envs",
|
||||
responses(
|
||||
(status = 200, description = "Managed virtual environments", body = Vec<ManagedEnvSummary>)
|
||||
)
|
||||
)]
|
||||
pub async fn list_envs() -> ApiResult<Json<Vec<ManagedEnvSummary>>> {
|
||||
let directory = root();
|
||||
let mut envs = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&directory) {
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if let Some(summary) = read_summary(&name, &entry.path()) {
|
||||
envs.push(summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
envs.sort_by_key(|env| env.name.to_lowercase());
|
||||
Ok(Json(envs))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/envs",
|
||||
tag = "python-envs",
|
||||
request_body = CreateManagedEnvRequest,
|
||||
responses(
|
||||
(status = 201, description = "Virtual environment created", body = ManagedEnvSummary),
|
||||
(status = 400, description = "Validation or uv failure", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn create_env(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<CreateManagedEnvRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<ManagedEnvSummary>)> {
|
||||
let name = validate_name(&request.name)?;
|
||||
let python_version = validate_python_version(request.python_version.as_deref())?;
|
||||
let packages = validate_packages(&request.packages)?;
|
||||
let directory = root();
|
||||
let target = directory.join(&name);
|
||||
if target.exists() {
|
||||
return Err(ApiError::bad_request(
|
||||
"ENV_EXISTS",
|
||||
format!("虚拟环境 {name} 已存在,先删除再重建"),
|
||||
));
|
||||
}
|
||||
fs::create_dir_all(&directory).map_err(ApiError::internal)?;
|
||||
|
||||
let uv = crate::uv::current(&state.pool).await;
|
||||
let mirror = crate::python_mirrors::repository::default_enabled(&state.pool)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(%error, "读取默认 Python 镜像失败,创建环境时改用 uv 自身配置");
|
||||
None
|
||||
});
|
||||
let program = uv.program();
|
||||
if crate::uv::probe_version(&program).is_none() {
|
||||
return Err(ApiError::bad_request(
|
||||
"UV_UNAVAILABLE",
|
||||
"uv 不可用:请先在「Python 环境」页下载或指定 uv",
|
||||
));
|
||||
}
|
||||
let target_arg = target.to_string_lossy().into_owned();
|
||||
|
||||
let mut venv_args = vec!["venv".to_owned(), target_arg.clone()];
|
||||
if let Some(version) = python_version {
|
||||
venv_args.push("--python".to_owned());
|
||||
venv_args.push(version);
|
||||
}
|
||||
if let Err(message) = run_uv(&directory, &owned(&venv_args), None, &program, &uv.env()).await {
|
||||
let _ = fs::remove_dir_all(&target);
|
||||
return Err(ApiError::bad_request(
|
||||
"ENV_CREATE_FAILED",
|
||||
format!("创建虚拟环境失败: {message}"),
|
||||
));
|
||||
}
|
||||
|
||||
if !packages.is_empty() {
|
||||
let python = target.join("Scripts").join("python.exe");
|
||||
let mut install_args = vec![
|
||||
"pip".to_owned(),
|
||||
"install".to_owned(),
|
||||
"--python".to_owned(),
|
||||
python.to_string_lossy().into_owned(),
|
||||
];
|
||||
install_args.extend(packages.clone());
|
||||
if let Err(message) = run_uv(
|
||||
&target,
|
||||
&owned(&install_args),
|
||||
mirror.as_ref(),
|
||||
&program,
|
||||
&uv.env(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = fs::remove_dir_all(&target);
|
||||
return Err(ApiError::bad_request(
|
||||
"ENV_CREATE_FAILED",
|
||||
format!("安装预装包失败: {message}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let summary = read_summary(&name, &target).ok_or_else(|| {
|
||||
ApiError::bad_request("ENV_CREATE_FAILED", "环境创建后没有找到 python.exe")
|
||||
})?;
|
||||
Ok((StatusCode::CREATED, Json(summary)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/envs/{name}/packages",
|
||||
tag = "python-envs",
|
||||
params(("name" = String, Path, description = "Environment name")),
|
||||
responses(
|
||||
(status = 200, description = "Installed packages", body = Vec<ManagedEnvPackage>),
|
||||
(status = 404, description = "Environment not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn list_env_packages(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
) -> ApiResult<Json<Vec<ManagedEnvPackage>>> {
|
||||
let name = validate_name(&name)?;
|
||||
let path = env_path(&name)?;
|
||||
let python = path.join("Scripts").join("python.exe");
|
||||
let uv = crate::uv::current(&state.pool).await;
|
||||
let program = uv.program();
|
||||
if crate::uv::probe_version(&program).is_none() {
|
||||
return Err(ApiError::bad_request(
|
||||
"UV_UNAVAILABLE",
|
||||
"uv 不可用:请先在「Python 环境」页下载或指定 uv",
|
||||
));
|
||||
}
|
||||
let arguments = [
|
||||
"pip".to_owned(),
|
||||
"list".to_owned(),
|
||||
"--python".to_owned(),
|
||||
python.to_string_lossy().into_owned(),
|
||||
"--format".to_owned(),
|
||||
"json".to_owned(),
|
||||
];
|
||||
let stdout = run_uv(&path, &owned(&arguments), None, &program, &uv.env())
|
||||
.await
|
||||
.map_err(|message| {
|
||||
ApiError::bad_request(
|
||||
"ENV_PACKAGES_FAILED",
|
||||
format!("读取已安装包失败: {message}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let packages = serde_json::from_str::<Vec<serde_json::Value>>(&stdout)
|
||||
.map_err(ApiError::internal)?
|
||||
.into_iter()
|
||||
.filter_map(|value| {
|
||||
let name = value.get("name")?.as_str()?.to_owned();
|
||||
let version = value
|
||||
.get("version")?
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
Some(ManagedEnvPackage { name, version })
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(packages))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/python/envs/{name}",
|
||||
tag = "python-envs",
|
||||
params(("name" = String, Path, description = "Environment name")),
|
||||
responses(
|
||||
(status = 204, description = "Environment deleted"),
|
||||
(status = 404, description = "Environment not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn delete_env(AxumPath(name): AxumPath<String>) -> ApiResult<StatusCode> {
|
||||
let name = validate_name(&name)?;
|
||||
let path = env_path(&name)?;
|
||||
fs::remove_dir_all(&path).map_err(ApiError::internal)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ManagedPython {
|
||||
pub version: String,
|
||||
/// uv 下载到平台目录下的解释器路径
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct InstallPythonRequest {
|
||||
/// 要下载的版本,例如 `3.12` 或 `3.12.4`
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
fn version_key(version: &str) -> Vec<u32> {
|
||||
version
|
||||
.split('.')
|
||||
.map(|part| part.parse().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 只返回 uv 下载到平台目录里的解释器(忽略系统里已有的 Python)。
|
||||
async fn installed_pythons(pool: &sqlx::SqlitePool) -> Result<Vec<ManagedPython>, String> {
|
||||
let uv = crate::uv::current(pool).await;
|
||||
let program = uv.program();
|
||||
if crate::uv::probe_version(&program).is_none() {
|
||||
return Err("uv 不可用:请先在「Python 环境」页下载或指定 uv".to_owned());
|
||||
}
|
||||
let directory = uv.python_install_dir();
|
||||
fs::create_dir_all(&directory).map_err(|error| format!("无法创建 Python 目录: {error}"))?;
|
||||
let arguments = [
|
||||
"python".to_owned(),
|
||||
"list".to_owned(),
|
||||
"--only-installed".to_owned(),
|
||||
"--output-format".to_owned(),
|
||||
"json".to_owned(),
|
||||
];
|
||||
let stdout = run_uv(&directory, &owned(&arguments), None, &program, &uv.env()).await?;
|
||||
|
||||
let prefix = directory.to_string_lossy().to_lowercase();
|
||||
let mut pythons: Vec<ManagedPython> = serde_json::from_str::<Vec<serde_json::Value>>(&stdout)
|
||||
.map_err(|error| format!("解析 uv python list 失败: {error}"))?
|
||||
.into_iter()
|
||||
.filter_map(|value| {
|
||||
let version = value.get("version")?.as_str()?.to_owned();
|
||||
// uv 按自己的 cwd 输出相对路径(这里的 cwd 就是平台 Python 目录),先补成绝对路径。
|
||||
let path = Path::new(value.get("path")?.as_str()?);
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
directory.join(path)
|
||||
};
|
||||
let path = absolute.to_string_lossy().into_owned();
|
||||
path.to_lowercase()
|
||||
.starts_with(&prefix)
|
||||
.then_some(ManagedPython { version, path })
|
||||
})
|
||||
.collect();
|
||||
pythons.sort_by_key(|python| version_key(&python.version));
|
||||
pythons.dedup_by(|left, right| left.version == right.version);
|
||||
Ok(pythons)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/pythons",
|
||||
tag = "python-envs",
|
||||
responses(
|
||||
(status = 200, description = "Pythons downloaded by uv into the platform directory", body = Vec<ManagedPython>),
|
||||
(status = 400, description = "uv unavailable", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn list_pythons(State(state): State<AppState>) -> ApiResult<Json<Vec<ManagedPython>>> {
|
||||
let pythons = installed_pythons(&state.pool)
|
||||
.await
|
||||
.map_err(|message| ApiError::bad_request("UV_UNAVAILABLE", message))?;
|
||||
Ok(Json(pythons))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/pythons",
|
||||
tag = "python-envs",
|
||||
request_body = InstallPythonRequest,
|
||||
responses(
|
||||
(status = 200, description = "Python downloaded; returns the installed list", body = Vec<ManagedPython>),
|
||||
(status = 400, description = "Validation or uv failure", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn install_python(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<InstallPythonRequest>,
|
||||
) -> ApiResult<Json<Vec<ManagedPython>>> {
|
||||
let version = validate_python_version(Some(&request.version))?
|
||||
.ok_or_else(|| ApiError::validation("version", "请填写要下载的 Python 版本,例如 3.12"))?;
|
||||
let uv = crate::uv::current(&state.pool).await;
|
||||
let program = uv.program();
|
||||
if crate::uv::probe_version(&program).is_none() {
|
||||
return Err(ApiError::bad_request(
|
||||
"UV_UNAVAILABLE",
|
||||
"uv 不可用:请先在「Python 环境」页下载或指定 uv",
|
||||
));
|
||||
}
|
||||
let directory = uv.python_install_dir();
|
||||
fs::create_dir_all(&directory).map_err(ApiError::internal)?;
|
||||
|
||||
let arguments = ["python".to_owned(), "install".to_owned(), version.clone()];
|
||||
run_uv(&directory, &owned(&arguments), None, &program, &uv.env())
|
||||
.await
|
||||
.map_err(|message| {
|
||||
ApiError::bad_request(
|
||||
"PYTHON_INSTALL_FAILED",
|
||||
format!("下载 Python {version} 失败: {message}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let pythons = installed_pythons(&state.pool)
|
||||
.await
|
||||
.map_err(|message| ApiError::bad_request("UV_UNAVAILABLE", message))?;
|
||||
Ok(Json(pythons))
|
||||
}
|
||||
|
||||
fn owned(arguments: &[String]) -> Vec<&str> {
|
||||
arguments.iter().map(String::as_str).collect()
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/python/envs", get(list_envs).post(create_env))
|
||||
.route(
|
||||
"/api/v1/python/envs/{name}/packages",
|
||||
get(list_env_packages),
|
||||
)
|
||||
.route("/api/v1/python/envs/{name}", delete(delete_env))
|
||||
.route(
|
||||
"/api/v1/python/pythons",
|
||||
get(list_pythons).post(install_python),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_safe_environment_names() {
|
||||
assert_eq!(validate_name(" data-science ").unwrap(), "data-science");
|
||||
assert_eq!(validate_name("py3.11_web").unwrap(), "py3.11_web");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_environment_names() {
|
||||
for name in ["", "..", ".hidden", "a/b", "a\\b", "a b", "环境"] {
|
||||
assert!(validate_name(name).is_err(), "{name} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_python_versions() {
|
||||
assert_eq!(
|
||||
validate_python_version(Some(" 3.11 ")).unwrap().as_deref(),
|
||||
Some("3.11")
|
||||
);
|
||||
assert!(validate_python_version(Some("latest")).is_err());
|
||||
assert!(validate_python_version(Some("3.11 --system")).is_err());
|
||||
assert!(validate_python_version(None).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_packages_that_look_like_flags() {
|
||||
assert!(validate_packages(&["--index-url=http://x".to_owned()]).is_err());
|
||||
assert!(validate_packages(&["numpy==2.1.0".to_owned()]).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -437,7 +437,7 @@ impl TaskExecutor {
|
||||
} => {
|
||||
let script_language =
|
||||
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter));
|
||||
let prepared = match prepare_script_environment(
|
||||
let mut prepared = match prepare_script_environment(
|
||||
working_dir,
|
||||
interpreter,
|
||||
venv.as_deref(),
|
||||
@@ -448,58 +448,46 @@ impl TaskExecutor {
|
||||
return failed_outcome(format!("准备 Python 运行环境失败: {error}"));
|
||||
}
|
||||
};
|
||||
let mut notes = Vec::new();
|
||||
|
||||
// 环境在保存任务时创建;运行期只做校验,避免跑到一半才开始装依赖。
|
||||
if prepared.virtual_env.is_none() && script_language == ScriptLanguage::Python {
|
||||
return failed_outcome(
|
||||
"项目还没有 .venv;请在编辑器里用 uv 创建虚拟环境(新建任务时会自动创建)",
|
||||
);
|
||||
}
|
||||
if let Some(venv) = &prepared.virtual_env {
|
||||
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
|
||||
}
|
||||
|
||||
// 平台已经不依赖系统 PATH 的 uv:把平台副本放进任务进程的 PATH,
|
||||
// 脚本里直接写 `uv ...` 也能用,而且和平台是同一个版本。
|
||||
let uv = crate::uv::current(&self.inner.pool).await;
|
||||
if script_language == ScriptLanguage::Python
|
||||
&& let Some(directory) = uv.program().parent()
|
||||
&& let Err(error) = prepared.prepend_path(directory)
|
||||
{
|
||||
tracing::warn!(%error, "failed to prepend the managed uv directory to PATH");
|
||||
}
|
||||
|
||||
let mut command = Command::new(&prepared.interpreter);
|
||||
prepared.apply(&mut command);
|
||||
|
||||
let mut notes = Vec::new();
|
||||
if let Some(venv) = &prepared.virtual_env {
|
||||
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
|
||||
} else if script_language == ScriptLanguage::Python {
|
||||
// Python 任务强制使用项目虚拟环境:没有 .venv 时不再悄悄退回系统解释器。
|
||||
let project = crate::python_env::scan_project_files(working_dir);
|
||||
if project.has_pyproject || !project.requirements.is_empty() {
|
||||
return failed_outcome(
|
||||
"项目有 pyproject.toml / requirements.txt,但还没有 .venv;请先在编辑器中用 uv 创建虚拟环境",
|
||||
);
|
||||
}
|
||||
return failed_outcome(
|
||||
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt;请先创建 .venv 或补充依赖文件",
|
||||
);
|
||||
}
|
||||
// uv 的平台配置(UV_PYTHON_INSTALL_DIR、页面里配的 UV_*)和默认镜像也带进任务进程;
|
||||
// 任务自己配的环境变量优先。
|
||||
if script_language == ScriptLanguage::Python {
|
||||
match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
||||
{
|
||||
Ok(Some(mirror)) => {
|
||||
if !env_has(env, "PIP_INDEX_URL") {
|
||||
command.env("PIP_INDEX_URL", &mirror.index_url);
|
||||
}
|
||||
if !env_has(env, "UV_INDEX_URL") {
|
||||
command.env("UV_INDEX_URL", &mirror.index_url);
|
||||
}
|
||||
if !env_has(env, "UV_DEFAULT_INDEX") {
|
||||
command.env("UV_DEFAULT_INDEX", &mirror.index_url);
|
||||
}
|
||||
if let Some(host) = mirror
|
||||
.trusted_host
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
&& !env_has(env, "PIP_TRUSTED_HOST")
|
||||
{
|
||||
command.env("PIP_TRUSTED_HOST", host);
|
||||
}
|
||||
notes.push(format!(
|
||||
"Python 包镜像: {} ({})",
|
||||
mirror.name, mirror.index_url
|
||||
));
|
||||
for (key, value) in uv.env() {
|
||||
if !env_has(env, &key) {
|
||||
command.env(key, value);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
task_id = %task.id,
|
||||
%error,
|
||||
"failed to load default python mirror"
|
||||
);
|
||||
}
|
||||
if let Ok(Some(mirror)) =
|
||||
crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
||||
{
|
||||
for key in ["UV_DEFAULT_INDEX", "UV_INDEX_URL"] {
|
||||
if !env_has(env, key) {
|
||||
command.env(key, &mirror.index_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::SqlitePool;
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiJson, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const UV_EXECUTABLE: &str = "uv.exe";
|
||||
/// 自己下载的 uv 放在程序目录下的这个子目录里。
|
||||
const UV_DIRECTORY: &str = "uv";
|
||||
/// uv 下载的 Python 放在程序目录下的这个子目录里(相对路径默认值)。
|
||||
const PYTHON_DIR_DEFAULT: &str = "python";
|
||||
/// uv 的缓存目录默认值(相对程序目录)。
|
||||
const CACHE_DIR_DEFAULT: &str = "uv-cache";
|
||||
const PYTHON_INSTALL_DIR_ENV: &str = "UV_PYTHON_INSTALL_DIR";
|
||||
const CACHE_DIR_ENV: &str = "UV_CACHE_DIR";
|
||||
/// 没有本机 uv 时从这里下载(中科大维护的 GitHub Release 镜像)。
|
||||
const UV_MIRROR: &str = "https://mirrors.ustc.edu.cn/github-release/astral-sh/uv/LatestRelease";
|
||||
const MAX_UV_ARCHIVE_BYTES: u64 = 200 * 1024 * 1024;
|
||||
const MAX_UV_ENV_ENTRIES: usize = 32;
|
||||
const MAX_UV_ENV_VALUE_CHARS: usize = 512;
|
||||
|
||||
/// uv 从哪里来。只用平台自己认的副本,不读系统 PATH:PATH 上的版本和可用性
|
||||
/// 会随启动方式变化,不适合当调度器的隐式依赖。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UvSource {
|
||||
/// 程序目录 `uv\uv.exe`:下载或自己放进来
|
||||
Managed,
|
||||
/// 用户指定路径的 uv
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl UvSource {
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Managed => "managed",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
// 旧版本存过 system;现在统一当成程序目录 uv,由用户下载或指定。
|
||||
"system" | "managed" => Some(Self::Managed),
|
||||
"custom" => Some(Self::Custom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UvSettings {
|
||||
pub source: UvSource,
|
||||
pub custom_path: Option<String>,
|
||||
pub extra_env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for UvSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UvSettings {
|
||||
/// 执行时真正调用的 uv 程序。
|
||||
pub fn program(&self) -> PathBuf {
|
||||
match self.source {
|
||||
UvSource::Managed => managed_directory().join(UV_EXECUTABLE),
|
||||
UvSource::Custom => self
|
||||
.custom_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| managed_directory().join(UV_EXECUTABLE)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入给 uv 的环境变量:两个路径变量没配时用相对默认值(`python` / `uv-cache`),
|
||||
/// 注入前统一解析成绝对路径——uv 的 cwd 会变,相对路径不能直接交给它。
|
||||
pub fn env(&self) -> BTreeMap<String, String> {
|
||||
let mut env = self.extra_env.clone();
|
||||
for (key, default) in UV_PATH_DEFAULTS {
|
||||
let value = env
|
||||
.get(key)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(default)
|
||||
.trim()
|
||||
.to_owned();
|
||||
let resolved = resolve_platform_path(&value);
|
||||
env.insert(key.to_owned(), resolved.to_string_lossy().into_owned());
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// uv 下载 Python 的目录(解析后的绝对路径)。
|
||||
pub fn python_install_dir(&self) -> PathBuf {
|
||||
let value = self
|
||||
.extra_env
|
||||
.get(PYTHON_INSTALL_DIR_ENV)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(PYTHON_DIR_DEFAULT);
|
||||
resolve_platform_path(value)
|
||||
}
|
||||
|
||||
/// uv 的缓存目录(解析后的绝对路径)。
|
||||
pub fn cache_dir(&self) -> PathBuf {
|
||||
let value = self
|
||||
.extra_env
|
||||
.get(CACHE_DIR_ENV)
|
||||
.map(String::as_str)
|
||||
.unwrap_or(CACHE_DIR_DEFAULT);
|
||||
resolve_platform_path(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// 两个路径型 UV 变量的默认值:相对程序目录,随包移动;也可以改成绝对路径。
|
||||
const UV_PATH_DEFAULTS: [(&str, &str); 2] = [
|
||||
(PYTHON_INSTALL_DIR_ENV, PYTHON_DIR_DEFAULT),
|
||||
(CACHE_DIR_ENV, CACHE_DIR_DEFAULT),
|
||||
];
|
||||
|
||||
/// 相对路径按程序目录解析,绝对路径原样返回。
|
||||
fn resolve_platform_path(value: &str) -> PathBuf {
|
||||
let path = Path::new(value.trim());
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
executable_directory().join(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// 程序目录下的 `uv` 目录(uv 下载后放这里)。
|
||||
pub fn managed_directory() -> PathBuf {
|
||||
executable_directory().join(UV_DIRECTORY)
|
||||
}
|
||||
|
||||
fn executable_directory() -> PathBuf {
|
||||
env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(Path::to_path_buf))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// 运行 `<program> --version`;不是可用的 uv 时返回 `None`。
|
||||
pub fn probe_version(program: &Path) -> Option<String> {
|
||||
let mut command = std::process::Command::new(program);
|
||||
command
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
let output = command.output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_owned();
|
||||
version
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("uv ")
|
||||
.then_some(version)
|
||||
}
|
||||
|
||||
/// 当前架构对应的 Windows 压缩包名。
|
||||
const fn archive_name() -> &'static str {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"uv-aarch64-pc-windows-msvc.zip"
|
||||
} else {
|
||||
"uv-x86_64-pc-windows-msvc.zip"
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `<zip>.sha256` 里取第一段十六进制摘要。
|
||||
fn parse_sha256(text: &str) -> Option<String> {
|
||||
let token = text.split_whitespace().next()?;
|
||||
(token.len() == 64 && token.chars().all(|character| character.is_ascii_hexdigit()))
|
||||
.then(|| token.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn hex_digest(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
let mut hex = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
fn extract_uv_exe(archive: &[u8], destination: &Path) -> Result<(), String> {
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(archive))
|
||||
.map_err(|error| format!("uv 压缩包无效: {error}"))?;
|
||||
let mut entry = archive
|
||||
.by_name(UV_EXECUTABLE)
|
||||
.map_err(|_| format!("uv 压缩包里没有 {UV_EXECUTABLE}"))?;
|
||||
let mut file =
|
||||
std::fs::File::create(destination).map_err(|error| format!("无法写入 uv: {error}"))?;
|
||||
std::io::copy(&mut entry, &mut file).map_err(|error| format!("解压 uv 失败: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解压到临时文件、验证可运行、再替换掉旧的 uv。
|
||||
fn install_archive(archive: &[u8], managed: &Path) -> Result<String, String> {
|
||||
std::fs::create_dir_all(managed).map_err(|error| format!("无法创建 uv 目录: {error}"))?;
|
||||
let target = managed.join(UV_EXECUTABLE);
|
||||
let temporary = managed.join(format!("uv-download-{}.tmp", Uuid::new_v4().simple()));
|
||||
|
||||
extract_uv_exe(archive, &temporary)?;
|
||||
if probe_version(&temporary).is_none() {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
return Err("下载的 uv 无法运行,已丢弃".to_owned());
|
||||
}
|
||||
if target.exists() {
|
||||
std::fs::remove_file(&target).map_err(|error| format!("无法替换旧的 uv: {error}"))?;
|
||||
}
|
||||
std::fs::rename(&temporary, &target).map_err(|error| format!("无法安装 uv: {error}"))?;
|
||||
|
||||
probe_version(&target).ok_or_else(|| "安装后的 uv 无法运行".to_owned())
|
||||
}
|
||||
|
||||
/// 从镜像下载当前架构的 uv,校验 sha256 后安装到程序目录的 `uv` 文件夹。
|
||||
async fn download_and_install() -> Result<String, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.user_agent("TaskPilot/0.1")
|
||||
.build()
|
||||
.map_err(|error| format!("无法创建下载客户端: {error}"))?;
|
||||
let url = format!("{UV_MIRROR}/{}", archive_name());
|
||||
|
||||
let checksum = client
|
||||
.get(format!("{url}.sha256"))
|
||||
.send()
|
||||
.await
|
||||
.and_then(reqwest::Response::error_for_status)
|
||||
.map_err(|error| format!("下载 uv 校验文件失败: {error}"))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取 uv 校验文件失败: {error}"))?;
|
||||
let expected = parse_sha256(&checksum).ok_or_else(|| "uv 校验文件格式不正确".to_owned())?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.and_then(reqwest::Response::error_for_status)
|
||||
.map_err(|error| format!("下载 uv 失败: {error}"))?;
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > MAX_UV_ARCHIVE_BYTES)
|
||||
{
|
||||
return Err("uv 压缩包超出大小上限".to_owned());
|
||||
}
|
||||
let archive = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| format!("读取 uv 压缩包失败: {error}"))?;
|
||||
if archive.len() as u64 > MAX_UV_ARCHIVE_BYTES {
|
||||
return Err("uv 压缩包超出大小上限".to_owned());
|
||||
}
|
||||
if hex_digest(&Sha256::digest(&archive)) != expected {
|
||||
return Err("uv 压缩包 sha256 校验失败".to_owned());
|
||||
}
|
||||
|
||||
let managed = managed_directory();
|
||||
tokio::task::spawn_blocking(move || install_archive(&archive, &managed))
|
||||
.await
|
||||
.map_err(|error| format!("安装 uv 失败: {error}"))?
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UvCandidate {
|
||||
pub source: UvSource,
|
||||
pub path: String,
|
||||
pub available: bool,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UvSettingsResponse {
|
||||
pub source: UvSource,
|
||||
pub custom_path: Option<String>,
|
||||
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
|
||||
pub extra_env: BTreeMap<String, String>,
|
||||
/// 程序目录下的 uv 目录
|
||||
pub managed_dir: String,
|
||||
/// 实际生效的 Python 存放目录(绝对路径,相对值按程序目录解析)
|
||||
pub python_dir: String,
|
||||
/// 实际生效的 uv 缓存目录(绝对路径)
|
||||
pub cache_dir: String,
|
||||
/// 两个路径变量的默认值(相对程序目录),页面用来显示「默认」那一行
|
||||
pub env_defaults: BTreeMap<String, String>,
|
||||
/// 当前设置指向的 uv;不可用时 `version` 为空
|
||||
pub executable: Option<String>,
|
||||
pub version: Option<String>,
|
||||
/// 各来源的探测结果
|
||||
pub candidates: Vec<UvCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateUvSettingsRequest {
|
||||
pub source: UvSource,
|
||||
#[serde(default)]
|
||||
pub custom_path: Option<String>,
|
||||
/// 额外注入给 uv 的环境变量;只允许 `UV_` 前缀
|
||||
#[serde(default)]
|
||||
pub extra_env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// 执行期使用的 uv 设置;读取失败时回退到程序目录 uv,不让任务因此跑不起来。
|
||||
pub async fn current(pool: &SqlitePool) -> UvSettings {
|
||||
match load(pool).await {
|
||||
Ok(settings) => settings,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "读取 uv 设置失败,回退到程序目录 uv");
|
||||
UvSettings::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(source: UvSource, program: &Path) -> UvCandidate {
|
||||
let version = probe_version(program);
|
||||
UvCandidate {
|
||||
source,
|
||||
path: program.to_string_lossy().into_owned(),
|
||||
available: version.is_some(),
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(settings: &UvSettings) -> UvSettingsResponse {
|
||||
let managed_directory = managed_directory();
|
||||
let mut candidates = vec![candidate(
|
||||
UvSource::Managed,
|
||||
&managed_directory.join(UV_EXECUTABLE),
|
||||
)];
|
||||
if let Some(path) = settings
|
||||
.custom_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
candidates.push(candidate(UvSource::Custom, Path::new(path)));
|
||||
}
|
||||
let current = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.source == settings.source);
|
||||
|
||||
UvSettingsResponse {
|
||||
source: settings.source,
|
||||
custom_path: settings.custom_path.clone(),
|
||||
extra_env: settings.extra_env.clone(),
|
||||
managed_dir: managed_directory.to_string_lossy().into_owned(),
|
||||
python_dir: settings.python_install_dir().to_string_lossy().into_owned(),
|
||||
cache_dir: settings.cache_dir().to_string_lossy().into_owned(),
|
||||
env_defaults: UV_PATH_DEFAULTS
|
||||
.iter()
|
||||
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
|
||||
.collect(),
|
||||
executable: current.map(|candidate| candidate.path.clone()),
|
||||
version: current.and_then(|candidate| candidate.version.clone()),
|
||||
candidates,
|
||||
}
|
||||
}
|
||||
|
||||
/// 只允许 `UV_` 前缀:这组变量是注入给 uv 进程的,不能借它改 PATH 之类的东西。
|
||||
fn validate_extra_env(extra_env: &BTreeMap<String, String>) -> ApiResult<BTreeMap<String, String>> {
|
||||
if extra_env.len() > MAX_UV_ENV_ENTRIES {
|
||||
return Err(ApiError::validation(
|
||||
"extra_env",
|
||||
format!("环境变量最多 {MAX_UV_ENV_ENTRIES} 条"),
|
||||
));
|
||||
}
|
||||
|
||||
let mut cleaned = BTreeMap::new();
|
||||
for (name, value) in extra_env {
|
||||
let name = name.trim();
|
||||
let valid_name = name.starts_with("UV_")
|
||||
&& name.len() > 3
|
||||
&& name.chars().all(|character| {
|
||||
character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
|
||||
});
|
||||
if !valid_name {
|
||||
return Err(ApiError::validation(
|
||||
"extra_env",
|
||||
format!("环境变量名必须是大写的 UV_ 前缀: {name}"),
|
||||
));
|
||||
}
|
||||
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(ApiError::validation(
|
||||
"extra_env",
|
||||
format!("{name} 的值不能为空"),
|
||||
));
|
||||
}
|
||||
if value.chars().count() > MAX_UV_ENV_VALUE_CHARS {
|
||||
return Err(ApiError::validation(
|
||||
"extra_env",
|
||||
format!("{name} 的值不能超过 {MAX_UV_ENV_VALUE_CHARS} 个字符"),
|
||||
));
|
||||
}
|
||||
cleaned.insert(name.to_owned(), value.to_owned());
|
||||
}
|
||||
Ok(cleaned)
|
||||
}
|
||||
|
||||
fn validate(request: UpdateUvSettingsRequest) -> ApiResult<UvSettings> {
|
||||
let custom_path = request
|
||||
.custom_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned);
|
||||
|
||||
if request.source == UvSource::Custom {
|
||||
let path = custom_path.as_deref().ok_or_else(|| {
|
||||
ApiError::validation("custom_path", "请填写自己下载的 uv 可执行文件路径")
|
||||
})?;
|
||||
if !Path::new(path).is_file() {
|
||||
return Err(ApiError::validation(
|
||||
"custom_path",
|
||||
format!("找不到 uv 可执行文件: {path}"),
|
||||
));
|
||||
}
|
||||
if probe_version(Path::new(path)).is_none() {
|
||||
return Err(ApiError::validation(
|
||||
"custom_path",
|
||||
"该文件无法运行 `uv --version`,它可能不是 uv",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UvSettings {
|
||||
source: request.source,
|
||||
custom_path,
|
||||
extra_env: validate_extra_env(&request.extra_env)?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn load(pool: &SqlitePool) -> ApiResult<UvSettings> {
|
||||
let row = sqlx::query_as::<_, (String, Option<String>, String)>(
|
||||
"SELECT source, custom_path, extra_env FROM uv_settings WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let Some((source, custom_path, extra_env)) = row else {
|
||||
return Ok(UvSettings::default());
|
||||
};
|
||||
Ok(UvSettings {
|
||||
source: UvSource::parse(&source).unwrap_or(UvSource::Managed),
|
||||
custom_path,
|
||||
extra_env: serde_json::from_str(&extra_env).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn save(pool: &SqlitePool, settings: &UvSettings) -> ApiResult<()> {
|
||||
let extra_env = serde_json::to_string(&settings.extra_env)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO uv_settings (id, source, custom_path, extra_env, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
custom_path = excluded.custom_path,
|
||||
extra_env = excluded.extra_env,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(settings.source.as_str())
|
||||
.bind(settings.custom_path.as_deref())
|
||||
.bind(extra_env)
|
||||
.bind(Utc::now())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/uv",
|
||||
tag = "uv",
|
||||
responses(
|
||||
(status = 200, description = "uv settings and detected executables", body = UvSettingsResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_uv_settings(State(state): State<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
|
||||
let settings = load(&state.pool).await?;
|
||||
tokio::task::spawn_blocking(move || describe(&settings))
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(ApiError::internal)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/uv",
|
||||
tag = "uv",
|
||||
request_body = UpdateUvSettingsRequest,
|
||||
responses(
|
||||
(status = 200, description = "uv settings saved", body = UvSettingsResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn update_uv_settings(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<UpdateUvSettingsRequest>,
|
||||
) -> ApiResult<Json<UvSettingsResponse>> {
|
||||
let settings = tokio::task::spawn_blocking(move || validate(request))
|
||||
.await
|
||||
.map_err(ApiError::internal)??;
|
||||
save(&state.pool, &settings).await?;
|
||||
tokio::task::spawn_blocking(move || describe(&settings))
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(ApiError::internal)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/uv/download",
|
||||
tag = "uv",
|
||||
responses(
|
||||
(status = 200, description = "uv downloaded from the mirror and switched to it", body = UvSettingsResponse),
|
||||
(status = 400, description = "Download, verification or install failed", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn download_uv(State(state): State<AppState>) -> ApiResult<Json<UvSettingsResponse>> {
|
||||
// shortcut: 并发下载不互斥,后完成的覆盖先完成的;单用户本地工具够用,需要时加互斥。
|
||||
let version = download_and_install()
|
||||
.await
|
||||
.map_err(|message| ApiError::bad_request("UV_DOWNLOAD_FAILED", message))?;
|
||||
tracing::info!(%version, "downloaded uv into the managed directory");
|
||||
|
||||
let mut settings = load(&state.pool).await?;
|
||||
// 下载的目的就是让它生效,所以直接切到程序目录 uv。
|
||||
settings.source = UvSource::Managed;
|
||||
save(&state.pool, &settings).await?;
|
||||
tokio::task::spawn_blocking(move || describe(&settings))
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(ApiError::internal)
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/v1/uv", get(get_uv_settings).put(update_uv_settings))
|
||||
.route("/api/v1/uv/download", post(download_uv))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn managed_directory_sits_next_to_the_binary() {
|
||||
let managed = managed_directory();
|
||||
|
||||
assert_eq!(managed.file_name(), Some(std::ffi::OsStr::new("uv")));
|
||||
assert_eq!(managed.parent(), env::current_exe().unwrap().parent());
|
||||
}
|
||||
|
||||
fn request(source: UvSource, custom_path: Option<&str>) -> UpdateUvSettingsRequest {
|
||||
UpdateUvSettingsRequest {
|
||||
source,
|
||||
custom_path: custom_path.map(str::to_owned),
|
||||
extra_env: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn program_follows_the_configured_source() {
|
||||
let custom = UvSettings {
|
||||
source: UvSource::Custom,
|
||||
custom_path: Some(r"C:\tools\uv\uv.exe".to_owned()),
|
||||
extra_env: BTreeMap::new(),
|
||||
};
|
||||
assert_eq!(custom.program(), PathBuf::from(r"C:\tools\uv\uv.exe"));
|
||||
|
||||
let managed = UvSettings {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env: BTreeMap::new(),
|
||||
};
|
||||
assert_eq!(managed.program(), managed_directory().join(UV_EXECUTABLE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_custom_source_without_a_runnable_uv() {
|
||||
assert!(validate(request(UvSource::Custom, None)).is_err());
|
||||
assert!(
|
||||
validate(request(
|
||||
UvSource::Custom,
|
||||
Some(r"C:\definitely\not\here\uv.exe")
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_custom_path_when_switching_away_from_custom() {
|
||||
let settings = validate(request(UvSource::Managed, Some(" C:\\tools\\uv.exe "))).unwrap();
|
||||
|
||||
assert_eq!(settings.source, UvSource::Managed);
|
||||
assert_eq!(settings.custom_path.as_deref(), Some(r"C:\tools\uv.exe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_uv_prefixed_environment_variables() {
|
||||
let mut extra_env = BTreeMap::new();
|
||||
extra_env.insert("UV_CACHE_DIR".to_owned(), " D:\\uv-cache ".to_owned());
|
||||
extra_env.insert("UV_HTTP_TIMEOUT".to_owned(), "120".to_owned());
|
||||
let settings = validate(UpdateUvSettingsRequest {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
settings.extra_env.get("UV_CACHE_DIR").map(String::as_str),
|
||||
Some("D:\\uv-cache")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_uv_environment_variables() {
|
||||
for name in ["PATH", "uv_cache_dir", "UV_", "UV-CACHE"] {
|
||||
let mut extra_env = BTreeMap::new();
|
||||
extra_env.insert(name.to_owned(), "x".to_owned());
|
||||
let result = validate(UpdateUvSettingsRequest {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env,
|
||||
});
|
||||
assert!(result.is_err(), "{name} should be rejected");
|
||||
}
|
||||
|
||||
let mut extra_env = BTreeMap::new();
|
||||
extra_env.insert("UV_CACHE_DIR".to_owned(), " ".to_owned());
|
||||
assert!(
|
||||
validate(UpdateUvSettingsRequest {
|
||||
source: UvSource::Managed,
|
||||
custom_path: None,
|
||||
extra_env,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_windows_archive_for_this_architecture() {
|
||||
let name = archive_name();
|
||||
|
||||
assert!(name.starts_with("uv-"));
|
||||
assert!(name.ends_with("-pc-windows-msvc.zip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_sha256_sidecar() {
|
||||
let digest = "0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let text = format!("{digest} uv-x86_64-pc-windows-msvc.zip\n");
|
||||
|
||||
assert_eq!(
|
||||
parse_sha256(&text).as_deref(),
|
||||
Some(digest.to_ascii_lowercase().as_str())
|
||||
);
|
||||
assert!(parse_sha256("not a checksum").is_none());
|
||||
assert!(parse_sha256("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_uv_from_the_archive() {
|
||||
use std::io::Write;
|
||||
|
||||
let mut buffer = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(&mut buffer);
|
||||
writer
|
||||
.start_file(UV_EXECUTABLE, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
writer.write_all(b"fake uv").unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
let archive = buffer.into_inner();
|
||||
let directory = tempfile::TempDir::new().unwrap();
|
||||
let target = directory.path().join(UV_EXECUTABLE);
|
||||
|
||||
extract_uv_exe(&archive, &target).unwrap();
|
||||
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"fake uv");
|
||||
}
|
||||
}
|
||||
@@ -225,17 +225,16 @@ async fn venv_probe_reports_project_dependency_files() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn venv_create_rejects_project_without_dependency_files() {
|
||||
async fn venv_create_rejects_missing_working_directory() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
let token = fetch_token(&app).await;
|
||||
let project = temp_dir.path().join("empty-project");
|
||||
fs::create_dir_all(&project).expect("project directory should be created");
|
||||
let absent = temp_dir.path().join("absent-project");
|
||||
|
||||
let (status, body) = post_create(
|
||||
&app,
|
||||
Some(&token),
|
||||
json!({
|
||||
"working_dir": project.to_str().expect("project path should be UTF-8")
|
||||
"working_dir": absent.to_str().expect("absent path should be UTF-8")
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -246,7 +245,7 @@ async fn venv_create_rejects_project_without_dependency_files() {
|
||||
body["message"]
|
||||
.as_str()
|
||||
.expect("message should be a string")
|
||||
.contains("pyproject.toml")
|
||||
.contains("工作目录")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+46
-5
@@ -201,6 +201,46 @@ async fn wait_for_run(router: &Router, run_id: i64) -> Value {
|
||||
panic!("run {run_id} did not reach a terminal state");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn python_task_without_venv_fails_with_editor_hint() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().join("bare-python");
|
||||
fs::create_dir_all(&working_dir).expect("working directory should be created");
|
||||
fs::write(working_dir.join("main.py"), "print('hi')\n").expect("script should be written");
|
||||
|
||||
let mut task = script_task(
|
||||
"bare_python",
|
||||
&working_dir.to_string_lossy(),
|
||||
"python.exe",
|
||||
"main.py",
|
||||
vec![],
|
||||
"0 0 2 * * *",
|
||||
10,
|
||||
);
|
||||
task["action"]["language"] = json!("python");
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
|
||||
let run_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
"/api/v1/tasks/bare_python/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
|
||||
let run = response_json(run_response).await;
|
||||
let completed = wait_for_run(&app.router, run["id"].as_i64().unwrap()).await;
|
||||
|
||||
assert_eq!(completed["status"], "failed");
|
||||
let message = completed["error_message"]
|
||||
.as_str()
|
||||
.expect("failed run should carry an error message");
|
||||
// 运行期不再自动装依赖,只提示去编辑器里创建。
|
||||
assert!(message.contains("编辑器里用 uv 创建"), "{message}");
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_language_runs_batch_file_with_cmd_c() {
|
||||
let app = test_app().await;
|
||||
@@ -706,11 +746,12 @@ async fn scheduler_runs_tasks_and_tracks_enable_state() {
|
||||
)
|
||||
.await;
|
||||
let runs = response_json(response).await;
|
||||
if let Some(item) = runs["items"]
|
||||
.as_array()
|
||||
.and_then(|items| items.first())
|
||||
.filter(|item| item["trigger"] == "schedule")
|
||||
{
|
||||
// 每秒触发一次,机器忙时上一轮还没结束,最新一条可能是 skipped;取真正跑起来那条。
|
||||
if let Some(item) = runs["items"].as_array().and_then(|items| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item["trigger"] == "schedule" && item["status"] != "skipped")
|
||||
}) {
|
||||
scheduled_run = Some(item.clone());
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
use std::{fs, path::Path};
|
||||
|
||||
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_json(
|
||||
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 uv_settings_default_to_the_managed_uv() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["source"], "managed");
|
||||
assert_eq!(body["custom_path"], Value::Null);
|
||||
let managed_dir = body["managed_dir"]
|
||||
.as_str()
|
||||
.expect("managed_dir should be a string");
|
||||
assert_eq!(
|
||||
Path::new(managed_dir).file_name().unwrap(),
|
||||
std::ffi::OsStr::new("uv")
|
||||
);
|
||||
// 不再读系统 PATH,候选里只有程序目录 uv(自定义路径未设置时也不出现)。
|
||||
let sources = body["candidates"]
|
||||
.as_array()
|
||||
.expect("candidates should be an array")
|
||||
.iter()
|
||||
.map(|candidate| candidate["source"].as_str().unwrap().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(sources, vec!["managed"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uv_settings_reject_unusable_custom_path_and_persist_valid_ones() {
|
||||
let (app, temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({ "source": "custom", "custom_path": null })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "custom_path");
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({ "source": "custom", "custom_path": "C:\\missing\\uv.exe" })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "custom_path");
|
||||
|
||||
// 文件存在但不是可运行的 uv,也要拒绝保存。
|
||||
let fake = temp_dir.path().join("fake.exe");
|
||||
fs::write(&fake, b"not a program").expect("fake executable should be written");
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"source": "custom",
|
||||
"custom_path": fake.to_string_lossy()
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "custom_path");
|
||||
|
||||
// 切回程序目录 uv 时保留已填写的自定义路径,方便再次切换。
|
||||
let (status, saved) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"source": "managed",
|
||||
"custom_path": " C:\\tools\\uv.exe "
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(saved["source"], "managed");
|
||||
assert_eq!(saved["custom_path"], "C:\\tools\\uv.exe");
|
||||
|
||||
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["source"], "managed");
|
||||
assert_eq!(body["custom_path"], "C:\\tools\\uv.exe");
|
||||
assert!(
|
||||
body["candidates"]
|
||||
.as_array()
|
||||
.expect("candidates should be an array")
|
||||
.iter()
|
||||
.any(|candidate| candidate["source"] == "custom")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uv_settings_mutations_require_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
"/api/v1/uv",
|
||||
None,
|
||||
Some(json!({ "source": "managed", "custom_path": null })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uv_download_requires_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(&app, Method::POST, "/api/v1/uv/download", None, None).await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
Reference in New Issue
Block a user