Skip to content

Commit 069d523

Browse files
sgaistleafty
andauthored
feat(notebook): add secret handling for private registry image pull (#1265)
* feat(notebook): add secret handling for private registry image pull This secret is created by the admin deploying the platform and is not user accessible. * chore: update patch_sesssion for new image pull secret implementation * fix: add missing None check for private registry secret handling * chore: simplify secret return handling When going through this if, the secret is created, thus returning early is the right action. Co-authored-by: Flora Thiebaut <flora@leafty.dev> * chore: make created image pull secret adoption explicit While this is the default value, this make the intention clearer with regard to the use of a pre-created secret such as the one used for the private image registry used when building images from a private repository. * feat(builds): validate private/public image prefix to be different This avoids accidentally sending images built from private repositories to the same registry used for public repositories. * feat: add tests for image prefix validation * chore: fix format issues --------- Co-authored-by: Flora Thiebaut <flora@leafty.dev>
1 parent ac935e7 commit 069d523

5 files changed

Lines changed: 64 additions & 2 deletions

File tree

bases/renku_data_services/data_api/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic:
222222
storage_repo=dm.storage_repo,
223223
user_repo=dm.kc_user_repo,
224224
git_repositories_repo=dm.git_repositories_repo,
225+
builds_config=dm.config.builds,
225226
)
226227
platform_config = PlatformConfigBP(
227228
name="platform_config",

components/renku_data_services/notebooks/blueprints.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from renku_data_services.notebooks.image_check import ImageCheckRepository
3333
from renku_data_services.project.db import ProjectRepository, ProjectSessionSecretRepository
3434
from renku_data_services.repositories.db import GitRepositoriesRepository
35+
from renku_data_services.session.config import BuildsConfig
3536
from renku_data_services.session.db import SessionRepository
3637
from renku_data_services.storage.db import StorageRepository
3738
from renku_data_services.users.db import UserRepo
@@ -61,6 +62,7 @@ class NotebooksNewBP(CustomBlueprint):
6162
user_repo: UserRepo
6263
metrics: MetricsService
6364
git_repositories_repo: GitRepositoriesRepository
65+
builds_config: BuildsConfig
6466

6567
def start(self) -> BlueprintFactoryResponse:
6668
"""Start a session with the new operator."""
@@ -92,6 +94,7 @@ async def _handler(
9294
image_check_repo=self.image_check_repo,
9395
data_source_repo=self.data_source_repo,
9496
git_repositories_repo=self.git_repositories_repo,
97+
builds_config=self.builds_config,
9598
)
9699
status = 201 if created else 200
97100
return json(session.as_apispec().model_dump(exclude_none=True, mode="json"), status)
@@ -164,6 +167,7 @@ async def _handler(
164167
metrics=self.metrics,
165168
image_check_repo=self.image_check_repo,
166169
data_source_repo=self.data_source_repo,
170+
builds_config=self.builds_config,
167171
)
168172
return json(new_session.as_apispec().model_dump(exclude_none=True, mode="json"))
169173

components/renku_data_services/notebooks/core_sessions.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
from renku_data_services.project.models import Project, SessionSecret
106106
from renku_data_services.repositories.db import GitRepositoriesRepository
107107
from renku_data_services.repositories.models import Metadata as RepoMetadata
108+
from renku_data_services.session.config import BuildsConfig
108109
from renku_data_services.session.db import SessionRepository
109110
from renku_data_services.session.models import SessionLauncher
110111
from renku_data_services.users.db import UserRepo
@@ -617,7 +618,8 @@ def __format_image_pull_secret(secret_name: str, access_token: str, registry_dom
617618
data={".dockerconfigjson": registry_secret},
618619
metadata=V1ObjectMeta(name=secret_name),
619620
type="kubernetes.io/dockerconfigjson",
620-
)
621+
),
622+
adopt=True,
621623
)
622624

623625

