From 25a5804f62a27cfa39a9aeb53dce061cd25fb911 Mon Sep 17 00:00:00 2001 From: carmake Date: Tue, 23 Jun 2026 20:16:05 +0800 Subject: [PATCH] feat(templates): E2B-style display names with cached name resolution Add cluster-wide template display names (CubeMaster display_name) so sandboxes and APIs can reference templates by human-readable name. CubeMaster owns lookup, reservation at definition creation, bounded positive/negative caches with invalidation, and a DB migration with dedupe; CubeAPI exposes thin name resolution and GET /templates/lookup; Web adds create hints, list/detail rename, and i18n. Assisted-by: Cursor:Composer Signed-off-by: carmake Co-authored-by: Cursor --- CubeAPI/src/cubemaster/mod.rs | 102 ++++ CubeAPI/src/error/mod.rs | 10 + CubeAPI/src/handlers/templates.rs | 100 +++- CubeAPI/src/models/mod.rs | 42 +- CubeAPI/src/openapi.rs | 18 +- CubeAPI/src/routes.rs | 1 + CubeAPI/src/services/mod.rs | 8 +- CubeAPI/src/services/sandboxes.rs | 20 +- CubeAPI/src/services/templates.rs | 511 ++++++++++++++++-- ...0623051112_template_display_name_index.sql | 93 ++++ CubeMaster/pkg/server/server.go | 2 + .../pkg/service/httpservice/cube/cube.go | 6 + .../pkg/service/httpservice/cube/template.go | 151 +++++- .../cube/template_display_name_test.go | 162 ++++++ .../httpservice/cube/template_from_image.go | 9 +- .../service/httpservice/cube/template_test.go | 4 +- CubeMaster/pkg/service/sandbox/types/types.go | 3 + CubeMaster/pkg/templatecenter/cache.go | 111 +++- CubeMaster/pkg/templatecenter/cache_test.go | 55 ++ CubeMaster/pkg/templatecenter/delete.go | 16 +- CubeMaster/pkg/templatecenter/delete_test.go | 10 +- .../pkg/templatecenter/image_job_runner.go | 8 +- CubeMaster/pkg/templatecenter/job_repo.go | 15 +- CubeMaster/pkg/templatecenter/redo.go | 2 +- .../pkg/templatecenter/request_validation.go | 5 + CubeMaster/pkg/templatecenter/snapshot_ops.go | 5 +- CubeMaster/pkg/templatecenter/store.go | 31 +- .../templatecenter/template_display_name.go | 400 ++++++++++++++ .../template_display_name_test.go | 417 ++++++++++++++ .../pkg/templatecenter/template_image_test.go | 18 +- openapi.yml | 338 +++++++++++- web/src/api/client.ts | 42 +- web/src/api/generated/schema.ts | 297 +++++++++- web/src/locales/en/templateDetail.json | 9 + web/src/locales/en/templates.json | 7 + web/src/locales/zh/templateDetail.json | 9 + web/src/locales/zh/templates.json | 7 + web/src/pages/TemplateDetail.tsx | 121 ++++- web/src/pages/Templates.tsx | 76 ++- 39 files changed, 3078 insertions(+), 163 deletions(-) create mode 100644 CubeMaster/pkg/base/dao/migrate/migrations/mysql/20260623051112_template_display_name_index.sql create mode 100644 CubeMaster/pkg/service/httpservice/cube/template_display_name_test.go create mode 100644 CubeMaster/pkg/templatecenter/template_display_name.go create mode 100644 CubeMaster/pkg/templatecenter/template_display_name_test.go diff --git a/CubeAPI/src/cubemaster/mod.rs b/CubeAPI/src/cubemaster/mod.rs index 6da9f2b7a..e480c3289 100644 --- a/CubeAPI/src/cubemaster/mod.rs +++ b/CubeAPI/src/cubemaster/mod.rs @@ -32,6 +32,7 @@ use chrono::{DateTime, Utc}; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use uuid::Uuid; const TEMPLATE_ID_LABEL_KEY: &str = "cube.master.appsnapshot.template.id"; @@ -384,6 +385,70 @@ impl CubeMasterClient { parse_response(resp).await } + /// GET /cube/template/lookup?name=… — map a display name to its templateID. + /// This is a name-to-ID lookup, distinct from CubeMaster's internal template + /// "resolve" (node/locality binding). + pub async fn resolve_template_by_name(&self, name: &str) -> Result { + self.lookup_template_by_name(name, false).await + } + + /// Same as [`resolve_template_by_name`] but bypasses CubeMaster read cache. + pub async fn resolve_template_by_name_fresh( + &self, + name: &str, + ) -> Result { + self.lookup_template_by_name(name, true).await + } + + async fn lookup_template_by_name( + &self, + name: &str, + fresh: bool, + ) -> Result { + let url = format!("{}/cube/template/lookup", self.base_url); + let mut query = vec![("name", name)]; + if fresh { + query.push(("fresh", "true")); + } + let resp = self + .inner + .get(&url) + .query(&query) + .send() + .await + .map_err(CubeMasterError::Http)?; + let body: TemplateLookupResponse = parse_response(resp).await?; + if body.template_id.trim().is_empty() { + return Err(CubeMasterError::Api { + ret_code: 130404, + ret_msg: format!("template name {name} not found"), + }); + } + Ok(body.template_id) + } + + /// POST /cube/template/display-name — update template display name. + pub async fn update_template_display_name( + &self, + template_id: &str, + display_name: &str, + ) -> Result { + let url = format!("{}/cube/template/display-name", self.base_url); + let req = UpdateTemplateDisplayNameRequest { + request_id: Uuid::new_v4().to_string(), + template_id: template_id.to_string(), + display_name: display_name.to_string(), + }; + let resp = self + .inner + .post(&url) + .json(&req) + .send() + .await + .map_err(CubeMasterError::Http)?; + parse_response(resp).await + } + /// GET /cube/template/from-image?job_id=… — poll a create-from-image job. pub async fn get_template_from_image_job( &self, @@ -539,6 +604,17 @@ impl CubeMasterError { ) } + /// True when CubeMaster returned 130400 (parameter / validation error). + pub fn is_bad_request(&self) -> bool { + matches!( + self, + Self::Api { + ret_code: 130400, + .. + } + ) + } + pub fn is_invalid_path_parameter(&self) -> bool { matches!(self, Self::InvalidPathParameter { .. }) } @@ -1592,6 +1668,8 @@ pub struct TemplateSummaryItem { pub created_at: String, #[serde(default)] pub image_info: String, + #[serde(default)] + pub display_name: String, } /// Envelope for GET /cube/template (list mode). @@ -1629,6 +1707,8 @@ pub struct TemplateResponse { pub replicas: Vec, #[serde(default)] pub create_request: Option, + #[serde(default)] + pub display_name: String, } /// Body for DELETE /cube/template. @@ -1651,6 +1731,25 @@ pub struct RetEnvelope { pub ret: RetCode, } +/// Response for GET /cube/template/lookup. The `ret` envelope is validated +/// centrally by `parse_response`, so only the resolved id is consumed here. +#[derive(Debug, Deserialize)] +pub struct TemplateLookupResponse { + #[serde(default)] + pub template_id: String, + #[serde(default)] + pub display_name: String, +} + +/// Body for POST /cube/template/display-name. +#[derive(Debug, Serialize)] +pub struct UpdateTemplateDisplayNameRequest { + #[serde(rename = "RequestID")] + pub request_id: String, + pub template_id: String, + pub display_name: String, +} + #[derive(Debug, Deserialize, Default, Clone)] pub struct TemplateCompatSummary { #[serde(default)] @@ -1743,6 +1842,9 @@ pub struct CreateTemplateFromImageReq { pub template_id: String, /// CubeMaster field name for the source image. pub source_image_ref: String, + /// Human-readable template name (E2B `name`). + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, /// Writable layer size, e.g. "1G". #[serde(skip_serializing_if = "Option::is_none")] pub writable_layer_size: Option, diff --git a/CubeAPI/src/error/mod.rs b/CubeAPI/src/error/mod.rs index cf0f5058d..cbd443b0e 100644 --- a/CubeAPI/src/error/mod.rs +++ b/CubeAPI/src/error/mod.rs @@ -22,6 +22,12 @@ pub enum AppError { #[allow(dead_code)] BadRequest(String), + #[error("bad gateway: {0}")] + BadGateway(String), + + #[error("service unavailable: {0}")] + ServiceUnavailable(String), + #[error("internal error: {0}")] Internal(#[from] anyhow::Error), @@ -41,6 +47,10 @@ impl IntoResponse for AppError { AppError::NotFound(msg) => (StatusCode::NOT_FOUND, 404, msg.clone()), AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, 401, msg.clone()), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, 400, msg.clone()), + AppError::BadGateway(msg) => (StatusCode::BAD_GATEWAY, 502, msg.clone()), + AppError::ServiceUnavailable(msg) => { + (StatusCode::SERVICE_UNAVAILABLE, 503, msg.clone()) + } AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, 500, e.to_string()), AppError::Conflict(msg) => (StatusCode::CONFLICT, 409, msg.clone()), AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, 429, msg.clone()), diff --git a/CubeAPI/src/handlers/templates.rs b/CubeAPI/src/handlers/templates.rs index b2bcd29d5..2388b12f3 100644 --- a/CubeAPI/src/handlers/templates.rs +++ b/CubeAPI/src/handlers/templates.rs @@ -13,10 +13,11 @@ use axum::{ use serde::Deserialize; use crate::{ - error::{AppError, AppResult}, + error::AppResult, models::{ ApiError, CreateTemplateRequest, ListTemplatesQuery, RebuildTemplateRequest, - TemplateCompatAdoptResponseView, TemplateCompatMatrixView, TemplateDetail, TemplateSummary, + TemplateBuildJob, TemplateCompatAdoptResponseView, TemplateCompatMatrixView, + TemplateDetail, TemplateNameLookupResponse, TemplateSummary, UpdateTemplateRequest, }, state::AppState, }; @@ -41,16 +42,52 @@ pub async fn list_templates( Ok((StatusCode::OK, Json(items))) } +// ─── GET /templates/lookup ────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct TemplateNameLookupQuery { + pub name: String, +} + +#[utoipa::path( + get, + path = "/templates/lookup", + params( + ("name" = String, Query, description = "Human-readable template display name") + ), + responses( + (status = 200, description = "Name resolves to a template", body = TemplateNameLookupResponse), + (status = 400, description = "Invalid or ambiguous template name", body = ApiError), + (status = 404, description = "Template name not found", body = ApiError), + (status = 500, description = "Unexpected backend error", body = ApiError) + ) +)] +pub async fn lookup_template_name( + State(state): State, + Query(query): Query, +) -> AppResult { + let template_id = state + .services + .templates + .lookup_template_name(&query.name) + .await?; + Ok(( + StatusCode::OK, + Json(TemplateNameLookupResponse { template_id }), + )) +} + // ─── GET /templates/:templateID ─────────────────────────────────────────────── #[utoipa::path( get, path = "/templates/{templateID}", params( - ("templateID" = String, Path, description = "Template identifier") + ("templateID" = String, Path, description = "Template identifier (tpl-*) or human-readable display name") ), responses( (status = 200, description = "Template detail", body = TemplateDetail), + (status = 400, description = "Invalid or ambiguous template name", body = ApiError), (status = 404, description = "Template not found", body = ApiError), (status = 500, description = "Unexpected backend error", body = ApiError) ) @@ -84,10 +121,11 @@ pub async fn template_compat(State(state): State) -> AppResult, Json(body): Json, @@ -134,17 +183,32 @@ pub async fn rebuild_template( // ─── PATCH /templates/:templateID ───────────────────────────────────────────── +#[utoipa::path( + patch, + path = "/templates/{templateID}", + params( + ("templateID" = String, Path, description = "Template identifier (tpl-*) or human-readable display name") + ), + request_body = UpdateTemplateRequest, + responses( + (status = 200, description = "Updated template display name", body = TemplateDetail), + (status = 400, description = "Invalid name", body = ApiError), + (status = 404, description = "Template not found", body = ApiError), + (status = 409, description = "Name already in use", body = ApiError), + (status = 500, description = "Unexpected backend error", body = ApiError) + ) +)] pub async fn update_template( - State(_): State, - Path(_template_id): Path, - _body: Json, + State(state): State, + Path(template_id): Path, + Json(body): Json, ) -> AppResult { - // CubeMaster exposes no dedicated PATCH; clients should use POST - // /templates/:id (rebuild) or DELETE + re-create. - Err::<(), _>(AppError::NotImplemented( - "template metadata update is not supported; use POST /templates/{id} to rebuild" - .to_string(), - )) + let detail = state + .services + .templates + .update_template_name(template_id, body) + .await?; + Ok((StatusCode::OK, Json(detail))) } // ─── DELETE /templates/:templateID ──────────────────────────────────────────── @@ -168,7 +232,11 @@ pub async fn delete_template( // branch additionally exposes the operation id via a response header so // audit trails / debugging can still correlate the deletion with its // CubeMaster job, but no body is returned. - if state.services.snapshots.has_snapshot(&template_id).await? { + // Snapshot IDs use the snap- prefix; template display names must not, so + // only treat snap-* paths as snapshot deletion when the snapshot exists. + if template_id.trim().to_ascii_lowercase().starts_with("snap-") + && state.services.snapshots.has_snapshot(&template_id).await? + { let resp = state.services.snapshots.delete(&template_id).await?; let mut headers = HeaderMap::new(); if let Ok(value) = HeaderValue::from_str(&resp.operation_id) { @@ -239,13 +307,13 @@ fn default_log_limit() -> i32 { pub async fn get_template_build_logs( State(state): State, - Path((_template_id, build_id)): Path<(String, String)>, + Path((template_id, build_id)): Path<(String, String)>, Query(_params): Query, ) -> AppResult { let logs = state .services .templates - .get_template_build_logs(&build_id) + .get_template_build_logs(&template_id, &build_id) .await?; Ok((StatusCode::OK, Json(logs))) } diff --git a/CubeAPI/src/models/mod.rs b/CubeAPI/src/models/mod.rs index a97966f46..8334d7c34 100644 --- a/CubeAPI/src/models/mod.rs +++ b/CubeAPI/src/models/mod.rs @@ -540,11 +540,21 @@ pub struct ListTemplatesQuery { pub instance_type: Option, } +/// Response for GET /templates/lookup (display name → template ID). +#[derive(Debug, Serialize, ToSchema)] +pub struct TemplateNameLookupResponse { + #[serde(rename = "templateID")] + pub template_id: String, +} + /// Summary row returned by GET /templates. #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct TemplateSummary { #[serde(rename = "templateID")] pub template_id: String, + /// Human-readable template name (0 or 1 entry; empty when unset). + #[serde(default)] + pub names: Vec, #[serde(rename = "instanceType", skip_serializing_if = "Option::is_none")] pub instance_type: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -558,11 +568,14 @@ pub struct TemplateSummary { pub image_info: Option, } -/// Detailed template response (GET /templates/:id). +/// Detailed template response (GET /templates/{templateID}). #[derive(Debug, Serialize, ToSchema)] pub struct TemplateDetail { #[serde(rename = "templateID")] pub template_id: String, + /// Human-readable template name (0 or 1 entry; empty when unset). + #[serde(default)] + pub names: Vec, #[serde(rename = "instanceType", skip_serializing_if = "Option::is_none")] pub instance_type: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -587,11 +600,14 @@ pub struct TemplateDetail { /// Body for POST /templates (create from image). #[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateTemplateRequest { - /// Deprecated and ignored. Template IDs are always generated server-side - /// with the `tpl-` prefix; clients must use the returned `templateID`. - #[serde(rename = "templateID", default)] - #[allow(dead_code)] - pub template_id: String, + /// Optional human-readable template name (ASCII letters, digits, hyphen, + /// underscore; must start with a letter or digit; max 256 characters; must not + /// use `tpl-` or `snap-` prefix; cluster-wide case-insensitive unique among templates + /// that currently hold the name (assigned when the definition is created; + /// released after failed builds). Sandboxes may reference this name only after + /// the template build succeeds (template ready on a node). + #[serde(default)] + pub name: Option, #[serde(rename = "instanceType", default)] pub instance_type: Option, /// Container image reference, e.g. `registry.example.com/code:latest`. @@ -650,6 +666,15 @@ pub struct CreateTemplateRequest { pub deny_out: Option>, } +/// Body for PATCH /templates/:templateID (update display name). +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateTemplateRequest { + /// Required display name (ASCII letters, digits, hyphen, underscore; + /// must start with a letter or digit; max 256 characters; must not use + /// `tpl-` or `snap-` prefix; case-insensitive unique). + pub name: String, +} + /// Body for POST /templates/:id (rebuild). #[derive(Debug, Deserialize, ToSchema)] pub struct RebuildTemplateRequest { @@ -662,8 +687,13 @@ pub struct RebuildTemplateRequest { pub struct TemplateBuildJob { #[serde(rename = "jobID")] pub job_id: String, + #[serde(rename = "buildID")] + pub build_id: String, #[serde(rename = "templateID")] pub template_id: String, + /// Human-readable template name (set on create when `name` is provided; empty on rebuild). + #[serde(default)] + pub names: Vec, pub status: String, pub phase: String, pub progress: i32, diff --git a/CubeAPI/src/openapi.rs b/CubeAPI/src/openapi.rs index f3e55258f..db4c85e34 100644 --- a/CubeAPI/src/openapi.rs +++ b/CubeAPI/src/openapi.rs @@ -13,11 +13,12 @@ use crate::{ handlers, models::{ ApiError, ClusterOverview, ComponentMatrixRowView, ComponentVersionGroupView, - ComponentVersionView, ControlPlaneVersionView, NodeComponentEntryView, NodeConditionView, - NodeResourcesView, NodeVersionRowView, NodeView, ResumedSandbox, Sandbox, SandboxDetail, - SandboxLogEntry, SandboxLogsV2Response, SandboxState, SandboxVolumeMount, - TemplateCompatAdoptResponseView, TemplateCompatMatrixView, TemplateCompatRowView, - TemplateCompatSummaryView, TemplateDetail, TemplateNodeCompatView, TemplateSummary, + ComponentVersionView, ControlPlaneVersionView, CreateTemplateRequest, + NodeComponentEntryView, NodeConditionView, NodeResourcesView, NodeVersionRowView, NodeView, + ResumedSandbox, Sandbox, SandboxDetail, SandboxLogEntry, SandboxLogsV2Response, + SandboxState, SandboxVolumeMount, TemplateBuildJob, TemplateCompatAdoptResponseView, + TemplateCompatMatrixView, TemplateCompatRowView, TemplateCompatSummaryView, TemplateDetail, + TemplateNameLookupResponse, TemplateNodeCompatView, TemplateSummary, UpdateTemplateRequest, VersionMatrixView, }, }; @@ -57,7 +58,10 @@ impl Modify for SecurityAddon { handlers::cluster::list_nodes, handlers::cluster::get_node, handlers::templates::list_templates, + handlers::templates::lookup_template_name, handlers::templates::get_template, + handlers::templates::create_template, + handlers::templates::update_template, handlers::templates::template_compat, handlers::templates::adopt_template_compat_baseline, handlers::sandboxes::list_sandboxes_v2, @@ -82,7 +86,11 @@ impl Modify for SecurityAddon { NodeVersionRowView, VersionMatrixView, TemplateSummary, + TemplateNameLookupResponse, TemplateDetail, + CreateTemplateRequest, + TemplateBuildJob, + UpdateTemplateRequest, TemplateCompatSummaryView, TemplateNodeCompatView, TemplateCompatRowView, diff --git a/CubeAPI/src/routes.rs b/CubeAPI/src/routes.rs index e4dad1f3b..407f5e510 100644 --- a/CubeAPI/src/routes.rs +++ b/CubeAPI/src/routes.rs @@ -187,6 +187,7 @@ fn build_template_routes(state: &AppState, auth_configured: bool) -> Router Self { + let template_names = templates::TemplateNameCache::new(cubemaster.clone()); Self { cluster: cluster::ClusterService::new(cubemaster.clone()), sandboxes: sandboxes::SandboxService::new( cubemaster.clone(), config.instance_type.clone(), config.sandbox_domain.clone(), + template_names.clone(), ), snapshots: snapshots::SnapshotService::new( cubemaster.clone(), config.instance_type.clone(), ), - templates: templates::TemplateService::new(cubemaster, config.instance_type.clone()), + templates: templates::TemplateService::new( + cubemaster, + config.instance_type.clone(), + template_names, + ), } } } diff --git a/CubeAPI/src/services/sandboxes.rs b/CubeAPI/src/services/sandboxes.rs index 31bb2b2b7..630fb5cc5 100644 --- a/CubeAPI/src/services/sandboxes.rs +++ b/CubeAPI/src/services/sandboxes.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use uuid::Uuid; -use super::validate_allow_out_domains_require_deny_all; +use super::{templates::TemplateNameCache, validate_allow_out_domains_require_deny_all}; use crate::{ constants::ENVD_VERSION, cubemaster::{ @@ -34,6 +34,7 @@ pub struct SandboxService { cubemaster: CubeMasterClient, instance_type: String, sandbox_domain: String, + template_names: TemplateNameCache, } impl SandboxService { @@ -41,11 +42,13 @@ impl SandboxService { cubemaster: CubeMasterClient, instance_type: String, sandbox_domain: String, + template_names: TemplateNameCache, ) -> Self { Self { cubemaster, instance_type, sandbox_domain, + template_names, } } @@ -116,7 +119,11 @@ impl SandboxService { } pub async fn create_sandbox(&self, body: NewSandbox) -> AppResult { - let template_id = body.template_id.clone(); + let template_ref = body.template_id.clone(); + let template_id = self + .template_names + .resolve_template_ref_fresh(&template_ref) + .await?; let mut annotations = HashMap::from([ ( "cube.master.appsnapshot.template.id".to_string(), @@ -175,7 +182,13 @@ impl SandboxService { .await .map_err(internal_error)?; - resp.ret.into_result().map_err(internal_error)?; + if let Err(err) = resp.ret.into_result() { + return Err(if err.is_not_found() { + AppError::NotFound(err.to_string()) + } else { + internal_error(err) + }); + } Ok(self.sandbox_response(template_id, resp.sandbox_id, resp.request_id)) } @@ -983,6 +996,7 @@ mod tests { timeout: Some(60), annotations: HashMap::new(), labels: None, + distribution_scope: None, volumes: None, containers: vec![], exposed_ports: vec![], diff --git a/CubeAPI/src/services/templates.rs b/CubeAPI/src/services/templates.rs index b7947d2fa..8676d8556 100644 --- a/CubeAPI/src/services/templates.rs +++ b/CubeAPI/src/services/templates.rs @@ -16,21 +16,155 @@ use crate::{ models::{ CreateTemplateRequest, RebuildTemplateRequest, TemplateBuildJob, TemplateBuildStatus, TemplateCompatMatrixView, TemplateCompatRowView, TemplateCompatSummaryView, TemplateDetail, - TemplateNodeCompatView, TemplateSummary, + TemplateNodeCompatView, TemplateSummary, UpdateTemplateRequest, }, }; +// Resolve human-readable template names to template IDs at CubeAPI entry points. +// References that already look like `tpl-*` or `snap-*` ids bypass lookup. +// Name resolution is cached in CubeMaster; CubeAPI is a thin pass-through. + +#[derive(Clone)] +pub struct TemplateNameCache { + cubemaster: CubeMasterClient, +} + +impl TemplateNameCache { + pub fn new(cubemaster: CubeMasterClient) -> Self { + Self { cubemaster } + } + + /// Returns true when the reference should be resolved via display_name lookup. + pub fn needs_resolution(reference: &str) -> bool { + let reference = reference.trim(); + if reference.is_empty() { + return false; + } + let lower = reference.to_ascii_lowercase(); + !lower.starts_with("tpl-") && !lower.starts_with("snap-") + } + + /// Resolve a template reference via CubeMaster name lookup. + pub async fn resolve_template_ref(&self, reference: &str) -> AppResult { + self.resolve(reference, false).await + } + + /// Resolve bypassing CubeMaster read cache (mutating API paths). + pub async fn resolve_template_ref_fresh(&self, reference: &str) -> AppResult { + self.resolve(reference, true).await + } + + async fn resolve(&self, reference: &str, fresh: bool) -> AppResult { + let reference = reference.trim(); + if reference.is_empty() { + return Err(AppError::BadRequest( + "template reference is empty".to_string(), + )); + } + if !Self::needs_resolution(reference) { + return Ok(reference.to_string()); + } + if fresh { + self.cubemaster + .resolve_template_by_name_fresh(reference) + .await + .map_err(map_resolve_err(reference)) + } else { + self.cubemaster + .resolve_template_by_name(reference) + .await + .map_err(map_resolve_err(reference)) + } + } +} + +pub fn template_names(display_name: &str) -> Vec { + let display_name = display_name.trim(); + if display_name.is_empty() { + Vec::new() + } else { + vec![display_name.to_string()] + } +} + +fn map_resolve_err(reference: &str) -> impl FnOnce(CubeMasterError) -> AppError { + let reference = reference.to_string(); + move |err| match err { + CubeMasterError::Api { ret_code, .. } if ret_code == 130404 => { + AppError::NotFound(format!("template name {reference} not found")) + } + CubeMasterError::Api { + ret_code, ret_msg, .. + } if ret_code == 130409 => AppError::Conflict(ret_msg), + CubeMasterError::Api { + ret_code, ret_msg, .. + } if ret_code == 130400 => AppError::BadRequest(ret_msg), + CubeMasterError::Http(e) if e.is_timeout() || e.is_connect() => { + AppError::ServiceUnavailable("CubeMaster unavailable".to_string()) + } + CubeMasterError::Http(_) => AppError::BadGateway("CubeMaster request failed".to_string()), + other => AppError::Internal(anyhow::anyhow!(other)), + } +} + +/// Validate an optional or required E2B-style template display name at the CubeAPI +/// boundary (mirrors CubeMaster NormalizeTemplateDisplayName rules). +fn validate_template_display_name(name: &str, required: bool) -> AppResult { + let name = name.trim(); + if name.is_empty() { + if required { + return Err(AppError::BadRequest("name is required".to_string())); + } + return Ok(String::new()); + } + if name.len() > 256 { + return Err(AppError::BadRequest( + "template name exceeds 256 characters".to_string(), + )); + } + let lower = name.to_ascii_lowercase(); + if lower.starts_with("tpl-") || lower.starts_with("snap-") { + return Err(AppError::BadRequest( + "template name must not use tpl- or snap- prefix".to_string(), + )); + } + if !name + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphanumeric()) + { + return Err(AppError::BadRequest( + "template name must start with a letter or digit".to_string(), + )); + } + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(AppError::BadRequest( + "template name must contain only letters, digits, hyphens, and underscores".to_string(), + )); + } + Ok(name.to_string()) +} + #[derive(Clone)] pub struct TemplateService { cubemaster: CubeMasterClient, instance_type: String, + template_names: TemplateNameCache, } impl TemplateService { - pub fn new(cubemaster: CubeMasterClient, instance_type: String) -> Self { + pub fn new( + cubemaster: CubeMasterClient, + instance_type: String, + template_names: TemplateNameCache, + ) -> Self { Self { cubemaster, instance_type, + template_names, } } @@ -46,6 +180,7 @@ impl TemplateService { .into_iter() .map(|s| TemplateSummary { template_id: s.template_id, + names: template_names(&s.display_name), instance_type: non_empty(s.instance_type), version: non_empty(s.version), status: s.status, @@ -56,51 +191,25 @@ impl TemplateService { .collect()) } - pub async fn get_template(&self, template_id: &str) -> AppResult { + pub async fn get_template(&self, template_ref: &str) -> AppResult { + let template_id = self + .template_names + .resolve_template_ref(template_ref) + .await?; let resp = self - .cubemaster - .get_template(template_id) - .await - .map_err(map_err)?; - - if resp.template_id.is_empty() && resp.status.is_empty() { - return Err(AppError::NotFound(format!( - "template {} not found", - template_id - ))); - } + .fetch_resolved_template(template_ref, &template_id) + .await?; + + Ok(template_detail_from_cm_response( + &resp, + &template_id, + resp.display_name.as_str(), + )) + } - // Extract network fields from create_request JSON (stored by CubeMaster) - let network_type = resp - .create_request - .as_ref() - .and_then(|v| v.get("network_type")) - .and_then(|v| v.as_str()) - .and_then(|s| { - if s.is_empty() { - None - } else { - Some(s.to_string()) - } - }); - let allow_internet_access = resp - .create_request - .as_ref() - .and_then(|v| v.get("cube_network_config")) - .and_then(|v| v.get("allowInternetAccess")) - .and_then(|v| v.as_bool()); - - Ok(TemplateDetail { - template_id: string_or(resp.template_id, template_id), - instance_type: non_empty(resp.instance_type), - version: non_empty(resp.version), - status: resp.status, - last_error: non_empty(resp.last_error), - replicas: resp.replicas, - create_request: resp.create_request, - network_type, - allow_internet_access, - }) + /// Resolve a display name to template ID (lightweight lookup for UI hints). + pub async fn lookup_template_name(&self, name: &str) -> AppResult { + self.template_names.resolve_template_ref(name).await } pub async fn create_template( @@ -111,6 +220,17 @@ impl TemplateService { return Err(AppError::BadRequest("image is required".to_string())); } + let display_name = match body.name.as_deref() { + Some(raw) => { + let validated = validate_template_display_name(raw, false)?; + if validated.is_empty() { + None + } else { + Some(validated) + } + } + None => None, + }; let dns_servers = validate_dns_servers(body.dns.as_deref())?; let container_overrides = build_template_container_overrides(&body, dns_servers.as_deref()); let cube_network_config = build_template_cube_network_config(&body)?; @@ -125,6 +245,7 @@ impl TemplateService { // normalizeTemplateImageRequest. template_id: String::new(), source_image_ref: body.image.trim().to_string(), + display_name: display_name.clone(), writable_layer_size: body.writable_layer_size, exposed_ports: body.exposed_ports, network_type: non_empty_option(body.network_type), @@ -141,14 +262,45 @@ impl TemplateService { .await .map_err(map_err)?; - Ok(to_job(resp)) + Ok(to_job(resp, display_name.as_deref())) + } + + pub async fn update_template_name( + &self, + template_ref: String, + body: UpdateTemplateRequest, + ) -> AppResult { + let template_id = self + .template_names + .resolve_template_ref_fresh(&template_ref) + .await?; + let name = validate_template_display_name(&body.name, true)?; + + let existing = self + .fetch_resolved_template(&template_ref, &template_id) + .await?; + + self.cubemaster + .update_template_display_name(&template_id, &name) + .await + .map_err(map_err)?; + + Ok(template_detail_from_cm_response( + &existing, + &template_id, + &name, + )) } pub async fn rebuild_template( &self, - template_id: String, + template_ref: String, body: RebuildTemplateRequest, ) -> AppResult { + let template_id = self + .template_names + .resolve_template_ref_fresh(&template_ref) + .await?; let req = RedoTemplateReq { request_id: new_request_id(), template_id, @@ -157,15 +309,19 @@ impl TemplateService { let resp = self.cubemaster.redo_template(&req).await.map_err(map_err)?; - Ok(to_job(resp)) + Ok(to_job(resp, None)) } pub async fn delete_template( &self, - template_id: String, + template_ref: String, instance_type: Option, sync: Option, ) -> AppResult<()> { + let template_id = self + .template_names + .resolve_template_ref_fresh(&template_ref) + .await?; let req = TemplateDeleteRequest { request_id: new_request_id(), template_id, @@ -181,7 +337,11 @@ impl TemplateService { Ok(()) } - pub async fn start_template_build(&self, template_id: String) -> AppResult { + pub async fn start_template_build(&self, template_ref: String) -> AppResult { + let template_id = self + .template_names + .resolve_template_ref_fresh(&template_ref) + .await?; let req = RedoTemplateReq { request_id: new_request_id(), template_id, @@ -190,36 +350,52 @@ impl TemplateService { let resp = self.cubemaster.redo_template(&req).await.map_err(map_err)?; - Ok(to_job(resp)) + Ok(to_job(resp, None)) } pub async fn get_template_build_status( &self, - template_id: &str, + template_ref: &str, build_id: &str, ) -> AppResult { + let template_id = self + .template_names + .resolve_template_ref(template_ref) + .await?; let resp = self .cubemaster .get_template_build_status(build_id) .await .map_err(map_err)?; + ensure_build_belongs_to_template(resp.template_id.as_str(), &template_id, build_id)?; + Ok(TemplateBuildStatus { build_id: string_or(resp.build_id, build_id), - template_id: string_or(resp.template_id, template_id), + template_id: string_or(resp.template_id, &template_id), status: resp.status, progress: resp.progress, message: resp.message, }) } - pub async fn get_template_build_logs(&self, build_id: &str) -> AppResult { + pub async fn get_template_build_logs( + &self, + template_ref: &str, + build_id: &str, + ) -> AppResult { + let template_id = self + .template_names + .resolve_template_ref(template_ref) + .await?; let resp = self .cubemaster .get_template_build_status(build_id) .await .map_err(map_err)?; + ensure_build_belongs_to_template(resp.template_id.as_str(), &template_id, build_id)?; + let line = build_log_line(&resp.status, resp.progress, &resp.message); Ok(serde_json::json!({ @@ -239,7 +415,11 @@ impl TemplateService { Ok(to_compat_matrix_view(resp.data.unwrap_or_default())) } - pub async fn adopt_compat_baseline(&self, template_id: String) -> AppResult { + pub async fn adopt_compat_baseline(&self, template_ref: String) -> AppResult { + let template_id = self + .template_names + .resolve_template_ref_fresh(&template_ref) + .await?; let req = TemplateCompatAdoptRequest { action: "adopt_baseline".to_string(), template_id, @@ -251,10 +431,50 @@ impl TemplateService { .map_err(map_err)?; Ok(resp.updated) } + + async fn fetch_resolved_template( + &self, + template_ref: &str, + template_id: &str, + ) -> AppResult { + let resp = self + .cubemaster + .get_template(template_id) + .await + .map_err(map_err)?; + + if resp.template_id.is_empty() && resp.status.is_empty() { + return Err(AppError::NotFound(format!( + "template {} not found", + template_ref + ))); + } + + Ok(resp) + } +} + +fn ensure_build_belongs_to_template( + build_template_id: &str, + expected_template_id: &str, + build_id: &str, +) -> AppResult<()> { + let build_template_id = build_template_id.trim(); + if build_template_id.is_empty() { + return Err(AppError::NotFound(format!( + "build {build_id} not found for template {expected_template_id}" + ))); + } + if build_template_id != expected_template_id { + return Err(AppError::NotFound(format!( + "build {build_id} not found for template {expected_template_id}" + ))); + } + Ok(()) } fn map_err(e: CubeMasterError) -> AppError { - if e.is_invalid_path_parameter() { + if e.is_invalid_path_parameter() || e.is_bad_request() { AppError::BadRequest(e.to_string()) } else if e.is_not_found() || e.is_endpoint_missing() { AppError::NotFound(e.to_string()) @@ -265,6 +485,44 @@ fn map_err(e: CubeMasterError) -> AppError { } } +fn template_detail_from_cm_response( + resp: &crate::cubemaster::TemplateResponse, + fallback_template_id: &str, + display_name: &str, +) -> TemplateDetail { + let network_type = resp + .create_request + .as_ref() + .and_then(|v| v.get("network_type")) + .and_then(|v| v.as_str()) + .and_then(|s| { + if s.is_empty() { + None + } else { + Some(s.to_string()) + } + }); + let allow_internet_access = resp + .create_request + .as_ref() + .and_then(|v| v.get("cube_network_config")) + .and_then(|v| v.get("allowInternetAccess")) + .and_then(|v| v.as_bool()); + + TemplateDetail { + template_id: string_or(resp.template_id.clone(), fallback_template_id), + names: template_names(display_name), + instance_type: non_empty(resp.instance_type.clone()), + version: non_empty(resp.version.clone()), + status: resp.status.clone(), + last_error: non_empty(resp.last_error.clone()), + replicas: resp.replicas.clone(), + create_request: resp.create_request.clone(), + network_type, + allow_internet_access, + } +} + fn new_request_id() -> String { Uuid::new_v4().to_string() } @@ -329,11 +587,13 @@ fn to_compat_matrix_view(src: crate::cubemaster::TemplateCompatMatrix) -> Templa } } -fn to_job(resp: TemplateJobResponse) -> TemplateBuildJob { +fn to_job(resp: TemplateJobResponse, display_name: Option<&str>) -> TemplateBuildJob { let job = resp.job.unwrap_or_else(default_template_job); TemplateBuildJob { - job_id: job.job_id, + job_id: job.job_id.clone(), + build_id: job.job_id, template_id: job.template_id, + names: display_name.map(template_names).unwrap_or_default(), status: job.status, phase: job.phase, progress: job.progress, @@ -341,6 +601,12 @@ fn to_job(resp: TemplateJobResponse) -> TemplateBuildJob { } } +fn optional_name(name: Option<&str>) -> Option { + name.map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + fn default_template_job() -> TemplateJob { TemplateJob { job_id: String::new(), @@ -510,7 +776,7 @@ mod tests { fn sample_request() -> CreateTemplateRequest { CreateTemplateRequest { - template_id: String::new(), + name: Some("my-python-env".to_string()), instance_type: Some("cubebox".to_string()), image: "python:3.11-slim".to_string(), writable_layer_size: Some("1G".to_string()), @@ -596,4 +862,131 @@ mod tests { let err = validate_dns_servers(Some(&["not-an-ip".to_string()])).unwrap_err(); assert!(matches!(err, AppError::BadRequest(_))); } + + #[test] + fn template_detail_from_cm_response_overrides_display_name() { + let resp = crate::cubemaster::TemplateResponse { + request_id: String::new(), + ret: crate::cubemaster::RetCode { + ret_code: 200, + ret_msg: "ok".to_string(), + }, + template_id: "tpl-1".to_string(), + display_name: "old-name".to_string(), + status: "ready".to_string(), + instance_type: String::new(), + version: String::new(), + last_error: String::new(), + replicas: Vec::new(), + create_request: None, + }; + let detail = template_detail_from_cm_response(&resp, "tpl-1", "new-name"); + assert_eq!(detail.names, vec!["new-name".to_string()]); + assert_eq!(detail.template_id, "tpl-1"); + } + + #[test] + fn needs_resolution_skips_system_ids() { + assert!(!TemplateNameCache::needs_resolution("tpl-abc")); + assert!(!TemplateNameCache::needs_resolution("snap-abc")); + assert!(!TemplateNameCache::needs_resolution("TPL-abc")); + assert!(TemplateNameCache::needs_resolution("cubesandbox-template")); + } + + #[tokio::test] + async fn map_resolve_err_maps_http_connect_failure_to_service_unavailable() { + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_millis(50)) + .build() + .expect("client"); + let err = client + .get("http://127.0.0.1:1") + .send() + .await + .expect_err("connect should fail"); + let mapped = super::map_resolve_err("my-env")(CubeMasterError::Http(err)); + match mapped { + AppError::ServiceUnavailable(msg) => { + assert_eq!(msg, "CubeMaster unavailable"); + } + AppError::BadGateway(msg) => { + assert_eq!(msg, "CubeMaster request failed"); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn validate_template_display_name_rejects_reserved_prefix() { + let err = super::validate_template_display_name("tpl-env", true).unwrap_err(); + assert!(matches!(err, AppError::BadRequest(_))); + } + + #[test] + fn validate_template_display_name_accepts_valid_name() { + let got = super::validate_template_display_name(" my-env_1 ", true).expect("valid"); + assert_eq!(got, "my-env_1"); + } + + #[test] + fn validate_template_display_name_optional_allows_empty() { + assert_eq!( + super::validate_template_display_name(" ", false).expect("empty"), + "" + ); + } + + #[test] + fn map_resolve_err_maps_not_found_conflict_and_bad_request() { + let not_found = CubeMasterError::Api { + ret_code: 130404, + ret_msg: "template name not found".to_string(), + }; + assert!(matches!( + super::map_resolve_err("my-env")(not_found), + AppError::NotFound(_) + )); + + let conflict = CubeMasterError::Api { + ret_code: 130409, + ret_msg: "template name is already in use".to_string(), + }; + assert!(matches!( + super::map_resolve_err("my-env")(conflict), + AppError::Conflict(_) + )); + + let bad_request = CubeMasterError::Api { + ret_code: 130400, + ret_msg: "template name is invalid".to_string(), + }; + assert!(matches!( + super::map_resolve_err("my-env")(bad_request), + AppError::BadRequest(_) + )); + } + + #[test] + fn ensure_build_belongs_to_template_rejects_empty_build_template_id() { + let err = super::ensure_build_belongs_to_template("", "tpl-1", "build-1").unwrap_err(); + assert!(matches!(err, AppError::NotFound(_))); + } + + #[test] + fn template_names_omits_empty_display_name() { + assert_eq!(template_names("my-env"), vec!["my-env"]); + assert!(template_names("").is_empty()); + assert!(template_names(" ").is_empty()); + } + + #[test] + fn ensure_build_belongs_to_template_accepts_matching_ids() { + ensure_build_belongs_to_template("tpl-1", "tpl-1", "job-1").expect("matching ids"); + } + + #[test] + fn ensure_build_belongs_to_template_rejects_mismatch() { + let err = ensure_build_belongs_to_template("tpl-other", "tpl-1", "job-1").unwrap_err(); + assert!(matches!(err, AppError::NotFound(_))); + } } diff --git a/CubeMaster/pkg/base/dao/migrate/migrations/mysql/20260623051112_template_display_name_index.sql b/CubeMaster/pkg/base/dao/migrate/migrations/mysql/20260623051112_template_display_name_index.sql new file mode 100644 index 000000000..65dd659a4 --- /dev/null +++ b/CubeMaster/pkg/base/dao/migrate/migrations/mysql/20260623051112_template_display_name_index.sql @@ -0,0 +1,93 @@ +-- Index display_name for template name → templateID lookup and uniqueness. +-- +-- PRODUCTION: run the audit queries below on staging before applying. This migration +-- clears reserved-prefix names and duplicate display names (prefers non-FAILED +-- survivor, then oldest id). +-- Review release notes with operators before rolling out to production. +-- +-- Pre-migration audit (run manually on staging before apply): +-- Prefix violations (expect zero rows before apply): +-- SELECT template_id, display_name FROM t_cube_template_definition +-- WHERE kind = 'template' AND TRIM(display_name) <> '' +-- AND (LOWER(display_name) LIKE 'tpl-%' OR LOWER(display_name) LIKE 'snap-%'); +-- Duplicate names (expect zero rows, or review losers below before apply): +-- SELECT LOWER(display_name) AS name_key, COUNT(*) AS cnt +-- FROM t_cube_template_definition +-- WHERE kind = 'template' AND TRIM(display_name) <> '' +-- GROUP BY LOWER(display_name) HAVING cnt > 1; +-- Rows that will lose display_name (keep_id is the survivor: non-FAILED first, then oldest id): +-- SELECT t.template_id, t.display_name, t.status, t.id AS loser_id, keep.keep_id, keep.keep_status +-- FROM t_cube_template_definition t +-- JOIN ( +-- SELECT name_key, keep_id, keep_status FROM ( +-- SELECT LOWER(display_name) AS name_key, id AS keep_id, status AS keep_status, +-- ROW_NUMBER() OVER ( +-- PARTITION BY LOWER(display_name) +-- ORDER BY CASE WHEN UPPER(status) = 'FAILED' THEN 1 ELSE 0 END, id +-- ) AS rn +-- FROM t_cube_template_definition +-- WHERE kind = 'template' AND TRIM(display_name) <> '' +-- ) ranked WHERE rn = 1 +-- ) keep ON LOWER(t.display_name) = keep.name_key +-- WHERE t.kind = 'template' AND TRIM(t.display_name) <> '' AND t.id <> keep.keep_id; + +-- +goose NO TRANSACTION +-- +goose Up + +CALL cubemaster_acquire_migration_lock('cubemaster_migration_20260623051112_template_display_name_index', 60); + +CALL cubemaster_assert_table_exists('t_cube_template_definition'); + +-- Drop invalid reserved-prefix names on templates (snapshots keep their names). +UPDATE `t_cube_template_definition` + SET `display_name` = '' + WHERE `kind` = 'template' + AND TRIM(`display_name`) <> '' + AND ( + LOWER(`display_name`) LIKE 'tpl-%' + OR LOWER(`display_name`) LIKE 'snap-%' + ); + +-- Resolve duplicate template display names: keep one row per name, preferring +-- non-FAILED status then oldest id; clear display_name on all other duplicates. +UPDATE `t_cube_template_definition` t + JOIN ( + SELECT id + FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY LOWER(`display_name`) + ORDER BY + CASE WHEN UPPER(`status`) = 'FAILED' THEN 1 ELSE 0 END, + id + ) AS rn + FROM `t_cube_template_definition` + WHERE `kind` = 'template' + AND TRIM(`display_name`) <> '' + ) ranked + WHERE rn > 1 + ) dup ON t.id = dup.id + SET t.`display_name` = ''; + +CALL cubemaster_add_column_if_missing( + 't_cube_template_definition', + 'display_name_key', + "varchar(256) GENERATED ALWAYS AS (IF(`display_name` = '' OR `kind` <> 'template', NULL, LOWER(`display_name`))) VIRTUAL AFTER `display_name`" +); + +CALL cubemaster_add_index_if_missing( + 't_cube_template_definition', + 'idx_template_display_name_key', + 'ADD UNIQUE INDEX `idx_template_display_name_key` (`display_name_key`)' +); + +SELECT RELEASE_LOCK('cubemaster_migration_20260623051112_template_display_name_index'); + +-- +goose Down +CALL cubemaster_acquire_migration_lock('cubemaster_migration_20260623051112_template_display_name_index', 60); + +CALL cubemaster_drop_index_if_exists('t_cube_template_definition', 'idx_template_display_name_key'); + +CALL cubemaster_drop_column_if_exists('t_cube_template_definition', 'display_name_key'); + +SELECT RELEASE_LOCK('cubemaster_migration_20260623051112_template_display_name_index'); diff --git a/CubeMaster/pkg/server/server.go b/CubeMaster/pkg/server/server.go index 7ef4dca76..362d5b696 100644 --- a/CubeMaster/pkg/server/server.go +++ b/CubeMaster/pkg/server/server.go @@ -101,6 +101,8 @@ func (s *internalHttp) registerHandlers() { cubeGroup.HandleFunc(cube.TemplateRedoAction, cube.HttpHandler).Methods(http.MethodPost) cubeGroup.HandleFunc(cube.TemplateBuildStatusAction+"/{build_id}/status", cube.HttpHandler).Methods(http.MethodGet) cubeGroup.HandleFunc(cube.TemplateFromImageAction, cube.HttpHandler).Methods(http.MethodGet, http.MethodPost) + cubeGroup.HandleFunc(cube.TemplateLookupAction, cube.HttpHandler).Methods(http.MethodGet) + cubeGroup.HandleFunc(cube.TemplateDisplayNameAction, cube.HttpHandler).Methods(http.MethodPost) cubeGroup.HandleFunc(cube.TemplateArtifactDownloadAction, cube.HttpHandler).Methods(http.MethodGet, http.MethodHead) cubeGroup.HandleFunc(cube.CADownloadActionPrefix+"{filename}", cube.HttpHandler).Methods(http.MethodGet, http.MethodHead) cubeGroup.HandleFunc(cube.RootfsArtifactAction, cube.HttpHandler).Methods(http.MethodGet) diff --git a/CubeMaster/pkg/service/httpservice/cube/cube.go b/CubeMaster/pkg/service/httpservice/cube/cube.go index e5de816ea..6d68cde40 100644 --- a/CubeMaster/pkg/service/httpservice/cube/cube.go +++ b/CubeMaster/pkg/service/httpservice/cube/cube.go @@ -35,6 +35,8 @@ const ( TemplateRedoAction = "/template/redo" TemplateBuildStatusAction = "/template/build" TemplateFromImageAction = "/template/from-image" + TemplateLookupAction = "/template/lookup" + TemplateDisplayNameAction = "/template/display-name" TemplateArtifactDownloadAction = "/template/artifact/download" RootfsArtifactAction = "/rootfs-artifact" CADownloadActionPrefix = "/ca/" @@ -94,6 +96,10 @@ func HttpHandler(w http.ResponseWriter, r *http.Request) { rsp = handleRedoTemplateAction(w, r, rt) case r.URL.Path == actionURI(TemplateFromImageAction): rsp = handleTemplateFromImageAction(w, r, rt) + case r.URL.Path == actionURI(TemplateLookupAction): + rsp = handleTemplateLookupAction(w, r, rt) + case r.URL.Path == actionURI(TemplateDisplayNameAction): + rsp = handleTemplateDisplayNameAction(w, r, rt) case r.URL.Path == actionURI(TemplateArtifactDownloadAction): rsp = handleTemplateArtifactDownloadAction(w, r, rt) case strings.HasPrefix(r.URL.Path, actionURI(CADownloadActionPrefix)): diff --git a/CubeMaster/pkg/service/httpservice/cube/template.go b/CubeMaster/pkg/service/httpservice/cube/template.go index 1f43a89ab..4a9efc9b5 100644 --- a/CubeMaster/pkg/service/httpservice/cube/template.go +++ b/CubeMaster/pkg/service/httpservice/cube/template.go @@ -7,8 +7,10 @@ package cube import ( "errors" "net/http" + "strings" "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/log" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/utils" "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/errorcode" "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/httpservice/common" "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" @@ -19,15 +21,17 @@ import ( var deleteTemplateFn = templatecenter.DeleteTemplate var getTemplateInfoFn = templatecenter.GetTemplateInfo var getTemplateRequestFn = templatecenter.GetTemplateRequest +var lookupTemplateIDByDisplayName = templatecenter.FindTemplateIDByDisplayName +var lookupTemplateIDByDisplayNameFresh = templatecenter.FindTemplateIDByDisplayNameFresh type templateResponse struct { *types.Res TemplateID string `json:"template_id,omitempty"` + DisplayName string `json:"display_name,omitempty"` InstanceType string `json:"instance_type,omitempty"` Version string `json:"version,omitempty"` Status string `json:"status,omitempty"` LastError string `json:"last_error,omitempty"` - DisplayName string `json:"display_name,omitempty"` CreatedAt string `json:"created_at,omitempty"` ImageInfo string `json:"image_info,omitempty"` Replicas []templatecenter.ReplicaStatus `json:"replicas,omitempty"` @@ -44,6 +48,7 @@ type templateListResponse struct { type templateSummary struct { TemplateID string `json:"template_id,omitempty"` + DisplayName string `json:"display_name,omitempty"` InstanceType string `json:"instance_type,omitempty"` Version string `json:"version,omitempty"` Status string `json:"status,omitempty"` @@ -194,6 +199,7 @@ func createTemplate(w http.ResponseWriter, r *http.Request, rt *CubeLog.RequestT }, }, TemplateID: info.TemplateID, + DisplayName: info.DisplayName, InstanceType: info.InstanceType, Version: info.Version, Status: info.Status, @@ -254,11 +260,11 @@ func getTemplate(w http.ResponseWriter, r *http.Request, rt *CubeLog.RequestTrac }, }, TemplateID: info.TemplateID, + DisplayName: info.DisplayName, InstanceType: info.InstanceType, Version: info.Version, Status: info.Status, LastError: info.LastError, - DisplayName: info.DisplayName, CreatedAt: info.CreatedAt, ImageInfo: info.ImageInfo, Replicas: info.Replicas, @@ -294,6 +300,7 @@ func listTemplates(r *http.Request, rt *CubeLog.RequestTrace) interface{} { for _, info := range infos { rsp.Data = append(rsp.Data, templateSummary{ TemplateID: info.TemplateID, + DisplayName: info.DisplayName, InstanceType: info.InstanceType, Version: info.Version, Status: info.Status, @@ -305,3 +312,143 @@ func listTemplates(r *http.Request, rt *CubeLog.RequestTrace) interface{} { rt.RetCode = int64(errorcode.ErrorCode_Success) return rsp } + +// templateLookupResponse carries the templateID resolved from a display name. +type templateLookupResponse struct { + *types.Res + TemplateID string `json:"template_id,omitempty"` + DisplayName string `json:"display_name,omitempty"` +} + +type updateTemplateDisplayNameRequest struct { + RequestID string `json:"RequestID,omitempty"` + TemplateID string `json:"template_id,omitempty"` + DisplayName string `json:"display_name,omitempty"` +} + +// handleTemplateLookupAction resolves a template display name to its templateID. +// This is distinct from internal "template resolve" (node/locality binding); it +// is a name-to-ID lookup backed by the unique display_name_key index. +func handleTemplateLookupAction(w http.ResponseWriter, r *http.Request, rt *CubeLog.RequestTrace) interface{} { + _ = w + if r.Method != http.MethodGet { + rt.RetCode = -1 + return &templateLookupResponse{ + Res: &types.Res{Ret: &types.Ret{ + RetCode: -1, + RetMsg: http.StatusText(http.StatusMethodNotAllowed), + }}, + } + } + name := strings.TrimSpace(r.URL.Query().Get("name")) + if name == "" { + rt.RetCode = int64(errorcode.ErrorCode_MasterParamsError) + return &templateLookupResponse{ + Res: &types.Res{Ret: &types.Ret{ + RetCode: int(errorcode.ErrorCode_MasterParamsError), + RetMsg: "name is required", + }}, + } + } + ctx := log.WithLogger(r.Context(), log.G(r.Context())) + fresh := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("fresh")), "true") || + strings.TrimSpace(r.URL.Query().Get("fresh")) == "1" + var templateID string + var err error + if fresh { + templateID, err = lookupTemplateIDByDisplayNameFresh(ctx, name) + } else { + templateID, err = lookupTemplateIDByDisplayName(ctx, name) + } + if err != nil { + code := int(errorcode.ErrorCode_MasterInternalError) + retMsg := "internal error" + switch { + case errors.Is(err, templatecenter.ErrTemplateNameNotFound), + errors.Is(err, templatecenter.ErrTemplateNameAmbiguous): + code = int(errorcode.ErrorCode_NotFound) + retMsg = "template name not found" + case errors.Is(err, templatecenter.ErrTemplateNameInvalid): + code = int(errorcode.ErrorCode_MasterParamsError) + retMsg = err.Error() + default: + log.G(ctx).Errorf("template lookup failed: %v", err) + } + rt.RetCode = int64(code) + return &templateLookupResponse{ + Res: &types.Res{Ret: &types.Ret{ + RetCode: code, + RetMsg: retMsg, + }}, + } + } + normalized, normErr := templatecenter.NormalizeTemplateDisplayName(name) + if normErr != nil { + log.G(ctx).Warnf("template lookup display_name normalization failed: %v", normErr) + normalized = strings.TrimSpace(name) + } + rt.RetCode = int64(errorcode.ErrorCode_Success) + return &templateLookupResponse{ + Res: &types.Res{Ret: &types.Ret{ + RetCode: int(errorcode.ErrorCode_Success), + RetMsg: "success", + }}, + TemplateID: templateID, + DisplayName: normalized, + } +} + +func handleTemplateDisplayNameAction(w http.ResponseWriter, r *http.Request, rt *CubeLog.RequestTrace) interface{} { + _ = w + if r.Method != http.MethodPost { + rt.RetCode = -1 + return &types.Res{Ret: &types.Ret{ + RetCode: -1, + RetMsg: http.StatusText(http.StatusMethodNotAllowed), + }} + } + req := &updateTemplateDisplayNameRequest{} + if err := utils.DecodeHttpBody(r.Body, req); err != nil { + rt.RetCode = int64(errorcode.ErrorCode_MasterParamsError) + return &types.Res{Ret: &types.Ret{ + RetCode: int(errorcode.ErrorCode_MasterParamsError), + RetMsg: "请求体解析失败", + }} + } + if req.TemplateID == "" { + rt.RetCode = int64(errorcode.ErrorCode_MasterParamsError) + return &types.Res{Ret: &types.Ret{ + RetCode: int(errorcode.ErrorCode_MasterParamsError), + RetMsg: "template_id is required", + }} + } + rt.RequestID = req.RequestID + ctx := CubeLog.WithRequestTrace(r.Context(), rt) + if err := templatecenter.UpdateDefinitionDisplayName(ctx, req.TemplateID, req.DisplayName); err != nil { + code := int(errorcode.ErrorCode_MasterInternalError) + retMsg := "internal error" + switch { + case errors.Is(err, templatecenter.ErrTemplateNotFound): + code = int(errorcode.ErrorCode_NotFound) + retMsg = err.Error() + case errors.Is(err, templatecenter.ErrTemplateNameInUse): + code = int(errorcode.ErrorCode_Conflict) + retMsg = err.Error() + case errors.Is(err, templatecenter.ErrTemplateNameInvalid): + code = int(errorcode.ErrorCode_MasterParamsError) + retMsg = err.Error() + default: + log.G(ctx).Errorf("update template display name failed: %v", err) + } + rt.RetCode = int64(code) + return &types.Res{Ret: &types.Ret{ + RetCode: code, + RetMsg: retMsg, + }} + } + rt.RetCode = int64(errorcode.ErrorCode_Success) + return &types.Res{Ret: &types.Ret{ + RetCode: int(errorcode.ErrorCode_Success), + RetMsg: "success", + }} +} diff --git a/CubeMaster/pkg/service/httpservice/cube/template_display_name_test.go b/CubeMaster/pkg/service/httpservice/cube/template_display_name_test.go new file mode 100644 index 000000000..8bd747453 --- /dev/null +++ b/CubeMaster/pkg/service/httpservice/cube/template_display_name_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package cube + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/agiledragon/gomonkey/v2" + "github.com/stretchr/testify/assert" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/errorcode" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/templatecenter" + "github.com/tencentcloud/CubeSandbox/cubelog" +) + +func TestHandleTemplateLookupRejectsNonGet(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/cube/template/lookup?name=my-env", nil) + rt := &CubeLog.RequestTrace{} + resp := handleTemplateLookupAction(httptest.NewRecorder(), req, rt) + + got, ok := resp.(*templateLookupResponse) + if !ok { + t.Fatalf("unexpected response type %T", resp) + } + assert.Equal(t, -1, got.Ret.RetCode) + assert.Equal(t, int64(-1), rt.RetCode) +} + +func TestHandleTemplateLookupRequiresName(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/cube/template/lookup?name=%20", nil) + rt := &CubeLog.RequestTrace{} + resp := handleTemplateLookupAction(httptest.NewRecorder(), req, rt) + + got, ok := resp.(*templateLookupResponse) + if !ok { + t.Fatalf("unexpected response type %T", resp) + } + assert.Equal(t, int(errorcode.ErrorCode_MasterParamsError), got.Ret.RetCode) + assert.Equal(t, "name is required", got.Ret.RetMsg) + assert.Equal(t, int64(errorcode.ErrorCode_MasterParamsError), rt.RetCode) +} + +func TestHandleTemplateDisplayNameRejectsNonPost(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/cube/template/display-name", nil) + rt := &CubeLog.RequestTrace{} + resp := handleTemplateDisplayNameAction(httptest.NewRecorder(), req, rt) + + res, ok := resp.(*types.Res) + if !ok { + t.Fatalf("unexpected response type %T", resp) + } + assert.Equal(t, -1, res.Ret.RetCode) + assert.Equal(t, int64(-1), rt.RetCode) +} + +func TestHandleTemplateDisplayNameRequiresTemplateID(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/cube/template/display-name", strings.NewReader(`{"RequestID":"req-1"}`)) + rt := &CubeLog.RequestTrace{} + resp := handleTemplateDisplayNameAction(httptest.NewRecorder(), req, rt) + + res, ok := resp.(*types.Res) + if !ok { + t.Fatalf("unexpected response type %T", resp) + } + assert.Equal(t, int(errorcode.ErrorCode_MasterParamsError), res.Ret.RetCode) + assert.Equal(t, "template_id is required", res.Ret.RetMsg) + assert.Equal(t, int64(errorcode.ErrorCode_MasterParamsError), rt.RetCode) +} + +func TestHandleTemplateDisplayNameRejectsBadBody(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/cube/template/display-name", strings.NewReader("not-json")) + rt := &CubeLog.RequestTrace{} + resp := handleTemplateDisplayNameAction(httptest.NewRecorder(), req, rt) + + res, ok := resp.(*types.Res) + if !ok { + t.Fatalf("unexpected response type %T", resp) + } + assert.Equal(t, int(errorcode.ErrorCode_MasterParamsError), res.Ret.RetCode) + assert.Equal(t, int64(errorcode.ErrorCode_MasterParamsError), rt.RetCode) +} + +func TestHandleTemplateLookupReturnsTemplateID(t *testing.T) { + orig := lookupTemplateIDByDisplayName + lookupTemplateIDByDisplayName = func(_ context.Context, _ string) (string, error) { + return "tpl-abc", nil + } + t.Cleanup(func() { lookupTemplateIDByDisplayName = orig }) + + req := httptest.NewRequest(http.MethodGet, "/cube/template/lookup?name=my-env", nil) + rt := &CubeLog.RequestTrace{} + resp := handleTemplateLookupAction(httptest.NewRecorder(), req, rt) + + got, ok := resp.(*templateLookupResponse) + if !ok { + t.Fatalf("unexpected response type %T", resp) + } + assert.Equal(t, int(errorcode.ErrorCode_Success), got.Ret.RetCode) + assert.Equal(t, "tpl-abc", got.TemplateID) + assert.Equal(t, "my-env", got.DisplayName) +} + +func TestHandleTemplateLookupMapsNotFound(t *testing.T) { + orig := lookupTemplateIDByDisplayName + lookupTemplateIDByDisplayName = func(_ context.Context, _ string) (string, error) { + return "", templatecenter.ErrTemplateNameNotFound + } + t.Cleanup(func() { lookupTemplateIDByDisplayName = orig }) + + req := httptest.NewRequest(http.MethodGet, "/cube/template/lookup?name=missing", nil) + rt := &CubeLog.RequestTrace{} + resp := handleTemplateLookupAction(httptest.NewRecorder(), req, rt) + + got, ok := resp.(*templateLookupResponse) + if !ok { + t.Fatalf("unexpected response type %T", resp) + } + assert.Equal(t, int(errorcode.ErrorCode_NotFound), got.Ret.RetCode) +} + +func TestHandleTemplateLookupMapsAmbiguous(t *testing.T) { + orig := lookupTemplateIDByDisplayName + lookupTemplateIDByDisplayName = func(_ context.Context, _ string) (string, error) { + return "", templatecenter.ErrTemplateNameAmbiguous + } + t.Cleanup(func() { lookupTemplateIDByDisplayName = orig }) + + req := httptest.NewRequest(http.MethodGet, "/cube/template/lookup?name=dup", nil) + rt := &CubeLog.RequestTrace{} + resp := handleTemplateLookupAction(httptest.NewRecorder(), req, rt) + + got, ok := resp.(*templateLookupResponse) + if !ok { + t.Fatalf("unexpected response type %T", resp) + } + assert.Equal(t, int(errorcode.ErrorCode_NotFound), got.Ret.RetCode) + assert.Equal(t, "template name not found", got.Ret.RetMsg) +} + +func TestHandleTemplateDisplayNameMapsConflict(t *testing.T) { + patches := gomonkey.ApplyFunc(templatecenter.UpdateDefinitionDisplayName, func(context.Context, string, string) error { + return errors.Join(templatecenter.ErrTemplateNameInUse, errors.New(`"my-env"`)) + }) + t.Cleanup(patches.Reset) + + req := httptest.NewRequest(http.MethodPost, "/cube/template/display-name", strings.NewReader(`{"template_id":"tpl-1","display_name":"my-env"}`)) + rt := &CubeLog.RequestTrace{} + resp := handleTemplateDisplayNameAction(httptest.NewRecorder(), req, rt) + + res, ok := resp.(*types.Res) + if !ok { + t.Fatalf("unexpected response type %T", resp) + } + assert.Equal(t, int(errorcode.ErrorCode_Conflict), res.Ret.RetCode) +} diff --git a/CubeMaster/pkg/service/httpservice/cube/template_from_image.go b/CubeMaster/pkg/service/httpservice/cube/template_from_image.go index aa390d776..43d39a5a4 100644 --- a/CubeMaster/pkg/service/httpservice/cube/template_from_image.go +++ b/CubeMaster/pkg/service/httpservice/cube/template_from_image.go @@ -104,10 +104,17 @@ func createTemplateFromImage(w http.ResponseWriter, r *http.Request, rt *CubeLog })) job, err := templatecenter.SubmitTemplateFromImage(ctx, req, requestBaseURL(r)) if err != nil { + code := int(errorcode.ErrorCode_MasterParamsError) + switch { + case errors.Is(err, templatecenter.ErrTemplateNameInUse): + code = int(errorcode.ErrorCode_Conflict) + case errors.Is(err, templatecenter.ErrTemplateNameInvalid): + code = int(errorcode.ErrorCode_MasterParamsError) + } return &types.CreateTemplateFromImageRes{ RequestID: req.RequestID, Ret: &types.Ret{ - RetCode: int(errorcode.ErrorCode_MasterParamsError), + RetCode: code, RetMsg: err.Error(), }, } diff --git a/CubeMaster/pkg/service/httpservice/cube/template_test.go b/CubeMaster/pkg/service/httpservice/cube/template_test.go index 7dbb3d19f..cbb1fa938 100644 --- a/CubeMaster/pkg/service/httpservice/cube/template_test.go +++ b/CubeMaster/pkg/service/httpservice/cube/template_test.go @@ -70,9 +70,9 @@ func TestDeleteTemplateMapsAttemptInProgressToParamsError(t *testing.T) { if !ok { t.Fatalf("unexpected response type %T", resp) } - assert.Equal(t, int(errorcode.ErrorCode_MasterParamsError), got.Ret.RetCode) + assert.Equal(t, int(errorcode.ErrorCode_Conflict), got.Ret.RetCode) assert.Contains(t, got.Ret.RetMsg, "build still running") - assert.Equal(t, int64(errorcode.ErrorCode_MasterParamsError), rt.RetCode) + assert.Equal(t, int64(errorcode.ErrorCode_Conflict), rt.RetCode) } func TestDeleteTemplateMapsCleanupLocatorMissingToNotFound(t *testing.T) { diff --git a/CubeMaster/pkg/service/sandbox/types/types.go b/CubeMaster/pkg/service/sandbox/types/types.go index 93a3d3b15..2c9da6810 100644 --- a/CubeMaster/pkg/service/sandbox/types/types.go +++ b/CubeMaster/pkg/service/sandbox/types/types.go @@ -510,6 +510,9 @@ type ContainerOverrides struct { type CreateTemplateFromImageReq struct { *Request + // DisplayName is the human-readable template name (E2B "name"). Stored in + // t_cube_template_definition.display_name and used for name→templateID lookup. + DisplayName string `json:"display_name,omitempty"` SourceImageRef string `json:"source_image_ref,omitempty" p:"source_image_ref" v:"required"` RegistryUsername string `json:"registry_username,omitempty"` RegistryPassword string `json:"registry_password,omitempty"` diff --git a/CubeMaster/pkg/templatecenter/cache.go b/CubeMaster/pkg/templatecenter/cache.go index 50540102b..d65ded243 100644 --- a/CubeMaster/pkg/templatecenter/cache.go +++ b/CubeMaster/pkg/templatecenter/cache.go @@ -6,6 +6,7 @@ package templatecenter import ( "context" + "fmt" "strings" "sync" "time" @@ -20,6 +21,12 @@ import ( const ( templateDefinitionCacheTTL = 360 * time.Minute templateLocalityCacheTTL = 360 * time.Minute + // Per-process only; multi-replica deployments may serve stale hits until TTL + // or until this replica handles a mutating path (see invalidateTemplateDisplayNameCache). + templateDisplayNameCacheTTL = 300 * time.Second + templateDisplayNameCacheMaxLen = 4096 + templateDisplayNameNotFoundCacheTTL = 30 * time.Second + templateDisplayNameNotFoundCacheMaxLen = 1024 ) type templateLocalitySnapshot struct { @@ -48,9 +55,12 @@ var ( // keyed by templateID. The kind is derived from a single column in // t_cube_template_definition, so its only mutation source is the same // definition write paths that already call invalidateTemplateCaches. - templateKindCache = cache.New(templateDefinitionCacheTTL, templateDefinitionCacheTTL) - templateRequestFetchGroup = &templateFetchGroup{calls: make(map[string]*templateFetchCall)} - templateRequestLockGroup = &templateLockGroup{} + templateKindCache = cache.New(templateDefinitionCacheTTL, templateDefinitionCacheTTL) + templateDisplayNameCache = cache.New(templateDisplayNameCacheTTL, templateDisplayNameCacheTTL) + templateDisplayNameNotFoundCache = cache.New(templateDisplayNameNotFoundCacheTTL, templateDisplayNameNotFoundCacheTTL) + templateRequestFetchGroup = &templateFetchGroup{calls: make(map[string]*templateFetchCall)} + templateDisplayNameFetchGroup = &templateFetchGroup{calls: make(map[string]*templateFetchCall)} + templateRequestLockGroup = &templateLockGroup{} ) func (g *templateLockGroup) get(templateID string) *sync.RWMutex { @@ -100,12 +110,17 @@ func (g *templateFetchGroup) Do(key string, fn func() (interface{}, error)) (int g.calls[key] = call g.mu.Unlock() - call.val, call.err = fn() - close(call.done) + defer func() { + if r := recover(); r != nil { + call.err = fmt.Errorf("template fetch panicked: %v", r) + } + close(call.done) + g.mu.Lock() + delete(g.calls, key) + g.mu.Unlock() + }() - g.mu.Lock() - delete(g.calls, key) - g.mu.Unlock() + call.val, call.err = fn() return call.val, call.err } @@ -200,6 +215,86 @@ func invalidateTemplateCaches(templateID string) { localcache.InvalidateImageState(templateID) } +func displayNameCacheKey(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func getCachedTemplateIDByDisplayName(key string) (string, bool) { + if key == "" { + return "", false + } + if _, ok := templateDisplayNameNotFoundCache.Get(key); ok { + return "", false + } + v, ok := templateDisplayNameCache.Get(key) + if !ok { + return "", false + } + templateID, ok := v.(string) + if !ok || strings.TrimSpace(templateID) == "" { + templateDisplayNameCache.Delete(key) + return "", false + } + return templateID, true +} + +func isDisplayNameNotFoundCached(key string) bool { + if key == "" { + return false + } + _, ok := templateDisplayNameNotFoundCache.Get(key) + return ok +} + +func setTemplateDisplayNameNotFoundCache(key string) { + key = displayNameCacheKey(key) + if key == "" { + return + } + if templateDisplayNameNotFoundCache.ItemCount() >= templateDisplayNameNotFoundCacheMaxLen { + evictOneDisplayNameNotFoundCacheEntry() + } + templateDisplayNameNotFoundCache.Set(key, true, templateDisplayNameNotFoundCacheTTL) +} + +func setTemplateDisplayNameCache(key, templateID string) { + key = displayNameCacheKey(key) + templateID = strings.TrimSpace(templateID) + if key == "" || templateID == "" { + return + } + templateDisplayNameNotFoundCache.Delete(key) + if templateDisplayNameCache.ItemCount() >= templateDisplayNameCacheMaxLen { + evictOneDisplayNameCacheEntry() + } + templateDisplayNameCache.Set(key, templateID, templateDisplayNameCacheTTL) +} + +func evictOneDisplayNameCacheEntry() { + for key := range templateDisplayNameCache.Items() { + templateDisplayNameCache.Delete(key) + return + } +} + +func evictOneDisplayNameNotFoundCacheEntry() { + for key := range templateDisplayNameNotFoundCache.Items() { + templateDisplayNameNotFoundCache.Delete(key) + return + } +} + +func invalidateTemplateDisplayNameCache(names ...string) { + for _, name := range names { + key := displayNameCacheKey(name) + if key == "" { + continue + } + templateDisplayNameCache.Delete(key) + templateDisplayNameNotFoundCache.Delete(key) + } +} + // getCachedTemplateKind returns the cached kind for a templateID. // The second return value reports whether the entry was present. func getCachedTemplateKind(templateID string) (string, bool) { diff --git a/CubeMaster/pkg/templatecenter/cache_test.go b/CubeMaster/pkg/templatecenter/cache_test.go index 27ec46326..1ad672ad2 100644 --- a/CubeMaster/pkg/templatecenter/cache_test.go +++ b/CubeMaster/pkg/templatecenter/cache_test.go @@ -5,6 +5,7 @@ package templatecenter import ( + "fmt" "testing" "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/constants" @@ -142,6 +143,60 @@ func TestTemplateKindCacheRoundTrip(t *testing.T) { } } +func TestTemplateDisplayNameCacheRoundTrip(t *testing.T) { + templateDisplayNameCache.Flush() + templateDisplayNameNotFoundCache.Flush() + + setTemplateDisplayNameCache("My-Env", "tpl-1") + if got, ok := getCachedTemplateIDByDisplayName("my-env"); !ok || got != "tpl-1" { + t.Fatalf("expected cache hit for tpl-1, got %q ok=%v", got, ok) + } + + invalidateTemplateDisplayNameCache("My-Env") + if _, ok := getCachedTemplateIDByDisplayName("my-env"); ok { + t.Fatal("expected cache miss after invalidation") + } +} + +func TestDisplayNameNotFoundCache(t *testing.T) { + templateDisplayNameCache.Flush() + templateDisplayNameNotFoundCache.Flush() + + setTemplateDisplayNameNotFoundCache("missing") + if !isDisplayNameNotFoundCached("missing") { + t.Fatal("expected not-found cache hit") + } + if _, ok := getCachedTemplateIDByDisplayName("missing"); ok { + t.Fatal("expected positive cache miss when not-found is cached") + } + + invalidateTemplateDisplayNameCache("missing") + if isDisplayNameNotFoundCached("missing") { + t.Fatal("expected not-found cache cleared after invalidation") + } +} + +func TestDisplayNameNotFoundCacheBounded(t *testing.T) { + templateDisplayNameNotFoundCache.Flush() + for i := 0; i < templateDisplayNameNotFoundCacheMaxLen+10; i++ { + setTemplateDisplayNameNotFoundCache(fmt.Sprintf("missing-%d", i)) + } + if got := templateDisplayNameNotFoundCache.ItemCount(); got > templateDisplayNameNotFoundCacheMaxLen { + t.Fatalf("not-found cache len = %d, want <= %d", got, templateDisplayNameNotFoundCacheMaxLen) + } +} + +func TestDisplayNamePositiveCacheBounded(t *testing.T) { + templateDisplayNameCache.Flush() + templateDisplayNameNotFoundCache.Flush() + for i := 0; i < templateDisplayNameCacheMaxLen+10; i++ { + setTemplateDisplayNameCache(fmt.Sprintf("name-%d", i), fmt.Sprintf("tpl-%d", i)) + } + if got := templateDisplayNameCache.ItemCount(); got > templateDisplayNameCacheMaxLen { + t.Fatalf("positive cache len = %d, want <= %d", got, templateDisplayNameCacheMaxLen) + } +} + func TestTemplateWriteLockClearsConcurrentReadRefill(t *testing.T) { templateDefinitionCache.Flush() templateLocalityReadyCache.Flush() diff --git a/CubeMaster/pkg/templatecenter/delete.go b/CubeMaster/pkg/templatecenter/delete.go index ec350eab3..eba574c03 100644 --- a/CubeMaster/pkg/templatecenter/delete.go +++ b/CubeMaster/pkg/templatecenter/delete.go @@ -113,7 +113,7 @@ func deleteTemplateWithTargets(ctx context.Context, templateID string, targets * if err := runArtifactCleanup(ctx, templateID, targets); err != nil { return err } - if err := runMetadataCleanup(ctx, templateID); err != nil { + if err := runMetadataCleanup(ctx, templateID, displayNameFromDefinition(targets.Definition)); err != nil { invalidateTemplateCaches(templateID) return err } @@ -244,7 +244,16 @@ func (t *templateCleanupTargets) shouldCheckInUse() bool { return !strings.EqualFold(t.Definition.Status, StatusFailed) } -func cleanupTemplateMetadata(ctx context.Context, templateID string) error { +func cleanupTemplateMetadata(ctx context.Context, templateID string, knownDisplayName ...string) error { + displayName := "" + if len(knownDisplayName) > 0 { + displayName = strings.TrimSpace(knownDisplayName[0]) + } + if displayName == "" { + if def, err := GetDefinition(ctx, templateID); err == nil { + displayName = strings.TrimSpace(def.DisplayName) + } + } var cleanupErr error if err := store.db.WithContext(ctx).Unscoped().Table(constants.TemplateReplicaTableName). Where("template_id = ?", templateID).Delete(&models.TemplateReplica{}).Error; err != nil { @@ -254,6 +263,9 @@ func cleanupTemplateMetadata(ctx context.Context, templateID string) error { Where("template_id = ?", templateID).Delete(&models.TemplateDefinition{}).Error; err != nil { cleanupErr = errors.Join(cleanupErr, err) } + if cleanupErr == nil && displayName != "" { + invalidateTemplateDisplayNameCache(displayName) + } return cleanupErr } diff --git a/CubeMaster/pkg/templatecenter/delete_test.go b/CubeMaster/pkg/templatecenter/delete_test.go index f929a6d5d..876557fe6 100644 --- a/CubeMaster/pkg/templatecenter/delete_test.go +++ b/CubeMaster/pkg/templatecenter/delete_test.go @@ -55,7 +55,7 @@ func TestDeleteTemplateWithTargetsAllowsJobOnlyCleanup(t *testing.T) { } return nil } - runMetadataCleanup = func(ctx context.Context, templateID string) error { + runMetadataCleanup = func(ctx context.Context, templateID string, _ ...string) error { metadataCalled = true return nil } @@ -148,7 +148,7 @@ func TestDeleteTemplateWithTargetsAllowsOrphanedJobCleanup(t *testing.T) { runArtifactCleanup = func(ctx context.Context, templateID string, targets *templateCleanupTargets) error { return nil } - runMetadataCleanup = func(ctx context.Context, templateID string) error { + runMetadataCleanup = func(ctx context.Context, templateID string, _ ...string) error { metadataCalled = true return nil } @@ -202,7 +202,7 @@ func TestDeleteTemplateWithTargetsAllowsArtifactOnlyCleanupWithoutLocator(t *tes } return nil } - runMetadataCleanup = func(ctx context.Context, templateID string) error { + runMetadataCleanup = func(ctx context.Context, templateID string, _ ...string) error { metadataCalled = true return nil } @@ -246,7 +246,7 @@ func TestDeleteTemplateWithTargetsPreservesJobsAfterPartialFailure(t *testing.T) runArtifactCleanup = func(ctx context.Context, templateID string, targets *templateCleanupTargets) error { return nil } - runMetadataCleanup = func(ctx context.Context, templateID string) error { + runMetadataCleanup = func(ctx context.Context, templateID string, _ ...string) error { metadataCalled = true return nil } @@ -300,7 +300,7 @@ func TestDeleteTemplateWithTargetsPreservesMetadataAfterArtifactFailure(t *testi runArtifactCleanup = func(ctx context.Context, templateID string, targets *templateCleanupTargets) error { return artifactErr } - runMetadataCleanup = func(ctx context.Context, templateID string) error { + runMetadataCleanup = func(ctx context.Context, templateID string, _ ...string) error { metadataCalled = true return nil } diff --git a/CubeMaster/pkg/templatecenter/image_job_runner.go b/CubeMaster/pkg/templatecenter/image_job_runner.go index c664c6195..4172dc2a5 100644 --- a/CubeMaster/pkg/templatecenter/image_job_runner.go +++ b/CubeMaster/pkg/templatecenter/image_job_runner.go @@ -154,7 +154,7 @@ func runTemplateImageJob(ctx context.Context, jobID string, req *types.CreateTem var info *TemplateInfo storedReq, err := normalizeStoredTemplateRequest(generatedReq) if err != nil { - _ = updateTemplateImageJob(ctx, jobID, map[string]any{ + failTemplateImageJob(ctx, jobID, req.TemplateID, map[string]any{ "status": JobStatusFailed, "phase": JobPhaseCreatingTemplate, "progress": 100, @@ -163,8 +163,8 @@ func runTemplateImageJob(ctx context.Context, jobID string, req *types.CreateTem }) return } - if _, err := ensureTemplateDefinition(ctx, req.TemplateID, storedReq, generatedReq.InstanceType, constants.GetAppSnapshotVersion(generatedReq.Annotations)); err != nil { - _ = updateTemplateImageJob(ctx, jobID, map[string]any{ + if _, err := ensureTemplateDefinition(ctx, req.TemplateID, storedReq, generatedReq.InstanceType, constants.GetAppSnapshotVersion(generatedReq.Annotations), req.DisplayName); err != nil { + failTemplateImageJob(ctx, jobID, req.TemplateID, map[string]any{ "status": JobStatusFailed, "phase": JobPhaseCreatingTemplate, "progress": 100, @@ -188,7 +188,7 @@ func runTemplateImageJob(ctx context.Context, jobID string, req *types.CreateTem logger.Errorf("cleanup fresh rootfs artifact after create template error fail: %v", cleanupErr) } } - _ = updateTemplateImageJob(ctx, jobID, map[string]any{ + failTemplateImageJob(ctx, jobID, req.TemplateID, map[string]any{ "status": JobStatusFailed, "phase": JobPhaseCreatingTemplate, "progress": 100, diff --git a/CubeMaster/pkg/templatecenter/job_repo.go b/CubeMaster/pkg/templatecenter/job_repo.go index 8817c9a28..5dc0e27e2 100644 --- a/CubeMaster/pkg/templatecenter/job_repo.go +++ b/CubeMaster/pkg/templatecenter/job_repo.go @@ -6,10 +6,13 @@ package templatecenter import ( "context" + "strings" + "time" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/constants" "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/db/models" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/log" "gorm.io/gorm" - "time" ) func getTemplateImageJobRecordByID(ctx context.Context, jobID string) (*models.TemplateImageJob, error) { @@ -145,6 +148,16 @@ func updateTemplateImageJob(ctx context.Context, jobID string, values map[string return nil } +func failTemplateImageJob(ctx context.Context, jobID, templateID string, values map[string]any) { + if err := updateTemplateImageJob(ctx, jobID, values); err != nil { + log.G(ctx).Warnf("update failed template image job: job_id=%s err=%v", jobID, err) + } + if templateStatus, ok := values["template_status"].(string); ok && + strings.EqualFold(templateStatus, StatusFailed) { + ReleaseTemplateDisplayNameAfterBuildFailure(ctx, templateID) + } +} + func updateRootfsArtifact(ctx context.Context, artifactID string, values map[string]any) error { values["updated_at"] = time.Now() tx := store.db.WithContext(ctx).Table(constants.RootfsArtifactTableName). diff --git a/CubeMaster/pkg/templatecenter/redo.go b/CubeMaster/pkg/templatecenter/redo.go index 38d117e7f..d3b81e37f 100644 --- a/CubeMaster/pkg/templatecenter/redo.go +++ b/CubeMaster/pkg/templatecenter/redo.go @@ -341,7 +341,7 @@ func runRedoTemplateImageJob(ctx context.Context, jobID string, req *types.RedoT failRedoTemplateImageJob(ctx, jobID, resumePhase, err.Error()) return } - if _, err := ensureTemplateDefinition(ctx, req.TemplateID, storedReq, generatedReq.InstanceType, constants.GetAppSnapshotVersion(generatedReq.Annotations)); err != nil { + if _, err := ensureTemplateDefinition(ctx, req.TemplateID, storedReq, generatedReq.InstanceType, constants.GetAppSnapshotVersion(generatedReq.Annotations), sourceReq.DisplayName); err != nil { failRedoTemplateImageJob(ctx, jobID, resumePhase, err.Error()) return } diff --git a/CubeMaster/pkg/templatecenter/request_validation.go b/CubeMaster/pkg/templatecenter/request_validation.go index 7db4d2892..69aa5474d 100644 --- a/CubeMaster/pkg/templatecenter/request_validation.go +++ b/CubeMaster/pkg/templatecenter/request_validation.go @@ -100,6 +100,11 @@ func normalizeTemplateImageRequest(req *types.CreateTemplateFromImageReq) (*type if err := validateTemplateCubeNetworkConfig(cloned.CubeNetworkConfig); err != nil { return nil, err } + displayName, err := NormalizeTemplateDisplayName(cloned.DisplayName) + if err != nil { + return nil, err + } + cloned.DisplayName = displayName return &cloned, nil } diff --git a/CubeMaster/pkg/templatecenter/snapshot_ops.go b/CubeMaster/pkg/templatecenter/snapshot_ops.go index ec27a1eb6..0627c4346 100644 --- a/CubeMaster/pkg/templatecenter/snapshot_ops.go +++ b/CubeMaster/pkg/templatecenter/snapshot_ops.go @@ -1036,10 +1036,7 @@ func createDefinitionTx(ctx context.Context, tx *gorm.DB, templateID string, sto model.StorageBackend = StorageBackendCow } if err := tx.Table(constants.TemplateDefinitionTableName).Create(model).Error; err != nil { - if strings.Contains(err.Error(), "1062") || strings.Contains(err.Error(), "Duplicate entry") { - return ErrDuplicateTemplate - } - return err + return mapDefinitionCreateDuplicateError(ctx, err, opts.DisplayName) } return nil } diff --git a/CubeMaster/pkg/templatecenter/store.go b/CubeMaster/pkg/templatecenter/store.go index b784e9929..3ffa38251 100644 --- a/CubeMaster/pkg/templatecenter/store.go +++ b/CubeMaster/pkg/templatecenter/store.go @@ -237,7 +237,7 @@ func ListTemplates(ctx context.Context) ([]TemplateInfo, error) { if _, ok := seen[job.TemplateID]; ok { continue } - out = append(out, templateInfoFromJob(&job)) + out = append(out, templateInfoFromJob(ctx, &job)) seen[job.TemplateID] = struct{}{} } return out, nil @@ -624,19 +624,31 @@ func createDefinitionWithOptions(ctx context.Context, templateID string, storedR return createDefinitionTx(ctx, store.db.WithContext(ctx), templateID, storedReq, instanceType, version, opts) } -func ensureTemplateDefinition(ctx context.Context, templateID string, storedReq *sandboxtypes.CreateCubeSandboxReq, instanceType, version string) (bool, error) { +func ensureTemplateDefinition(ctx context.Context, templateID string, storedReq *sandboxtypes.CreateCubeSandboxReq, instanceType, version, displayName string) (bool, error) { if _, err := GetDefinition(ctx, templateID); err == nil { return false, nil } else if !errors.Is(err, ErrTemplateNotFound) { return false, err } - if err := createDefinition(ctx, templateID, storedReq, instanceType, version); err != nil { + created := false + if err := withDisplayNameCreateLock(ctx, displayName, templateID, func() error { + if err := createDefinitionWithOptions(ctx, templateID, storedReq, instanceType, version, definitionCreateOptions{ + DisplayName: displayName, + }); err != nil { + return err + } + created = true + if strings.TrimSpace(displayName) != "" { + setTemplateDisplayNameCache(displayName, templateID) + } + if cacheErr := setTemplateRequestCache(templateID, storedReq); cacheErr != nil { + log.G(ctx).Warnf("set template request cache fail, template=%s err=%v", templateID, cacheErr) + } + return nil + }); err != nil { return false, err } - if cacheErr := setTemplateRequestCache(templateID, storedReq); cacheErr != nil { - log.G(ctx).Warnf("set template request cache fail, template=%s err=%v", templateID, cacheErr) - } - return true, nil + return created, nil } func finalizeTemplateReplicas(ctx context.Context, templateID, instanceType, version string, replicas []ReplicaStatus) (*TemplateInfo, error) { @@ -687,7 +699,7 @@ func GetTemplateInfo(ctx context.Context, templateID string) (*TemplateInfo, err if jobErr != nil { return nil, err } - info := templateInfoFromJob(job) + info := templateInfoFromJob(ctx, job) return &info, nil } replicas, err := ListReplicas(ctx, templateID) @@ -725,7 +737,7 @@ func GetTemplateInfo(ctx context.Context, templateID string) (*TemplateInfo, err return out, nil } -func templateInfoFromJob(job *models.TemplateImageJob) TemplateInfo { +func templateInfoFromJob(ctx context.Context, job *models.TemplateImageJob) TemplateInfo { if job == nil { return TemplateInfo{} } @@ -742,6 +754,7 @@ func templateInfoFromJob(job *models.TemplateImageJob) TemplateInfo { Version: DefaultTemplateVersion, Status: status, LastError: job.ErrorMessage, + DisplayName: displayNameFromTemplateImageJob(ctx, job), CreatedAt: formatUTCRFC3339(job.CreatedAt), ImageInfo: composeImageInfo(job.SourceImageRef, job.SourceImageDigest), } diff --git a/CubeMaster/pkg/templatecenter/template_display_name.go b/CubeMaster/pkg/templatecenter/template_display_name.go new file mode 100644 index 000000000..5fff374f1 --- /dev/null +++ b/CubeMaster/pkg/templatecenter/template_display_name.go @@ -0,0 +1,400 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package templatecenter + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/constants" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/db/models" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/log" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" +) + +const maxTemplateDisplayNameLen = 256 + +var ( + ErrTemplateNameNotFound = errors.New("template name not found") + ErrTemplateNameAmbiguous = errors.New("template name is ambiguous") + ErrTemplateNameInUse = errors.New("template name is already in use") + ErrTemplateNameInvalid = errors.New("template name is invalid") +) + +// NormalizeTemplateDisplayName trims and validates an E2B-style template name. +// An empty string means no display name (optional). Allowed characters are ASCII +// letters, digits, hyphen, and underscore only (same charset as E2B template names). +func NormalizeTemplateDisplayName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", nil + } + if len(name) > maxTemplateDisplayNameLen { + return "", fmt.Errorf("%w: exceeds %d characters", ErrTemplateNameInvalid, maxTemplateDisplayNameLen) + } + for i, r := range name { + if !isAllowedTemplateDisplayNameRune(r) { + return "", fmt.Errorf("%w: must contain only letters, digits, hyphens, and underscores", ErrTemplateNameInvalid) + } + if i == 0 && !isAllowedTemplateDisplayNameStartRune(r) { + return "", fmt.Errorf("%w: must start with a letter or digit", ErrTemplateNameInvalid) + } + } + lower := strings.ToLower(name) + if strings.HasPrefix(lower, "tpl-") || strings.HasPrefix(lower, "snap-") { + return "", fmt.Errorf("%w: must not use tpl- or snap- prefix", ErrTemplateNameInvalid) + } + return name, nil +} + +func isAllowedTemplateDisplayNameRune(r rune) bool { + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || r == '_' +} + +func isAllowedTemplateDisplayNameStartRune(r rune) bool { + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') +} + +// FindTemplateIDByDisplayName resolves a template name to its templateID using a +// case-insensitive exact match on display_name. Results are cached briefly with +// concurrent lookup coalescing on the same normalized name. +// +// Cache is in-process only. With multiple CubeMaster replicas, prefer +// FindTemplateIDByDisplayNameFresh on consistency-sensitive paths (rename, create +// validation); otherwise callers may observe stale positive or negative entries +// until TTL expiry or until that replica executes a mutating display-name path. +func FindTemplateIDByDisplayName(ctx context.Context, name string) (string, error) { + return findTemplateIDByDisplayName(ctx, name, false) +} + +// FindTemplateIDByDisplayNameFresh bypasses the read cache (still uses singleflight). +func FindTemplateIDByDisplayNameFresh(ctx context.Context, name string) (string, error) { + return findTemplateIDByDisplayName(ctx, name, true) +} + +func findTemplateIDByDisplayName(ctx context.Context, name string, fresh bool) (string, error) { + if !isReady() { + return "", ErrTemplateStoreNotInitialized + } + normalized, err := NormalizeTemplateDisplayName(name) + if err != nil { + return "", err + } + if normalized == "" { + return "", ErrTemplateNameNotFound + } + cacheKey := strings.ToLower(normalized) + if !fresh { + if templateID, ok := getCachedTemplateIDByDisplayName(cacheKey); ok { + return templateID, nil + } + if isDisplayNameNotFoundCached(cacheKey) { + return "", ErrTemplateNameNotFound + } + } + groupKey := cacheKey + if fresh { + groupKey = "fresh:" + cacheKey + } + result, err := templateDisplayNameFetchGroup.Do(groupKey, func() (interface{}, error) { + // Re-check after entering singleflight: a concurrent goroutine may have + // populated the cache between the outer check and Do(). + if !fresh { + if templateID, ok := getCachedTemplateIDByDisplayName(cacheKey); ok { + return templateID, nil + } + if isDisplayNameNotFoundCached(cacheKey) { + return "", ErrTemplateNameNotFound + } + } + templateID, lookupErr := findTemplateIDByDisplayNameFromDB(ctx, normalized) + if lookupErr != nil { + if errors.Is(lookupErr, ErrTemplateNameNotFound) && !fresh { + setTemplateDisplayNameNotFoundCache(cacheKey) + } + return "", lookupErr + } + setTemplateDisplayNameCache(cacheKey, templateID) + return templateID, nil + }) + if err != nil { + return "", err + } + templateID, _ := result.(string) + return templateID, nil +} + +func findTemplateIDByDisplayNameFromDB(ctx context.Context, normalized string) (string, error) { + owners, err := findDisplayNameOwnersFromDB(ctx, normalized) + if err != nil { + return "", err + } + switch len(owners) { + case 0: + return "", ErrTemplateNameNotFound + case 1: + return owners[0].TemplateID, nil + default: + return "", ErrTemplateNameAmbiguous + } +} + +type displayNameOwner struct { + TemplateID string + Status string +} + +func findDisplayNameOwnersFromDB(ctx context.Context, normalized string) ([]displayNameOwner, error) { + var owners []displayNameOwner + if err := store.db.WithContext(ctx).Table(constants.TemplateDefinitionTableName). + Select("template_id", "status"). + Where("display_name_key = ? AND kind = ?", strings.ToLower(normalized), TemplateKindTemplate). + Limit(2). + Find(&owners).Error; err != nil { + return nil, err + } + return owners, nil +} + +func displayNameLockKey(normalized string) string { + return "display-name:" + strings.ToLower(normalized) +} + +func displayNameFromDefinition(def *models.TemplateDefinition) string { + if def == nil { + return "" + } + return def.DisplayName +} + +func assertDisplayNameAvailableForCreate(ctx context.Context, normalized, templateID string) error { + owners, err := findDisplayNameOwnersFromDB(ctx, normalized) + if err != nil { + return err + } + switch len(owners) { + case 0: + return nil + case 1: + owner := owners[0] + if owner.TemplateID == templateID { + return nil + } + if strings.EqualFold(owner.Status, StatusFailed) { + def, defErr := GetDefinition(ctx, owner.TemplateID) + if errors.Is(defErr, ErrTemplateNotFound) { + return nil + } + if defErr != nil { + return defErr + } + // Caller (withDisplayNameCreateLock) already holds the per-name write lock. + return clearDefinitionDisplayNameLocked(ctx, owner.TemplateID, def) + } + return fmt.Errorf("%w: %q", ErrTemplateNameInUse, normalized) + default: + return ErrTemplateNameAmbiguous + } +} + +// withDisplayNameCreateLock runs fn while holding the per-name write lock after +// verifying the name is free. Used to keep availability check and definition INSERT +// atomic in ensureTemplateDefinition. +func withDisplayNameCreateLock(ctx context.Context, displayName, templateID string, fn func() error) error { + normalized, err := NormalizeTemplateDisplayName(displayName) + if err != nil { + return err + } + if normalized == "" { + return fn() + } + return withTemplateWriteLock(displayNameLockKey(normalized), func() error { + if err := assertDisplayNameAvailableForCreate(ctx, normalized, templateID); err != nil { + return err + } + return fn() + }) +} + +// ReserveTemplateDisplayNameForCreate checks that displayName is free before persisting +// a template definition. Names on FAILED templates are cleared so they can be reused. +func ReserveTemplateDisplayNameForCreate(ctx context.Context, displayName, templateID string) error { + return withDisplayNameCreateLock(ctx, displayName, templateID, func() error { return nil }) +} + +// ReleaseTemplateDisplayNameAfterBuildFailure clears display_name on a template +// whose image build failed so the name can be reused on a later create. +func ReleaseTemplateDisplayNameAfterBuildFailure(ctx context.Context, templateID string) { + if strings.TrimSpace(templateID) == "" { + return + } + if err := clearDefinitionDisplayName(ctx, templateID); err != nil && + !errors.Is(err, ErrTemplateNotFound) { + log.G(ctx).Warnf("release template display name after build failure: template=%s err=%v", templateID, err) + } +} + +// clearDefinitionDisplayNameLocked clears display_name without acquiring the per-name +// write lock. Callers that already hold displayNameLockKey(normalized) must use this +// path to avoid reentrant lock deadlock. +func clearDefinitionDisplayNameLocked(ctx context.Context, templateID string, def *models.TemplateDefinition) error { + if !isReady() { + return ErrTemplateStoreNotInitialized + } + templateID = strings.TrimSpace(templateID) + if templateID == "" { + return errors.New("template_id is required") + } + result := store.db.WithContext(ctx).Table(constants.TemplateDefinitionTableName). + Where("template_id = ?", templateID). + Update("display_name", "") + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrTemplateNotFound + } + if def != nil { + invalidateTemplateDisplayNameCache(def.DisplayName) + } + invalidateTemplateCaches(templateID) + return nil +} + +func clearDefinitionDisplayName(ctx context.Context, templateID string) error { + if !isReady() { + return ErrTemplateStoreNotInitialized + } + templateID = strings.TrimSpace(templateID) + if templateID == "" { + return errors.New("template_id is required") + } + def, defErr := GetDefinition(ctx, templateID) + if errors.Is(defErr, ErrTemplateNotFound) { + return ErrTemplateNotFound + } + if defErr != nil { + return defErr + } + if normalized, normErr := NormalizeTemplateDisplayName(def.DisplayName); normErr == nil && normalized != "" { + return withTemplateWriteLock(displayNameLockKey(normalized), func() error { + return clearDefinitionDisplayNameLocked(ctx, templateID, def) + }) + } + return clearDefinitionDisplayNameLocked(ctx, templateID, def) +} + +// ValidateTemplateDisplayNameAvailable ensures name is not used by another template. +func ValidateTemplateDisplayNameAvailable(ctx context.Context, name, excludeTemplateID string) error { + existing, err := findTemplateIDByDisplayNameFromDB(ctx, name) + if errors.Is(err, ErrTemplateNameNotFound) { + return nil + } + if err != nil { + return err + } + if excludeTemplateID != "" && existing == excludeTemplateID { + return nil + } + return fmt.Errorf("%w: %q", ErrTemplateNameInUse, strings.TrimSpace(name)) +} + +// UpdateDefinitionDisplayName sets the display_name for an existing template definition. +func UpdateDefinitionDisplayName(ctx context.Context, templateID, displayName string) error { + if !isReady() { + return ErrTemplateStoreNotInitialized + } + templateID = strings.TrimSpace(templateID) + if templateID == "" { + return errors.New("template_id is required") + } + def, err := GetDefinition(ctx, templateID) + if err != nil { + return err + } + if kind := strings.TrimSpace(def.Kind); !definitionSupportsTemplateDisplayName(kind) { + return ErrTemplateNotFound + } + normalized, err := normalizeRequiredTemplateDisplayName(displayName) + if err != nil { + return err + } + return withTemplateWriteLock(displayNameLockKey(normalized), func() error { + if err := ValidateTemplateDisplayNameAvailable(ctx, normalized, templateID); err != nil { + return err + } + oldDisplayName := def.DisplayName + result := store.db.WithContext(ctx).Table(constants.TemplateDefinitionTableName). + Where("template_id = ?", templateID). + Update("display_name", normalized) + if result.Error != nil { + return mapDefinitionCreateDuplicateError(ctx, result.Error, normalized) + } + if result.RowsAffected == 0 { + return ErrTemplateNotFound + } + invalidateTemplateDisplayNameCache(oldDisplayName, normalized) + invalidateTemplateCaches(templateID) + return nil + }) +} + +// definitionSupportsTemplateDisplayName reports whether a definition kind can +// carry a user-facing template name. Only kind == "template" qualifies, keeping +// this in lockstep with the display_name_key generated column (NULL unless +// kind = 'template') and FindTemplateIDByDisplayName (filters kind = 'template'). +func definitionSupportsTemplateDisplayName(kind string) bool { + return strings.TrimSpace(kind) == TemplateKindTemplate +} + +func normalizeRequiredTemplateDisplayName(displayName string) (string, error) { + normalized, err := NormalizeTemplateDisplayName(displayName) + if err != nil { + return "", err + } + if normalized == "" { + return "", fmt.Errorf("%w: name is required", ErrTemplateNameInvalid) + } + return normalized, nil +} + +func mapDefinitionCreateDuplicateError(ctx context.Context, err error, displayName string) error { + if err == nil { + return err + } + if !isDuplicateKeyError(err) { + log.G(ctx).Errorf("template definition DB error: %v", err) + return errors.New("internal error") + } + if strings.Contains(err.Error(), "display_name_key") && strings.TrimSpace(displayName) != "" { + return fmt.Errorf("%w: %q", ErrTemplateNameInUse, strings.TrimSpace(displayName)) + } + return ErrDuplicateTemplate +} + +func displayNameFromTemplateImageJob(ctx context.Context, job *models.TemplateImageJob) string { + if job == nil || strings.TrimSpace(job.RequestJSON) == "" { + return "" + } + var req types.CreateTemplateFromImageReq + if err := json.Unmarshal([]byte(job.RequestJSON), &req); err != nil { + log.G(ctx).Warnf("template image job display_name unmarshal failed: %v", err) + return "" + } + normalized, err := NormalizeTemplateDisplayName(req.DisplayName) + if err != nil { + log.G(ctx).Warnf("template image job display_name normalize failed: %v", err) + return "" + } + return normalized +} diff --git a/CubeMaster/pkg/templatecenter/template_display_name_test.go b/CubeMaster/pkg/templatecenter/template_display_name_test.go new file mode 100644 index 000000000..e3e3146cc --- /dev/null +++ b/CubeMaster/pkg/templatecenter/template_display_name_test.go @@ -0,0 +1,417 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +package templatecenter + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/agiledragon/gomonkey/v2" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/db/models" + "gorm.io/gorm" +) + +func TestNormalizeTemplateDisplayName(t *testing.T) { + t.Parallel() + + got, err := NormalizeTemplateDisplayName(" cubesandbox-template ") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "cubesandbox-template" { + t.Fatalf("got %q", got) + } + + if _, err := NormalizeTemplateDisplayName(""); err != nil { + t.Fatalf("empty name should be allowed: %v", err) + } + + if _, err := NormalizeTemplateDisplayName("tpl-custom"); !errors.Is(err, ErrTemplateNameInvalid) { + t.Fatalf("expected ErrTemplateNameInvalid for tpl- prefix, got %v", err) + } + if _, err := NormalizeTemplateDisplayName("SNAP-backup"); !errors.Is(err, ErrTemplateNameInvalid) { + t.Fatalf("expected ErrTemplateNameInvalid for snap- prefix, got %v", err) + } + + longName := strings.Repeat("a", maxTemplateDisplayNameLen+1) + if _, err := NormalizeTemplateDisplayName(longName); !errors.Is(err, ErrTemplateNameInvalid) { + t.Fatalf("expected length validation error, got %v", err) + } + + if _, err := NormalizeTemplateDisplayName("