From 381569d91ff112e9b1fcf794c397e3ef648385ca Mon Sep 17 00:00:00 2001 From: RonaldHensbergen Date: Wed, 26 Aug 2026 10:27:18 +0200 Subject: [PATCH] feat(renderer): add image.source build|registry mechanism (#532) Adds config.image.source (build|registry, default build) and config.image.tag to modules/orchestration/dagster/module.yaml, and a new renderer step (_apply_image_source) that, when source is "registry" and a tag is configured, drops a service's build: block and rewrites image: to docker.io/ronaldsoeverein/cds-dagster:. Services without a build: block, or with source: registry but no tag configured, are left unchanged so an untagged registry reference is never silently emitted. Resolves #532 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/renderer.py | 37 +++++++++- modules/orchestration/dagster/module.yaml | 21 ++++++ tests/test_renderer.py | 86 +++++++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/cli/renderer.py b/cli/renderer.py index 4e1e17a7..4122d54f 100644 --- a/cli/renderer.py +++ b/cli/renderer.py @@ -244,7 +244,8 @@ def _render_services( project_root=project_root, compose_dir=compose_dir, ) - + service_copy = _apply_image_source(service_copy, module) + # Attach to the network if network_name is provided if network_name: service_copy["networks"] = [network_name] @@ -661,6 +662,40 @@ def _rewrite_depends_on( return {**service_def, "depends_on": rewritten} +def _apply_image_source( + service_def: dict[str, Any], + module: dict[str, Any], +) -> dict[str, Any]: + """ + Swap a rendered service's build: block for a registry image: reference + when the module's config.image.source is "registry". + + Services that have no "build" block are left untouched (this is a no-op + for services that never build a local image). When config.image.source + is "registry", the "build" key is dropped and "image" is rewritten to + "docker.io/ronaldsoeverein/cds-:". Emitting + a registry reference without a tag would silently produce an untagged + image, so the service is left unchanged (with the build: block intact) + if no tag is configured; validator enforcement of a required tag is + tracked separately (issue #533). + """ + if "build" not in service_def: + return service_def + + image_config = module.get("config", {}).get("image", {}) + if not isinstance(image_config, dict) or image_config.get("source") != "registry": + return service_def + + tag = image_config.get("tag") + if not tag: + return service_def + + service_copy = dict(service_def) + service_copy.pop("build", None) + service_copy["image"] = f"docker.io/ronaldsoeverein/cds-{module.get('id')}:{tag}" + return service_copy + + def _rewrite_build_context( service_def: dict[str, Any], module: dict[str, Any], diff --git a/modules/orchestration/dagster/module.yaml b/modules/orchestration/dagster/module.yaml index 12abba61..b04a3135 100644 --- a/modules/orchestration/dagster/module.yaml +++ b/modules/orchestration/dagster/module.yaml @@ -70,6 +70,27 @@ spec: optimized for compatibility. "hardened" builds the Alpine-based image optimized for a minimal attack surface and fewer vulnerabilities. + source: + type: string + enum: + - build + - registry + default: build + description: > + Selects how the user-code, webserver, and daemon services + obtain their image. "build" (default) builds the image locally + from images/dagster/${config.image.variant}/Dockerfile. + "registry" pulls the already published, scanned, and signed + image from docker.io/ronaldsoeverein/cds-dagster instead of + building it, avoiding a local rebuild. Requires image.tag to + be set. + tag: + type: string + description: > + Registry tag to pull when image.source is "registry", e.g. a + version published by .github/workflows/publish-images.yml + (optionally prefixed with the variant, such as + "hardened-1.8.0"). Ignored when image.source is "build". storage: type: object diff --git a/tests/test_renderer.py b/tests/test_renderer.py index d25f4f3d..e0f4fbeb 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -852,5 +852,91 @@ def test_render_compose_reports_diagnostic_for_deeply_nested_service_definition( self.assertEqual(len(errors), 1) self.assertNotIn("web-app", output) + +class ImageSourceRenderingTest(unittest.TestCase): + def _plan(self, image_config): + return { + "metadata": {"name": "cds-test"}, + "modules": [ + { + "id": "dagster", + "config": {"image": image_config}, + "implementation": { + "kind": "docker-compose", + "compose": { + "services": { + "user-code": { + "build": { + "context": ".", + "dockerfile": "images/dagster/${config.image.variant}/Dockerfile", + }, + "image": "local/dagster:custom", + }, + "webserver": { + "image": "local/dagster:custom", + }, + } + }, + }, + } + ], + } + + def test_default_source_build_leaves_build_block_intact(self): + plan = self._plan({"variant": "base", "source": "build"}) + + output, diagnostics = render_compose(plan) + + self.assertEqual(len([d for d in diagnostics if d.level == "error"]), 0) + compose = yaml.safe_load(output) + service = compose["services"]["dagster-user-code"] + self.assertIn("build", service) + self.assertEqual(service["image"], "local/dagster:custom") + + def test_source_registry_with_tag_drops_build_and_rewrites_image(self): + plan = self._plan({"variant": "base", "source": "registry", "tag": "1.8.0"}) + + output, diagnostics = render_compose(plan) + + self.assertEqual(len([d for d in diagnostics if d.level == "error"]), 0) + compose = yaml.safe_load(output) + service = compose["services"]["dagster-user-code"] + self.assertNotIn("build", service) + self.assertEqual(service["image"], "docker.io/ronaldsoeverein/cds-dagster:1.8.0") + + def test_source_registry_without_tag_falls_back_to_build(self): + plan = self._plan({"variant": "base", "source": "registry"}) + + output, diagnostics = render_compose(plan) + + self.assertEqual(len([d for d in diagnostics if d.level == "error"]), 0) + compose = yaml.safe_load(output) + service = compose["services"]["dagster-user-code"] + self.assertIn("build", service) + self.assertEqual(service["image"], "local/dagster:custom") + + def test_source_registry_leaves_services_without_build_untouched(self): + plan = self._plan({"variant": "base", "source": "registry", "tag": "1.8.0"}) + + output, diagnostics = render_compose(plan) + + self.assertEqual(len([d for d in diagnostics if d.level == "error"]), 0) + compose = yaml.safe_load(output) + service = compose["services"]["dagster-webserver"] + self.assertNotIn("build", service) + self.assertEqual(service["image"], "local/dagster:custom") + + def test_source_registry_with_variant_prefixed_tag(self): + plan = self._plan({"variant": "hardened", "source": "registry", "tag": "hardened-1.8.0"}) + + output, diagnostics = render_compose(plan) + + self.assertEqual(len([d for d in diagnostics if d.level == "error"]), 0) + compose = yaml.safe_load(output) + service = compose["services"]["dagster-user-code"] + self.assertNotIn("build", service) + self.assertEqual(service["image"], "docker.io/ronaldsoeverein/cds-dagster:hardened-1.8.0") + + if __name__ == "__main__": unittest.main()