Skip to content

Commit be5aeca

Browse files
leaftyolevski
andcommitted
feat: add support for building linux/arm64 images from code (#1093)
Details: * Update the session launcher API to support specifying target platforms. This means the API can support multi-platform builds. * At the moment, images can be built with only ONE target platform, so validation only accepts a single target platform. * Image building can be configured to support the `linux/arm64` platform with env variables See chart changes here: SwissDataScienceCenter/renku#4231 --------- Co-authored-by: Tasko Olevski <olevski90@gmail.com>
1 parent 67d2267 commit be5aeca

File tree

13 files changed

+300
-14
lines changed

13 files changed

+300
-14
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""add platforms field to build_parameters
2+
3+
Revision ID: df2c0e65612a
4+
Revises: d437be68a4fb
5+
Create Date: 2025-11-03 08:59:27.001063
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
from renku_data_services.utils.sqlalchemy import ULIDType
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "df2c0e65612a"
16+
down_revision = "d437be68a4fb"
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade() -> None:
22+
op.create_table(
23+
"build_platforms",
24+
sa.Column("id", sa.Integer(), sa.Identity(always=True), nullable=False),
25+
sa.Column("platform", sa.Enum("linux_amd64", "linux_arm64", name="build_platform"), nullable=False),
26+
sa.Column("build_parameters_id", ULIDType(), nullable=False),
27+
sa.ForeignKeyConstraint(
28+
["build_parameters_id"],
29+
["sessions.build_parameters.id"],
30+
name="build_platform_build_parameters_id_fk",
31+
ondelete="CASCADE",
32+
),
33+
sa.PrimaryKeyConstraint("id"),
34+
schema="sessions",
35+
)
36+
37+
38+
def downgrade() -> None:
39+
op.drop_table("build_platforms", schema="sessions")
40+
op.execute("DROP type build_platform")

components/renku_data_services/session/api.spec.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,15 @@ components:
648648
minLength: 1
649649
maxLength: 99
650650
example: My Renku Session :)
651+
BuildPlatforms:
652+
description: The target runtime platforms of a session environment.
653+
type: array
654+
items:
655+
$ref: "#/components/schemas/BuildPlatform"
656+
BuildPlatform:
657+
description: A runtime platform, e.g. "linux/amd64".
658+
type: string
659+
enum: ["linux/amd64", "linux/arm64"]
651660
BuilderVariant:
652661
description: Type of virtual environment manager when building custom environments.
653662
type: string
@@ -728,6 +737,8 @@ components:
728737
properties:
729738
repository:
730739
$ref: "#/components/schemas/Repository"
740+
platforms:
741+
$ref: "#/components/schemas/BuildPlatforms"
731742
builder_variant:
732743
$ref: "#/components/schemas/BuilderVariant"
733744
frontend_variant:
@@ -755,6 +766,8 @@ components:
755766
properties:
756767
repository:
757768
$ref: "#/components/schemas/Repository"
769+
platforms:
770+
$ref: "#/components/schemas/BuildPlatforms"
758771
builder_variant:
759772
$ref: "#/components/schemas/BuilderVariant"
760773
frontend_variant:

components/renku_data_services/session/apispec.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# generated by datamodel-codegen:
22
# filename: api.spec.yaml
3-
# timestamp: 2025-10-23T07:30:34+00:00
3+
# timestamp: 2025-11-13T12:27:39+00:00
44

55
from __future__ import annotations
66

@@ -12,6 +12,11 @@
1212
from renku_data_services.session.apispec_base import BaseAPISpec
1313

1414

