diff --git a/crates/temps-core/src/templates.rs b/crates/temps-core/src/templates.rs index 16e7a587c..a3996b12b 100644 --- a/crates/temps-core/src/templates.rs +++ b/crates/temps-core/src/templates.rs @@ -189,6 +189,16 @@ pub struct ProjectTemplate { /// absent, the template builds from source. #[serde(default)] pub image: Option, + /// arm64/aarch64 variant of `image`, used instead of it when the deploy + /// host is arm64. Populate this when the upstream registry does not + /// publish a real multi-arch manifest under one tag/digest (some + /// self-hosted projects ship amd64 and arm64 as entirely separate, + /// separately-tagged images instead of one manifest list) -- `image` + /// alone would then only ever resolve to one architecture regardless of + /// where it is deployed. Must be an immutable `@sha256:` digest, same as + /// `image`, when `kind` is `service`. + #[serde(default)] + pub image_arm64: Option, /// Optional command passed to the container image. This is needed for /// production images whose default command is intentionally a development /// mode (for example Keycloak). @@ -776,6 +786,16 @@ impl TemplateService { "Service templates must use an immutable sha256 image digest".to_string(), ); } + if template + .image_arm64 + .as_deref() + .is_some_and(|image| !image.trim().is_empty() && !is_pinned_image_reference(image)) + { + errors.push( + "Service template image_arm64 must use an immutable sha256 image digest" + .to_string(), + ); + } if template .exposed_port .is_none_or(|port| !(1..=65_535).contains(&port)) @@ -1397,6 +1417,7 @@ templates: preset: "nextjs".to_string(), preset_config: None, image: None, + image_arm64: None, command: None, resources: None, exposed_port: None, diff --git a/crates/temps-core/templates/services/cal-diy.yaml b/crates/temps-core/templates/services/cal-diy.yaml new file mode 100644 index 000000000..fdf1a6b58 --- /dev/null +++ b/crates/temps-core/templates/services/cal-diy.yaml @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2024-2026 Temps Contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Cal.diy is Cal.com's MIT-licensed, self-hosted community edition. Upstream +# recommends it for personal/non-production use, not customer-facing booking +# infrastructure -- see https://github.com/calcom/cal.diy. The same +# release-docker.yaml CI build pushes this image to several Docker Hub +# namespaces (calcom/cal.com, calcom/cal.diy, calendso/calendso); only +# calcom/cal.com and calendso/calendso actually have published tags +# (calcom/cal.diy has none as of this writing) -- pinned here to v6.2.0, +# the latest cal.diy git tag at the time this was added. It does not publish +# a real multi-arch manifest under one tag/digest -- amd64 and arm64 are +# separate, separately-versioned tags (v6.2.0 vs v6.2.0-arm) -- hence the +# separate image_arm64 pin below. +slug: cal-diy +name: Cal.diy +version: 6.2.0 +kind: service +description: Self-hosted scheduling and booking pages backed by a Temps-managed PostgreSQL database -- Cal.com's open-source community edition. MIT-licensed, no license key. Upstream recommends personal/non-production use only. +git: + url: https://github.com/calcom/cal.diy.git + ref: v6.2.0 +preset: dockerfile +image: docker.io/calcom/cal.com@sha256:ace3bb1219fb7306585ab9f4d94d41af7ee064c343db0498173436bbe857bd49 +image_arm64: docker.io/calcom/cal.com@sha256:4b0fa72eec13bd3ddb608a6d13f05bf0ebc136e73832abfe1a8ec145db9e4651 +resources: + cpu_request: 500000 + cpu_limit: 1000000 + memory_request: 512 + memory_limit: 1024 +exposed_port: 3000 +health_check_path: / +tags: + - scheduling + - booking + - calendar + - self-hosted +features: + - Booking pages, availability rules, and buffers + - Calendar sync with Google, Outlook, and Apple Calendar + - "Team scheduling: round-robin and collective event types" + - PostgreSQL managed, backed up, and restored by Temps +services: + - postgres +managed_service_bindings: + postgres: + DATABASE_URL: POSTGRES_URL + DATABASE_DIRECT_URL: POSTGRES_URL +env_vars: + - name: NEXT_PUBLIC_WEBAPP_URL + description: Public URL this app is served on. + required: true + default_generator: app_url + - name: NEXTAUTH_URL + description: Must equal NEXT_PUBLIC_WEBAPP_URL. + required: true + default_generator: app_url + - name: NEXTAUTH_SECRET + description: Session-signing secret for NextAuth. + required: true + secret: true + default_generator: random_secret + - name: CALENDSO_ENCRYPTION_KEY + description: Symmetric key (AES-256, 32 bytes) used to encrypt stored calendar credentials. + required: true + secret: true + default_generator: random_secret + - name: EMAIL_FROM + description: From address for booking confirmation and reminder emails. + example: notifications@yourselfhostedcal.example + required: false + - name: EMAIL_SERVER_HOST + description: SMTP server host for outgoing email. + required: false + - name: EMAIL_SERVER_PORT + description: SMTP server port. + example: "587" + required: false +is_public: true +is_featured: true +sort_order: 2 diff --git a/crates/temps-projects/src/handlers/handlers.rs b/crates/temps-projects/src/handlers/handlers.rs index 9e5f1f128..fea895ee6 100644 --- a/crates/temps-projects/src/handlers/handlers.rs +++ b/crates/temps-projects/src/handlers/handlers.rs @@ -2748,6 +2748,7 @@ pub async fn list_project_templates( let response = super::templates::ListTemplatesResponse { templates: templates .into_iter() + .map(resolve_template_for_host_arch) .map(super::templates::TemplateResponse::from) .collect(), total, @@ -2789,7 +2790,9 @@ pub async fn get_project_template( .with_title("Template Not Found") .with_detail(e.to_string()) })?; - Ok(Json(super::templates::TemplateResponse::from(template))) + Ok(Json(super::templates::TemplateResponse::from( + resolve_template_for_host_arch(template), + ))) } /// List all available template tags @@ -2968,6 +2971,48 @@ fn is_pinned_sha256_image_reference(image: &str) -> bool { }) } +/// Best-effort choice between `image` and `image_arm64` for the architecture +/// this process is running on. Project creation happens before deployment +/// scheduling, so the eventual target node's architecture isn't known yet -- +/// this assumes a homogeneous fleet (the console host's own architecture), +/// which matches the documented single/few-node self-hosted reference +/// deployment. A heterogeneous multi-node fleet with mixed architectures is +/// not resolved correctly by this heuristic. +/// Overwrite `image` with the host-architecture-appropriate variant before a +/// template is shown to a client. The create-project form always submits +/// whatever `image` it was shown as an explicit runtime override (even when +/// the user never touched it), so arch selection has to happen here -- by +/// the time a request reaches `resolve_image_template_runtime`, an unedited +/// default already looks identical to a deliberate override. +fn resolve_template_for_host_arch( + mut template: temps_core::templates::ProjectTemplate, +) -> temps_core::templates::ProjectTemplate { + if let Some(default_image) = template.image.as_deref().filter(|image| !image.is_empty()) { + let resolved = + select_template_image_for_arch(&template, default_image, std::env::consts::ARCH) + .to_string(); + template.image = Some(resolved); + } + template +} + +fn select_template_image_for_arch<'a>( + template: &'a temps_core::templates::ProjectTemplate, + default_image: &'a str, + host_arch: &str, +) -> &'a str { + if host_arch == "aarch64" { + if let Some(arm_image) = template + .image_arm64 + .as_deref() + .filter(|image| !image.is_empty()) + { + return arm_image; + } + } + default_image +} + fn resolve_image_template_runtime( template: &temps_core::templates::ProjectTemplate, request: &super::templates::CreateProjectFromTemplateRequest, @@ -2979,7 +3024,13 @@ fn resolve_image_template_runtime( return Ok(None); }; - let image_ref = request.image.as_deref().unwrap_or(template_image).trim(); + let image_ref = request + .image + .as_deref() + .unwrap_or_else(|| { + select_template_image_for_arch(template, template_image, std::env::consts::ARCH) + }) + .trim(); if image_ref.is_empty() { return Err(TemplateRuntimeOverrideError::InvalidImage { reason: "the image reference cannot be empty".to_string(), @@ -3884,8 +3935,9 @@ mod tests { parse_owner_repo_from_git_url, production_environment_variable_names, project_created_from_template_telemetry_event, require_git_settings_permissions, require_template_creation_permissions, resolve_image_template_runtime, - service_template_changes, validate_template_service_selection, DropPresetCandidate, - TemplateEnvironmentError, TemplateRuntimeOverrideError, TemplateServiceSelectionError, + select_template_image_for_arch, service_template_changes, + validate_template_service_selection, DropPresetCandidate, TemplateEnvironmentError, + TemplateRuntimeOverrideError, TemplateServiceSelectionError, }; use axum::http::StatusCode; use chrono::Utc; @@ -4111,6 +4163,46 @@ mod tests { assert_eq!(stored["imageRuntime"]["healthCheckPath"], "/realms/master"); } + #[test] + fn arch_selection_prefers_image_arm64_only_on_arm64_hosts() { + let template = temps_core::templates::bundled_template_by_slug("cal-diy") + .expect("Cal.diy should be bundled"); + let default_image = template.image.as_deref().expect("Cal.diy image"); + let arm_image = template + .image_arm64 + .as_deref() + .expect("Cal.diy image_arm64"); + assert_ne!( + default_image, arm_image, + "fixture should exercise two distinct pinned images" + ); + + assert_eq!( + select_template_image_for_arch(&template, default_image, "aarch64"), + arm_image + ); + assert_eq!( + select_template_image_for_arch(&template, default_image, "x86_64"), + default_image + ); + } + + #[test] + fn arch_selection_falls_back_to_default_image_without_an_arm64_pin() { + let template = temps_core::templates::bundled_template_by_slug("keycloak") + .expect("Keycloak should be bundled"); + assert!( + template.image_arm64.is_none(), + "fixture should exercise a template with no arm64 override (its image is already multi-arch)" + ); + let default_image = template.image.as_deref().expect("Keycloak image"); + + assert_eq!( + select_template_image_for_arch(&template, default_image, "aarch64"), + default_image + ); + } + #[test] fn image_template_runtime_applies_user_overrides_and_can_clear_command() { let template = temps_core::templates::bundled_template_by_slug("keycloak") diff --git a/crates/temps-projects/src/handlers/templates.rs b/crates/temps-projects/src/handlers/templates.rs index d627493f2..bbe4248c5 100644 --- a/crates/temps-projects/src/handlers/templates.rs +++ b/crates/temps-projects/src/handlers/templates.rs @@ -55,6 +55,10 @@ pub struct TemplateResponse { /// Prebuilt Docker image reference. When set, the one-click deploy pulls and /// runs this image directly (no build); when absent it builds from `git`. pub image: Option, + /// arm64 variant of `image`, used instead of it when the deploy host is + /// arm64. Present only when the upstream image does not publish a real + /// multi-arch manifest under `image`'s tag/digest. + pub image_arm64: Option, /// Optional command passed to the image entrypoint. pub command: Option>, /// Curated CPU/memory profile applied when the project is created. @@ -125,6 +129,7 @@ impl From for TemplateResponse { .collect(), is_featured: template.is_featured, image: template.image, + image_arm64: template.image_arm64, command: template.command, resources: template.resources, exposed_port: template.exposed_port, @@ -308,6 +313,7 @@ mod tests { preset_config: None, resources: None, image: None, + image_arm64: None, command: None, exposed_port: None, health_check_path: None,