feat: 左侧导航改版与 Python 镜像管理
- 应用外壳换成 stash-ui Layout:左侧导航(任务调度 / Python 镜像)+ 页面标题区,窄屏折叠为顶部导航 - 新增 python_mirrors 表与 CRUD/设默认接口,内置清华、阿里云、中科大、PyPI 官方 - Python 脚本任务运行时注入默认镜像的 PIP_INDEX_URL / UV_INDEX_URL,任务自身环境变量优先 - 新增镜像管理页与表单,支持新增、编辑、启用/停用、设默认、删除 - 重新生成 OpenAPI 与 TS 类型;补齐后端镜像测试、前端单测与 e2e,修复窄屏页面被宽表格撑开
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE IF NOT EXISTS python_mirrors (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
index_url TEXT NOT NULL,
|
||||
trusted_host TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 同一时间只允许一个默认镜像
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_default
|
||||
ON python_mirrors(is_default)
|
||||
WHERE is_default = 1;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_python_mirrors_index_url
|
||||
ON python_mirrors(index_url);
|
||||
|
||||
INSERT OR IGNORE INTO python_mirrors
|
||||
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
|
||||
VALUES
|
||||
(
|
||||
'tsinghua',
|
||||
'清华 TUNA',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
NULL,
|
||||
1,
|
||||
1,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'aliyun',
|
||||
'阿里云',
|
||||
'https://mirrors.aliyun.com/pypi/simple/',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'ustc',
|
||||
'中科大',
|
||||
'https://pypi.mirrors.ustc.edu.cn/simple/',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
),
|
||||
(
|
||||
'pypi',
|
||||
'PyPI 官方',
|
||||
'https://pypi.org/simple',
|
||||
NULL,
|
||||
1,
|
||||
0,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
);
|
||||
@@ -20,6 +20,14 @@ use crate::{
|
||||
python_env::{
|
||||
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
|
||||
},
|
||||
python_mirrors::{
|
||||
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
||||
routes::{
|
||||
__path_create_python_mirror, __path_delete_python_mirror, __path_get_python_mirror,
|
||||
__path_list_python_mirrors, __path_set_default_python_mirror,
|
||||
__path_update_python_mirror,
|
||||
},
|
||||
},
|
||||
runs::{
|
||||
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
|
||||
routes::{
|
||||
@@ -219,6 +227,12 @@ pub async fn list_schedule(
|
||||
session,
|
||||
preview_cron,
|
||||
probe_python_venv,
|
||||
list_python_mirrors,
|
||||
get_python_mirror,
|
||||
create_python_mirror,
|
||||
update_python_mirror,
|
||||
delete_python_mirror,
|
||||
set_default_python_mirror,
|
||||
list_directories,
|
||||
task_stats,
|
||||
list_schedule,
|
||||
@@ -247,6 +261,9 @@ pub async fn list_schedule(
|
||||
VenvProbeStatus,
|
||||
VenvSource,
|
||||
InterpreterKind,
|
||||
PythonMirrorResponse,
|
||||
CreatePythonMirrorRequest,
|
||||
UpdatePythonMirrorRequest,
|
||||
DirectoryEntry,
|
||||
DirectoryListingResponse,
|
||||
TaskStatsResponse,
|
||||
@@ -305,6 +322,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/v1/schedule", get(list_schedule))
|
||||
.route("/api/v1/openapi.json", get(openapi_json))
|
||||
.merge(crate::tasks::routes::router())
|
||||
.merge(crate::python_mirrors::routes::router())
|
||||
.merge(crate::runs::routes::router())
|
||||
.route("/api/{*path}", any(api_not_found))
|
||||
// 本地工具也不需要接受超大请求体;任务载荷与日志上传都用不到 2 MiB 以上。
|
||||
|
||||
@@ -109,6 +109,26 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mirror_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
code: "MIRROR_NOT_FOUND",
|
||||
message: message.into(),
|
||||
field: None,
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mirror_conflict(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::CONFLICT,
|
||||
code: "MIRROR_CONFLICT",
|
||||
message: message.into(),
|
||||
field: Some("index_url".to_owned()),
|
||||
details: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod directory_browser;
|
||||
pub mod error;
|
||||
pub mod process_job;
|
||||
pub mod python_env;
|
||||
pub mod python_mirrors;
|
||||
pub mod runs;
|
||||
pub mod scheduler;
|
||||
pub mod security;
|
||||
|
||||
@@ -472,7 +472,7 @@ fn absolute_path(path: &Path) -> io::Result<PathBuf> {
|
||||
std::path::absolute(path)
|
||||
}
|
||||
|
||||
fn is_python_interpreter(interpreter: &str) -> bool {
|
||||
pub fn is_python_interpreter(interpreter: &str) -> bool {
|
||||
let name = Path::new(interpreter)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod model;
|
||||
pub mod repository;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
@@ -0,0 +1,52 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 一个 Python 包索引镜像(pip / uv)。
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct PythonMirrorResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// pip 的 `--index-url`,运行时注入 `PIP_INDEX_URL`
|
||||
pub index_url: String,
|
||||
/// pip 的 `--trusted-host`;https 源通常为空
|
||||
pub trusted_host: Option<String>,
|
||||
pub enabled: bool,
|
||||
/// 当前默认镜像;同一时间最多一个
|
||||
pub is_default: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreatePythonMirrorRequest {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
#[serde(default)]
|
||||
pub trusted_host: Option<String>,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdatePythonMirrorRequest {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
#[serde(default)]
|
||||
pub trusted_host: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
const fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 执行器每次运行 Python 任务时读取的默认镜像。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DefaultPythonMirror {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
pub trusted_host: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::{ApiError, ApiResult},
|
||||
python_mirrors::{
|
||||
model::{DefaultPythonMirror, PythonMirrorResponse},
|
||||
service::ValidatedMirror,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct MirrorRow {
|
||||
id: String,
|
||||
name: String,
|
||||
index_url: String,
|
||||
trusted_host: Option<String>,
|
||||
enabled: bool,
|
||||
is_default: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<MirrorRow> for PythonMirrorResponse {
|
||||
fn from(row: MirrorRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
index_url: row.index_url,
|
||||
trusted_host: row.trusted_host,
|
||||
enabled: row.enabled,
|
||||
is_default: row.is_default,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> ApiResult<Vec<PythonMirrorResponse>> {
|
||||
let rows = sqlx::query_as::<_, MirrorRow>(
|
||||
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
|
||||
FROM python_mirrors
|
||||
ORDER BY is_default DESC, enabled DESC, name COLLATE NOCASE ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
|
||||
let row = sqlx::query_as::<_, MirrorRow>(
|
||||
"SELECT id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at
|
||||
FROM python_mirrors WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::mirror_not_found(format!("镜像 {id} 不存在")))?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
pub async fn create(pool: &SqlitePool, input: ValidatedMirror) -> ApiResult<PythonMirrorResponse> {
|
||||
if index_url_exists(pool, &input.index_url, None).await? {
|
||||
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().simple().to_string();
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO python_mirrors
|
||||
(id, name, index_url, trusted_host, enabled, is_default, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&input.name)
|
||||
.bind(&input.index_url)
|
||||
.bind(input.trusted_host.as_deref())
|
||||
.bind(input.enabled)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(map_mirror_error)?;
|
||||
|
||||
get(pool, &id).await
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
input: ValidatedMirror,
|
||||
) -> ApiResult<PythonMirrorResponse> {
|
||||
if index_url_exists(pool, &input.index_url, Some(id)).await? {
|
||||
return Err(ApiError::mirror_conflict("该索引地址已经存在"));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let result = sqlx::query(
|
||||
"UPDATE python_mirrors
|
||||
SET name = ?, index_url = ?, trusted_host = ?, enabled = ?,
|
||||
is_default = CASE WHEN ? = 0 THEN 0 ELSE is_default END,
|
||||
updated_at = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&input.name)
|
||||
.bind(&input.index_url)
|
||||
.bind(input.trusted_host.as_deref())
|
||||
.bind(input.enabled)
|
||||
.bind(input.enabled)
|
||||
.bind(now)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(map_mirror_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
||||
}
|
||||
get(pool, id).await
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> ApiResult<()> {
|
||||
let result = sqlx::query("DELETE FROM python_mirrors WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设为默认:同一事务里先清掉旧默认,再把目标设为默认并启用。
|
||||
pub async fn set_default(pool: &SqlitePool, id: &str) -> ApiResult<PythonMirrorResponse> {
|
||||
let now = Utc::now();
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let exists: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM python_mirrors WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
transaction.rollback().await?;
|
||||
return Err(ApiError::mirror_not_found(format!("镜像 {id} 不存在")));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE python_mirrors SET is_default = 0, updated_at = ? WHERE is_default = 1")
|
||||
.bind(now)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE python_mirrors SET is_default = 1, enabled = 1, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
get(pool, id).await
|
||||
}
|
||||
|
||||
/// 执行器读取:只返回「启用且是默认」的镜像。
|
||||
pub async fn default_enabled(pool: &SqlitePool) -> ApiResult<Option<DefaultPythonMirror>> {
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"SELECT name, index_url, trusted_host
|
||||
FROM python_mirrors
|
||||
WHERE enabled = 1 AND is_default = 1
|
||||
LIMIT 1",
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(
|
||||
row.map(|(name, index_url, trusted_host)| DefaultPythonMirror {
|
||||
name,
|
||||
index_url,
|
||||
trusted_host,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn index_url_exists(
|
||||
pool: &SqlitePool,
|
||||
index_url: &str,
|
||||
excluding_id: Option<&str>,
|
||||
) -> ApiResult<bool> {
|
||||
let existing: Option<(String,)> = match excluding_id {
|
||||
Some(id) => {
|
||||
sqlx::query_as(
|
||||
"SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?) AND id <> ?",
|
||||
)
|
||||
.bind(index_url)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as("SELECT id FROM python_mirrors WHERE lower(index_url) = lower(?)")
|
||||
.bind(index_url)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(existing.is_some())
|
||||
}
|
||||
|
||||
fn map_mirror_error(error: sqlx::Error) -> ApiError {
|
||||
if let sqlx::Error::Database(database_error) = &error
|
||||
&& database_error.is_unique_violation()
|
||||
{
|
||||
return ApiError::mirror_conflict("该索引地址已经存在");
|
||||
}
|
||||
ApiError::from(error)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{ApiJson, ApiResult},
|
||||
python_mirrors::{
|
||||
model::{CreatePythonMirrorRequest, PythonMirrorResponse, UpdatePythonMirrorRequest},
|
||||
repository, service,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/python/mirrors",
|
||||
get(list_python_mirrors).post(create_python_mirror),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/python/mirrors/{id}",
|
||||
get(get_python_mirror)
|
||||
.put(update_python_mirror)
|
||||
.delete(delete_python_mirror),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/python/mirrors/{id}/default",
|
||||
post(set_default_python_mirror),
|
||||
)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/mirrors",
|
||||
tag = "python-mirrors",
|
||||
responses(
|
||||
(status = 200, description = "Python package mirrors", body = Vec<PythonMirrorResponse>)
|
||||
)
|
||||
)]
|
||||
pub async fn list_python_mirrors(
|
||||
State(state): State<AppState>,
|
||||
) -> ApiResult<Json<Vec<PythonMirrorResponse>>> {
|
||||
Ok(Json(repository::list(&state.pool).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/mirrors",
|
||||
tag = "python-mirrors",
|
||||
request_body = CreatePythonMirrorRequest,
|
||||
responses(
|
||||
(status = 201, description = "Mirror created", body = PythonMirrorResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
|
||||
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn create_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
ApiJson(request): ApiJson<CreatePythonMirrorRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<PythonMirrorResponse>)> {
|
||||
let input = service::validate(
|
||||
&request.name,
|
||||
&request.index_url,
|
||||
request.trusted_host.as_deref(),
|
||||
request.enabled,
|
||||
)?;
|
||||
let mirror = repository::create(&state.pool, input).await?;
|
||||
Ok((StatusCode::CREATED, Json(mirror)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/python/mirrors/{id}",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Mirror detail", body = PythonMirrorResponse),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn get_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<PythonMirrorResponse>> {
|
||||
Ok(Json(repository::get(&state.pool, &id).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/python/mirrors/{id}",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
request_body = UpdatePythonMirrorRequest,
|
||||
responses(
|
||||
(status = 200, description = "Mirror updated", body = PythonMirrorResponse),
|
||||
(status = 400, description = "Validation failed", body = crate::error::ErrorResponse),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse),
|
||||
(status = 409, description = "Mirror URL already exists", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn update_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
ApiJson(request): ApiJson<UpdatePythonMirrorRequest>,
|
||||
) -> ApiResult<Json<PythonMirrorResponse>> {
|
||||
let input = service::validate(
|
||||
&request.name,
|
||||
&request.index_url,
|
||||
request.trusted_host.as_deref(),
|
||||
request.enabled,
|
||||
)?;
|
||||
Ok(Json(repository::update(&state.pool, &id, input).await?))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/python/mirrors/{id}",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
responses(
|
||||
(status = 204, description = "Mirror deleted"),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn delete_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
repository::delete(&state.pool, &id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/python/mirrors/{id}/default",
|
||||
tag = "python-mirrors",
|
||||
params(("id" = String, Path, description = "Mirror identifier")),
|
||||
responses(
|
||||
(status = 200, description = "Default mirror updated", body = PythonMirrorResponse),
|
||||
(status = 404, description = "Mirror not found", body = crate::error::ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn set_default_python_mirror(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<PythonMirrorResponse>> {
|
||||
Ok(Json(repository::set_default(&state.pool, &id).await?))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use url::Url;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
|
||||
const MAX_NAME_CHARS: usize = 64;
|
||||
const MAX_URL_CHARS: usize = 2048;
|
||||
|
||||
/// 校验并规范化后的镜像输入。
|
||||
#[derive(Debug)]
|
||||
pub struct ValidatedMirror {
|
||||
pub name: String,
|
||||
pub index_url: String,
|
||||
pub trusted_host: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
pub fn validate(
|
||||
name: &str,
|
||||
index_url: &str,
|
||||
trusted_host: Option<&str>,
|
||||
enabled: bool,
|
||||
) -> ApiResult<ValidatedMirror> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(ApiError::validation("name", "请输入镜像名称"));
|
||||
}
|
||||
if name.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(ApiError::validation(
|
||||
"name",
|
||||
format!("镜像名称不能超过 {MAX_NAME_CHARS} 个字符"),
|
||||
));
|
||||
}
|
||||
|
||||
let index_url = index_url.trim();
|
||||
if index_url.is_empty() {
|
||||
return Err(ApiError::validation("index_url", "请输入索引地址"));
|
||||
}
|
||||
if index_url.chars().count() > MAX_URL_CHARS {
|
||||
return Err(ApiError::validation("index_url", "索引地址过长"));
|
||||
}
|
||||
let parsed = Url::parse(index_url)
|
||||
.map_err(|_| ApiError::validation("index_url", "索引地址不是有效的 URL"))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(ApiError::validation(
|
||||
"index_url",
|
||||
"索引地址只支持 http 或 https",
|
||||
));
|
||||
}
|
||||
if parsed.host_str().is_none() {
|
||||
return Err(ApiError::validation("index_url", "索引地址缺少主机名"));
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(ApiError::validation(
|
||||
"index_url",
|
||||
"索引地址不能包含用户名或密码",
|
||||
));
|
||||
}
|
||||
|
||||
let trusted_host = trusted_host
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
if value.contains("://") || value.contains('/') || value.contains(char::is_whitespace) {
|
||||
return Err(ApiError::validation(
|
||||
"trusted_host",
|
||||
"信任主机只填主机名,可带端口,例如 mirrors.aliyun.com",
|
||||
));
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok(ValidatedMirror {
|
||||
name: name.to_owned(),
|
||||
index_url: index_url.to_owned(),
|
||||
trusted_host,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{BTreeMap, HashMap},
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
sync::{
|
||||
@@ -26,7 +26,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::{
|
||||
error::ApiResult,
|
||||
process_job::ProcessJob,
|
||||
python_env::prepare_script_environment,
|
||||
python_env::{is_python_interpreter, prepare_script_environment},
|
||||
runs::{
|
||||
model::{RunStatus, RunTrigger, TaskRunResponse},
|
||||
repository,
|
||||
@@ -447,11 +447,48 @@ impl TaskExecutor {
|
||||
};
|
||||
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()));
|
||||
}
|
||||
if is_python_interpreter(interpreter) {
|
||||
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 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
|
||||
));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
task_id = %task.id,
|
||||
%error,
|
||||
"failed to load default python mirror"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command.arg(script).args(args).current_dir(working_dir);
|
||||
let startup_note = prepared
|
||||
.virtual_env
|
||||
.as_ref()
|
||||
.map(|venv| format!("使用 Python 虚拟环境: {}", venv.display()));
|
||||
let startup_note = (!notes.is_empty()).then(|| notes.join(";"));
|
||||
self.run_command(task, run_id, cancellation, command, startup_note)
|
||||
.await
|
||||
}
|
||||
@@ -1064,6 +1101,11 @@ async fn wait_after_termination(child: &mut Child) {
|
||||
}
|
||||
}
|
||||
|
||||
fn env_has(env: &BTreeMap<String, String>, key: &str) -> bool {
|
||||
env.keys()
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(key))
|
||||
}
|
||||
|
||||
fn sensitive_values(action: &TaskAction) -> Vec<String> {
|
||||
match action {
|
||||
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
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();
|
||||
let json = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
(status, json)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_python_mirrors_with_one_default() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let mirrors = body.as_array().expect("mirrors should be an array");
|
||||
assert_eq!(mirrors.len(), 4);
|
||||
let defaults: Vec<&Value> = mirrors
|
||||
.iter()
|
||||
.filter(|mirror| mirror["is_default"] == true)
|
||||
.collect();
|
||||
assert_eq!(defaults.len(), 1);
|
||||
assert_eq!(defaults[0]["id"], "tsinghua");
|
||||
assert!(mirrors.iter().all(|mirror| mirror["enabled"] == true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mirrors_crud_and_default_switch() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, created) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "内网镜像",
|
||||
"index_url": "https://pypi.example.local/simple",
|
||||
"trusted_host": "pypi.example.local",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let id = created["id"]
|
||||
.as_str()
|
||||
.expect("created mirror should have an id")
|
||||
.to_owned();
|
||||
assert_eq!(created["is_default"], false);
|
||||
assert_eq!(created["trusted_host"], "pypi.example.local");
|
||||
|
||||
let (status, mirror) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
&format!("/api/v1/python/mirrors/{id}/default"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(mirror["is_default"], true);
|
||||
|
||||
let (status, list) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let defaults = list
|
||||
.as_array()
|
||||
.expect("mirrors should be an array")
|
||||
.iter()
|
||||
.filter(|mirror| mirror["is_default"] == true)
|
||||
.count();
|
||||
assert_eq!(defaults, 1);
|
||||
|
||||
let (status, updated) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "内网镜像(停用)",
|
||||
"index_url": "https://pypi.example.local/simple",
|
||||
"trusted_host": null,
|
||||
"enabled": false
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(updated["name"], "内网镜像(停用)");
|
||||
assert_eq!(updated["enabled"], false);
|
||||
// 停用默认镜像时连带清掉默认标记,避免执行器继续引用一个停用的源
|
||||
assert_eq!(updated["is_default"], false);
|
||||
|
||||
let (status, _) = send_json(
|
||||
&app,
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::GET,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["code"], "MIRROR_NOT_FOUND");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_mirror_url_and_duplicate_index() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "坏地址",
|
||||
"index_url": "ftp://example.com/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "index_url");
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "重复镜像",
|
||||
"index_url": "https://pypi.org/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
assert_eq!(body["code"], "MIRROR_CONFLICT");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mirror_mutations_require_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
None,
|
||||
Some(json!({
|
||||
"name": "未授权",
|
||||
"index_url": "https://example.com/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
Reference in New Issue
Block a user