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
37 changes: 36 additions & 1 deletion cli/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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-<module id>:<config.image.tag>". 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],
Expand Down
21 changes: 21 additions & 0 deletions modules/orchestration/dagster/module.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions tests/test_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()