@@ -648,6 +650,7 @@ async def get_image_pull_secret(
648650
user: APIUser,
649651
internal_gitlab_user: APIUser,
650652
image_check_repo: ImageCheckRepository,
653+
builds_config: BuildsConfig,
651654
) -> ExtraSecret | None:
652655
"""Get an image pull secret."""
653656

@@ -657,6 +660,18 @@ async def get_image_pull_secret(
657660
if v2_secret:
658661
return v2_secret
659662

663+
if (
664+
builds_config.enabled
665+
and builds_config.build_output_private_image_prefix is not None
666+
and image.startswith(builds_config.build_output_private_image_prefix)
667+
):
668+
return ExtraSecret(
669+
V1Secret(
670+
metadata=V1ObjectMeta(name=builds_config.pull_private_image_secret_name),
671+
),
672+
adopt=False,
673+
)
674+
660675
if (
661676
nb_config.enable_internal_gitlab
662677
and isinstance(user, AuthenticatedAPIUser)
@@ -740,6 +755,7 @@ async def start_session(
740755
image_check_repo: ImageCheckRepository,
741756
data_source_repo: DataSourceRepository,
742757
git_repositories_repo: GitRepositoriesRepository,
758+
builds_config: BuildsConfig,
743759
) -> tuple[AmaltheaSessionV1Alpha1, bool]:
744760
"""Start an Amalthea session.
745761
@@ -910,6 +926,7 @@ async def start_session(
910926
user=user,
911927
internal_gitlab_user=internal_gitlab_user,
912928
image_check_repo=image_check_repo,
929+
builds_config=builds_config,
913930
)
914931
if image_secret:
915932
session_extras = session_extras.concat(SessionExtraResources(secrets=[image_secret]))
@@ -969,7 +986,9 @@ async def start_session(
969986
metadata=Metadata(name=server_name, annotations=annotations),
970987
spec=AmaltheaSessionSpec(
971988
location=session_location,
972-
imagePullSecrets=[ImagePullSecret(name=image_secret.name, adopt=True)] if image_secret else [],
989+
imagePullSecrets=[ImagePullSecret(name=image_secret.name, adopt=image_secret.adopt)]
990+
if image_secret
991+
else [],
973992
codeRepositories=[],
974993
hibernated=False,
975994
reconcileStrategy=ReconcileStrategy.whenFailedOrHibernated,
@@ -1069,6 +1088,7 @@ async def patch_session(
10691088
image_check_repo: ImageCheckRepository,
10701089
data_source_repo: DataSourceRepository,
10711090
metrics: MetricsService,
1091+
builds_config: BuildsConfig,
10721092
) -> AmaltheaSessionV1Alpha1:
10731093
"""Patch an Amalthea session."""
10741094
session = await nb_config.k8s_v2_client.get_session(session_id, user.id)
@@ -1220,6 +1240,7 @@ async def patch_session(
12201240
image_check_repo=image_check_repo,
12211241
user=user,
12221242
internal_gitlab_user=internal_gitlab_user,
1243+
builds_config=builds_config,
12231244
)
12241245
if image_pull_secret:
12251246
session_extras = session_extras.concat(SessionExtraResources(secrets=[image_pull_secret]))

components/renku_data_services/session/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from datetime import timedelta
66

77
from pydantic import ValidationError as PydanticValidationError
8+
from sanic_ext.exceptions import ValidationError
89

910
from renku_data_services.app_config import logging
1011
from renku_data_services.session import crs as session_crs
@@ -38,6 +39,7 @@ class BuildsConfig:
3839
build_platform_overrides: dict[str, BuildPlatformOverrides] | None = None
3940
push_secret_name: str | None = None
4041
push_private_secret_name: str | None = None
42+
pull_private_image_secret_name: str | None = None
4143
buildrun_retention_after_failed: timedelta | None = None
4244
buildrun_retention_after_succeeded: timedelta | None = None
4345
buildrun_build_timeout: timedelta | None = None
@@ -50,6 +52,14 @@ def from_env(cls) -> "BuildsConfig":
5052
enabled = os.environ.get("IMAGE_BUILDERS_ENABLED", "false").lower() == "true"
5153
build_output_image_prefix = os.environ.get("BUILD_OUTPUT_IMAGE_PREFIX")
5254
build_output_private_image_prefix = os.environ.get("BUILD_OUTPUT_PRIVATE_IMAGE_PREFIX")
55+
56+
if (
57+
build_output_image_prefix is not None
58+
and build_output_private_image_prefix is not None
59+
and build_output_image_prefix == build_output_private_image_prefix
60+
):
61+
raise ValidationError("Public and private builds cannot use the same image prefix")
62+
5363
build_builder_image = os.environ.get("BUILD_BUILDER_IMAGE")
5464
build_run_image = os.environ.get("BUILD_RUN_IMAGE")
5565
build_strategy_name = os.environ.get("BUILD_STRATEGY_NAME")
@@ -60,6 +70,7 @@ def from_env(cls) -> "BuildsConfig":
6070
)
6171
push_secret_name = os.environ.get("BUILD_PUSH_SECRET_NAME")
6272
push_private_secret_name = os.environ.get("BUILD_PUSH_PRIVATE_SECRET_NAME")
73+
pull_private_image_secret_name = os.environ.get("BUILD_PULL_PRIVATE_SECRET_NAME")
6374
buildrun_retention_after_failed_seconds = int(os.environ.get("BUILD_RUN_RETENTION_AFTER_FAILED_SECONDS") or "0")
6475
buildrun_retention_after_failed = (
6576
timedelta(seconds=buildrun_retention_after_failed_seconds)
@@ -134,6 +145,7 @@ def from_env(cls) -> "BuildsConfig":
134145
build_platform_overrides=build_platform_overrides,
135146
push_secret_name=push_secret_name or None,
136147
push_private_secret_name=push_private_secret_name or None,
148+
pull_private_image_secret_name=pull_private_image_secret_name or None,
137149
buildrun_retention_after_failed=buildrun_retention_after_failed,
138150
buildrun_retention_after_succeeded=buildrun_retention_after_succeeded,
139151
buildrun_build_timeout=buildrun_build_timeout,

test/bases/renku_data_services/data_api/test_sessions.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
import pytest
88
from kr8s.objects import new_class
99
from pytest import FixtureRequest
10+
from sanic_ext.exceptions import ValidationError
1011
from sanic_testing.testing import SanicASGITestClient, TestingResponse
1112
from syrupy.filters import props
1213

1314
from renku_data_services import errors
1415
from renku_data_services.data_api.dependencies import DependencyManager
16+
from renku_data_services.session.config import BuildsConfig
1517
from renku_data_services.session.constants import BUILD_RUN_GVK
1618
from renku_data_services.session.models import EnvVar
1719
from renku_data_services.users.models import UserInfo
@@ -55,6 +57,28 @@ def cleanup():
5557
return launch_session_helper
5658

5759

60+
@pytest.mark.parametrize(
61+
"prefix,private_prefix,must_raise",
62+
[
63+
("dummy/image-prefix", "dummy/private-image-prefix", False),
64+
(None, "dummy/private-image-prefix", False),
65+
("dummy/image-prefix", None, False),
66+
("dummy/private-image-prefix", "dummy/private-image-prefix", True),
67+
],
68+
)
69+
def test_same_build_image_prefix(monkeypatch, prefix, private_prefix, must_raise) -> None:
70+
if prefix is not None:
71+
monkeypatch.setenv("BUILD_OUTPUT_IMAGE_PREFIX", prefix)
72+
if private_prefix is not None:
73+
monkeypatch.setenv("BUILD_OUTPUT_PRIVATE_IMAGE_PREFIX", private_prefix)
74+
75+
if must_raise:
76+
with pytest.raises(ValidationError):
77+
_ = BuildsConfig.from_env()
78+
else:
79+
_ = BuildsConfig.from_env()
80+
81+
5882
@pytest.mark.asyncio
5983
async def test_get_all_session_environments(
6084
sanic_client: SanicASGITestClient, unauthorized_headers, create_session_environment, snapshot

0 commit comments

Comments
 (0)