Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions CubeAPI/src/cubemaster/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<String, CubeMasterError> {
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<String, CubeMasterError> {
self.lookup_template_by_name(name, true).await
}

async fn lookup_template_by_name(
&self,
name: &str,
fresh: bool,
) -> Result<String, CubeMasterError> {
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<RetEnvelope, CubeMasterError> {
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,
Expand Down Expand Up @@ -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 { .. })
}
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -1629,6 +1707,8 @@ pub struct TemplateResponse {
pub replicas: Vec<serde_json::Value>,
#[serde(default)]
pub create_request: Option<serde_json::Value>,
#[serde(default)]
pub display_name: String,
}

/// Body for DELETE /cube/template.
Expand All @@ -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)]
Expand Down Expand Up @@ -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<String>,
/// Writable layer size, e.g. "1G".
#[serde(skip_serializing_if = "Option::is_none")]
pub writable_layer_size: Option<String>,
Expand Down
10 changes: 10 additions & 0 deletions CubeAPI/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand All @@ -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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error messages leak internal details to API clients

These new variants follow the same pattern as AppError::Internal (line 54): the full error message is serialized into the JSON response body. For BadGateway and ServiceUnavailable, this leaks HTTP error details from cross-service CubeMaster calls (timeouts, connection refusals) directly to external callers. Consider logging the full error server-side and returning a generic message to the client, matching the Go-side pattern (template_display_name.go:356 — log detail, return "internal error").

}
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()),
Expand Down
100 changes: 84 additions & 16 deletions CubeAPI/src/handlers/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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<AppState>,
Query(query): Query<TemplateNameLookupQuery>,
) -> AppResult<impl IntoResponse> {
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)
)
Expand Down Expand Up @@ -84,10 +121,11 @@ pub async fn template_compat(State(state): State<AppState>) -> AppResult<impl In
post,
path = "/templates/compat/{templateID}/adopt-baseline",
params(
("templateID" = String, Path, description = "Template identifier")
("templateID" = String, Path, description = "Template identifier (tpl-*) or human-readable display name")
),
responses(
(status = 200, description = "Adopted UNKNOWN replicas to current baseline", body = TemplateCompatAdoptResponseView),
(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)
)
Expand All @@ -109,6 +147,17 @@ pub async fn adopt_template_compat_baseline(

// ─── POST /templates ──────────────────────────────────────────────────────────

#[utoipa::path(
post,
path = "/templates",
request_body = CreateTemplateRequest,
responses(
(status = 202, description = "Template build accepted", body = TemplateBuildJob),
(status = 400, description = "Invalid request", body = ApiError),
(status = 409, description = "Name already in use", body = ApiError),
(status = 500, description = "Unexpected backend error", body = ApiError)
)
)]
pub async fn create_template(
State(state): State<AppState>,
Json(body): Json<CreateTemplateRequest>,
Expand All @@ -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<AppState>,
Path(_template_id): Path<String>,
_body: Json<serde_json::Value>,
State(state): State<AppState>,
Path(template_id): Path<String>,
Json(body): Json<UpdateTemplateRequest>,
) -> AppResult<impl IntoResponse> {
// 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 ────────────────────────────────────────────
Expand All @@ -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) {
Expand Down Expand Up @@ -239,13 +307,13 @@ fn default_log_limit() -> i32 {

pub async fn get_template_build_logs(
State(state): State<AppState>,
Path((_template_id, build_id)): Path<(String, String)>,
Path((template_id, build_id)): Path<(String, String)>,
Query(_params): Query<BuildLogsQuery>,
) -> AppResult<impl IntoResponse> {
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)))
}
Loading
Loading