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
21 changes: 21 additions & 0 deletions crates/temps-core/src/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,16 @@ pub struct ProjectTemplate {
/// absent, the template builds from source.
#[serde(default)]
pub image: Option<String>,
/// 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<String>,
/// Optional command passed to the container image. This is needed for
/// production images whose default command is intentionally a development
/// mode (for example Keycloak).
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -1397,6 +1417,7 @@ templates:
preset: "nextjs".to_string(),
preset_config: None,
image: None,
image_arm64: None,
command: None,
resources: None,
exposed_port: None,
Expand Down
81 changes: 81 additions & 0 deletions crates/temps-core/templates/services/cal-diy.yaml
Original file line number Diff line number Diff line change
@@ -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
100 changes: 96 additions & 4 deletions crates/temps-projects/src/handlers/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions crates/temps-projects/src/handlers/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
/// Optional command passed to the image entrypoint.
pub command: Option<Vec<String>>,
/// Curated CPU/memory profile applied when the project is created.
Expand Down Expand Up @@ -125,6 +129,7 @@ impl From<ProjectTemplate> 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,
Expand Down Expand Up @@ -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,
Expand Down
Loading