15+
class BuildPlatform(Enum):
16+
linux_amd64 = "linux/amd64"
17+
linux_arm64 = "linux/arm64"
18+
19+
1520
class EnvironmentKind(Enum):
1621
GLOBAL = "GLOBAL"
1722
CUSTOM = "CUSTOM"
@@ -363,6 +368,9 @@ class BuildParameters(BaseAPISpec):
363368
extra="forbid",
364369
)
365370
repository: str = Field(..., description="A git repository URL")
371+
platforms: Optional[List[BuildPlatform]] = Field(
372+
None, description="The target runtime platforms of a session environment."
373+
)
366374
builder_variant: str = Field(
367375
...,
368376
description="Type of virtual environment manager when building custom environments.",
@@ -386,6 +394,9 @@ class BuildParametersPost(BuildParameters):
386394

387395
class BuildParametersPatch(BaseAPISpec):
388396
repository: Optional[str] = Field(None, description="A git repository URL")
397+
platforms: Optional[List[BuildPlatform]] = Field(
398+
None, description="The target runtime platforms of a session environment."
399+
)
389400
builder_variant: Optional[str] = Field(
390401
None,
391402
description="Type of virtual environment manager when building custom environments.",

components/renku_data_services/session/config.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,22 @@
88

99
from renku_data_services.app_config import logging
1010
from renku_data_services.session import crs as session_crs
11+
from renku_data_services.session import models
1112

1213
logger = logging.getLogger(__name__)
1314

1415

16+
@dataclass
17+
class BuildPlatformOverrides:
18+
"""Configuration overrides for a given target platform."""
19+
20+
builder_image: str | None = None
21+
run_image: str | None = None
22+
strategy_name: str | None = None
23+
node_selector: dict[str, str] | None = None
24+
tolerations: list[session_crs.Toleration] | None = None
25+
26+
1527
@dataclass
1628
class BuildsConfig:
1729
"""Configuration for container image builds."""
@@ -21,6 +33,7 @@ class BuildsConfig:
2133
build_builder_image: str | None = None
2234
build_run_image: str | None = None
2335
build_strategy_name: str | None = None
36+
build_platform_overrides: dict[str, BuildPlatformOverrides] | None = None
2437
push_secret_name: str | None = None
2538
buildrun_retention_after_failed: timedelta | None = None
2639
buildrun_retention_after_succeeded: timedelta | None = None
@@ -75,12 +88,38 @@ def from_env(cls) -> "BuildsConfig":
7588
except PydanticValidationError:
7689
logger.error("Could not validate BUILD_NODE_TOLERATIONS. Will not use tolerations for image builds.")
7790

91+
build_platform_overrides: dict[str, BuildPlatformOverrides] | None = None
92+
build_platform_overrides_str = os.environ.get("BUILD_PLATFORM_OVERRIDES")
93+
if build_platform_overrides_str:
94+
try:
95+
parsed = session_crs.BuildPlatformOverridesDict.model_validate_json(build_platform_overrides_str).root
96+
if parsed:
97+
for platform, data in parsed.items():
98+
if platform not in models.Platform:
99+
logger.error(f"Ignoring unknown platform {platform}.")
100+
continue
101+
if build_platform_overrides is None:
102+
build_platform_overrides = dict()
103+
build_platform_overrides[platform] = BuildPlatformOverrides(
104+
builder_image=data.builderImage,
105+
run_image=data.runImage,
106+
strategy_name=data.strategyName,
107+
node_selector=data.nodeSelector,
108+
tolerations=data.tolerations,
109+
)
110+
except PydanticValidationError:
111+
logger.error(
112+
"Could not validate BUILD_PLATFORM_OVERRIDES. "
113+
"Will not use platform-specific overrides for image builds."
114+
)
115+
78116
return cls(
79117
enabled=enabled or False,
80118
build_output_image_prefix=build_output_image_prefix or None,
81119
build_builder_image=build_builder_image,
82120
build_run_image=build_run_image,
83121
build_strategy_name=build_strategy_name or None,
122+
build_platform_overrides=build_platform_overrides,
84123
push_secret_name=push_secret_name or None,
85124
buildrun_retention_after_failed=buildrun_retention_after_failed,
86125
buildrun_retention_after_succeeded=buildrun_retention_after_succeeded,

components/renku_data_services/session/core.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,11 @@ def validate_unsaved_build_parameters(
6464
)
6565
)
6666

67+
platforms = __validate_build_parameters_platforms(environment.platforms)
68+
6769
return models.UnsavedBuildParameters(
6870
repository=environment.repository,
71+
platforms=platforms,
6972
builder_variant=environment.builder_variant,
7073
frontend_variant=environment.frontend_variant,
7174
repository_revision=environment.repository_revision if environment.repository_revision else None,
@@ -90,8 +93,13 @@ def validate_build_parameters_patch(environment: apispec.BuildParametersPatch) -
9093
)
9194
)
9295

96+
platforms: list[models.Platform] | None = None
97+
if environment.platforms is not None:
98+
platforms = __validate_build_parameters_platforms(environment.platforms)
99+
93100
return models.BuildParametersPatch(
94101
repository=environment.repository,
102+
platforms=platforms,
95103
builder_variant=environment.builder_variant,
96104
frontend_variant=environment.frontend_variant,
97105
repository_revision=environment.repository_revision,
@@ -344,3 +352,25 @@ def validate_build_patch(patch: apispec.BuildPatch) -> models.BuildPatch:
344352
"""Validate the update to a session launcher."""
345353
status = models.BuildStatus(patch.status.value) if patch.status else None
346354
return models.BuildPatch(status=status)
355+
356+
357+
def __validate_build_parameters_platforms(platforms: list[apispec.BuildPlatform] | None) -> list[models.Platform]:
358+
"""Validate the platforms field for build parameters."""
359+
platforms_str_list: list[str] = [models.Platform.linux_amd64]
360+
if platforms:
361+
platforms_str_list = [item.value for item in platforms]
362+
platforms_str_list = sorted(set(platforms_str_list))
363+
if len(platforms_str_list) != 1:
364+
raise errors.ValidationError(
365+
message=f"Invalid value for the field 'platforms': {platforms}: "
366+
"only one platform at a time is supported at the moment."
367+
)
368+
for platform in platforms_str_list:
369+
if platform not in models.Platform:
370+
raise errors.ValidationError(
371+
message=(
372+
f"Invalid value for the field 'platforms': {platforms}: "
373+
f"Valid values are {[e.value for e in models.Platform]}"
374+
)
375+
)
376+
return [models.Platform(item) for item in platforms_str_list]

components/renku_data_services/session/crs.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,25 @@ class Tolerations(RootModel[list[Toleration] | None]):
6363
"""A list of k8s tolerations."""
6464

6565
root: list[Toleration] | None = None
66+
67+
68+
class BuildPlatformOverrides(BaseModel):
69+
"""Configuration overrides for a given target platform.
70+
71+
Used to validate the builds configuration.
72+
"""
73+
74+
builderImage: str | None = None
75+
runImage: str | None = None
76+
strategyName: str | None = None
77+
nodeSelector: dict[str, str] | None = None
78+
tolerations: list[Toleration] | None = None
79+
80+
81+
class BuildPlatformOverridesDict(RootModel[dict[str, BuildPlatformOverrides] | None]):
82+
"""A map of target platforms to configuration overrides.
83+
84+
Used to validate the builds configuration.
85+
"""
86+
87+
root: dict[str, BuildPlatformOverrides] | None = None

components/renku_data_services/session/db.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,11 @@ def __copy_environment(
138138
if environment.environment_image_source == models.EnvironmentImageSource.build:
139139
if not environment.build_parameters:
140140
raise errors.ProgrammingError(message="Environment has no build parameters.")
141+
platforms = [
142+
schemas.BuildPlatformORM(platform=platform) for platform in environment.build_parameters.platforms
143+
]
141144
new_build_parameters = schemas.BuildParametersORM(
145+
platforms=platforms,
142146
builder_variant=environment.build_parameters.builder_variant,
143147
frontend_variant=environment.build_parameters.frontend_variant,
144148
repository=environment.build_parameters.repository,
@@ -164,7 +168,11 @@ def __insert_build_parameters_environment(
164168
raise errors.UnauthorizedError(
165169
message="You have to be authenticated to insert an environment in the DB.", quiet=True
166170
)
171+
platforms = [
172+
schemas.BuildPlatformORM(platform=platform) for platform in new_build_parameters_environment.platforms
173+
]
167174
build_parameters_orm = schemas.BuildParametersORM(
175+
platforms=platforms,
168176
builder_variant=new_build_parameters_environment.builder_variant,
169177
frontend_variant=new_build_parameters_environment.frontend_variant,
170178
repository=new_build_parameters_environment.repository,
@@ -265,6 +273,10 @@ async def __update_environment_build_parameters(
265273

266274
if build_parameters.repository is not None:
267275
environment.build_parameters.repository = build_parameters.repository
276+
if build_parameters.platforms is not None:
277+
environment.build_parameters.platforms = [
278+
schemas.BuildPlatformORM(platform=platform) for platform in build_parameters.platforms
279+
]
268280
if build_parameters.builder_variant is not None:
269281
environment.build_parameters.builder_variant = build_parameters.builder_variant
270282
if build_parameters.frontend_variant is not None:
@@ -418,7 +430,9 @@ async def insert_launcher(
418430
)
419431
session.add(environment_orm)
420432
elif isinstance(launcher.environment, models.UnsavedBuildParameters):
433+
platforms = [schemas.BuildPlatformORM(platform=platform) for platform in launcher.environment.platforms]
421434
build_parameters_orm = schemas.BuildParametersORM(
435+
platforms=platforms,
422436
builder_variant=launcher.environment.builder_variant,
423437
frontend_variant=launcher.environment.frontend_variant,
424438
repository=launcher.environment.repository,
@@ -718,7 +732,11 @@ async def __update_launcher_environment(
718732
await session.flush()
719733
case models.UnsavedBuildParameters() as new_custom_built_environment, models.EnvironmentKind.CUSTOM:
720734
# Custom environment with image is replaced by a custom environment with build
735+
platforms = [
736+
schemas.BuildPlatformORM(platform=platform) for platform in new_custom_built_environment.platforms
737+
]
721738
build_parameters_orm = schemas.BuildParametersORM(
739+
platforms=platforms,
722740
builder_variant=new_custom_built_environment.builder_variant,
723741
frontend_variant=new_custom_built_environment.frontend_variant,
724742
repository=new_custom_built_environment.repository,
@@ -884,6 +902,15 @@ async def start_build(self, user: base_models.APIUser, build: models.UnsavedBuil
884902
message=f"Session environment with id '{build.environment_id}' already has a build in progress."
885903
)
886904

905+
# We check that we build for a single target platform
906+
if len(build_parameters.platforms) > 1:
907+
raise errors.CannotStartBuildError(
908+
message=(
909+
f"Building images can target only one platform at a time. "
910+
f"Current value: {build_parameters.platforms}"
911+
)
912+
)
913+
887914
build_orm = schemas.BuildORM(
888915
environment_id=build.environment_id,
889916
status=models.BuildStatus.in_progress,
@@ -1052,6 +1079,9 @@ def _get_buildrun_params(
10521079
git_repository_revision = build_parameters.repository_revision
10531080
context_dir = build_parameters.context_dir
10541081

1082+
builder_image = self.builds_config.build_builder_image or constants.BUILD_DEFAULT_BUILDER_IMAGE
1083+
run_image = self.builds_config.build_run_image or constants.BUILD_DEFAULT_RUN_IMAGE
1084+
10551085
output_image_prefix = (
10561086
self.builds_config.build_output_image_prefix or constants.BUILD_DEFAULT_OUTPUT_IMAGE_PREFIX
10571087
)
@@ -1063,6 +1093,9 @@ def _get_buildrun_params(
10631093
build_strategy_name = self.builds_config.build_strategy_name or constants.BUILD_DEFAULT_BUILD_STRATEGY_NAME
10641094
push_secret_name = self.builds_config.push_secret_name or constants.BUILD_DEFAULT_PUSH_SECRET_NAME
10651095

1096+
node_selector = self.builds_config.node_selector
1097+
tolerations = self.builds_config.tolerations
1098+
10661099
retention_after_failed = (
10671100
self.builds_config.buildrun_retention_after_failed or constants.BUILD_RUN_DEFAULT_RETENTION_AFTER_FAILED
10681101
)
@@ -1083,26 +1116,36 @@ def _get_buildrun_params(
10831116
annotations["renku.io/launcher_id"] = str(launcher.id)
10841117
annotations["renku.io/project_id"] = str(launcher.project_id)
10851118

1086-
return models.ShipwrightBuildRunParams(
1119+
params = models.ShipwrightBuildRunParams(
10871120
name=build.k8s_name,
10881121
git_repository=git_repository,
1089-
build_image=self.builds_config.build_builder_image or constants.BUILD_DEFAULT_BUILDER_IMAGE,
1090-
run_image=self.builds_config.build_run_image or constants.BUILD_DEFAULT_RUN_IMAGE,
1122+
builder_image=builder_image,
1123+
run_image=run_image,
10911124
output_image=output_image,
10921125
build_strategy_name=build_strategy_name,
10931126
push_secret_name=push_secret_name,
10941127
retention_after_failed=retention_after_failed,
10951128
retention_after_succeeded=retention_after_succeeded,
10961129
build_timeout=build_timeout,
1097-
node_selector=self.builds_config.node_selector,
1098-
tolerations=self.builds_config.tolerations,
1130+
node_selector=node_selector,
1131+
tolerations=tolerations,
10991132
labels=labels,
11001133
annotations=annotations,
11011134
frontend=build_parameters.frontend_variant,
11021135
git_repository_revision=git_repository_revision,
11031136
context_dir=context_dir,
11041137
)
11051138

1139+
platform = models.Platform.linux_amd64
1140+
if build_parameters.platforms:
1141+
platform = build_parameters.platforms[0]
1142+
overrides = None
1143+
if self.builds_config.build_platform_overrides:
1144+
overrides = self.builds_config.build_platform_overrides.get(platform.value)
1145+
params = params.with_overrides(overrides=overrides)
1146+
1147+
return params
1148+
11061149
async def _get_environment_authorization(
11071150
self, session: AsyncSession, user: base_models.APIUser, environment: schemas.EnvironmentORM, scope: Scope
11081151
) -> bool:

0 commit comments

Comments
 (0)