Skip to content

Commit 579272b

Browse files
committed
feat(backend): persist multi-container artifacts and route Dockerfile edits by context
1 parent f746c9c commit 579272b

5 files changed

Lines changed: 129 additions & 3 deletions

File tree

‎src/backend/api/jobs.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,10 @@ def _apply_artifact_edits(state: dict, edits: list[ArtifactEditRequest]) -> None
408408
generated = infracost.setdefault("generated_terraform", {})
409409
generated_files = generated["files"] if isinstance(generated.get("files"), dict) else generated
410410

411+
plural_images = artifacts.get("docker_images")
412+
if not isinstance(plural_images, list):
413+
plural_images = None
414+
411415
for edit in edits:
412416
file_path = edit.file_path
413417
content = edit.content
@@ -417,6 +421,11 @@ def _apply_artifact_edits(state: dict, edits: list[ArtifactEditRequest]) -> None
417421
generated_files[file_path.replace(".", "_")] = content
418422
elif file_path == "Dockerfile":
419423
artifacts["dockerfile"] = content
424+
if plural_images is not None:
425+
_apply_dockerfile_edit(plural_images, ".", content)
426+
elif file_path.endswith("/Dockerfile") and plural_images is not None:
427+
context = file_path[:-len("/Dockerfile")]
428+
_apply_dockerfile_edit(plural_images, context, content)
420429
elif file_path == "docker-image.json":
421430
import json as _json
422431

@@ -432,6 +441,28 @@ def _apply_artifact_edits(state: dict, edits: list[ArtifactEditRequest]) -> None
432441
dop_tf[edit.file_path] = edit.content
433442
elif edit.file_path == "Dockerfile":
434443
dop_artifacts["dockerfile"] = edit.content
444+
dop_images = dop_artifacts.get("docker_images")
445+
if isinstance(dop_images, list):
446+
_apply_dockerfile_edit(dop_images, ".", edit.content)
447+
elif edit.file_path.endswith("/Dockerfile"):
448+
dop_images = dop_artifacts.get("docker_images")
449+
if isinstance(dop_images, list):
450+
_apply_dockerfile_edit(
451+
dop_images, edit.file_path[:-len("/Dockerfile")], edit.content
452+
)
453+
454+
455+
def _apply_dockerfile_edit(images: list[dict], context: str, content: str) -> None:
456+
"""Route a Dockerfile edit to the plural image whose build context
457+
matches. Root-level Dockerfile edits target the image with context "."."""
458+
for image in images:
459+
img_context = (image.get("context") or ".").rstrip("/")
460+
if img_context == context:
461+
image["dockerfile"] = content
462+
return
463+
# No exact context match: apply to the primary (first) image.
464+
if images:
465+
images[0]["dockerfile"] = content
435466

436467

437468
class RollbackRequest(BaseModel):

‎src/backend/artifact_validation.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,14 @@
3333

3434

3535
def allowed_file_path(file_path: str) -> bool:
36-
return file_path in _ALLOWED_FILE_PATHS
36+
if file_path in _ALLOWED_FILE_PATHS:
37+
return True
38+
# Multi-container: Dockerfiles live under their build context
39+
# (e.g. "backend/Dockerfile"), not just at the repo root.
40+
if file_path.endswith("/Dockerfile"):
41+
parent = file_path[:-len("/Dockerfile")]
42+
return bool(parent) and not parent.startswith("/") and ".." not in parent.split("/")
43+
return False
3744

3845

3946
def _balanced_structure(content: str) -> bool:

‎src/backend/persistence.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,31 @@ def _docker_artifacts(state: dict) -> list[tuple[str, str, str | None]]:
6565
agent stashes them at ``infracost_result._deploy_inputs.artifacts``, the
6666
mock/legacy shape at ``deployops_result.artifacts``. Returns
6767
``(file_path, artifact_type, content)`` tuples, content None when absent
68-
(e.g. a serverless Lambda run)."""
68+
(e.g. a serverless Lambda run).
69+
70+
Multi-container payloads carry the canonical plural ``docker_images``
71+
list; each image becomes its own artifact row keyed by its build context
72+
(``<context>/Dockerfile`` or ``Dockerfile`` for the repo root). Legacy
73+
payloads keep the singular ``dockerfile`` + ``docker_image`` shape.
74+
"""
6975
artifacts = {}
7076
infracost = state.get("infracost_result") or {}
7177
deploy_inputs = (infracost.get("_deploy_inputs") or {}).get("artifacts") or {}
7278
deployops = (state.get("deployops_result") or {}).get("artifacts") or {}
7379
for source in (deploy_inputs, deployops):
74-
for key in ("dockerfile", "docker_image"):
80+
for key in ("dockerfile", "docker_image", "docker_images"):
7581
if key in source:
7682
artifacts[key] = source[key]
7783

84+
rows: list[tuple[str, str, str | None]] = []
85+
plural = artifacts.get("docker_images")
86+
if plural:
87+
for image in plural:
88+
context = (image.get("context") or ".").rstrip("/")
89+
rel = "Dockerfile" if context == "." else f"{context}/Dockerfile"
90+
rows.append((rel, "dockerfile", image.get("dockerfile")))
91+
return rows
92+
7893
dockerfile = artifacts.get("dockerfile")
7994
docker_image = artifacts.get("docker_image") or {}
8095
image = {}

‎src/backend/tests/test_jobs.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,30 @@ def fake_publish(job_id, phase, progress, message=""):
287287
phases = {phase for phase, _ in published}
288288
assert "codesec_agent" in phases
289289
assert "human_gate_1" in phases
290+
291+
292+
def test_multi_container_dockerfile_edits_route_by_context():
293+
from src.backend.api.jobs import ArtifactEditRequest, _apply_artifact_edits
294+
from src.backend.artifact_validation import allowed_file_path
295+
296+
assert allowed_file_path("backend/Dockerfile") is True
297+
assert allowed_file_path("frontend/Dockerfile") is True
298+
assert allowed_file_path("Dockerfile") is True
299+
assert allowed_file_path("../evil/Dockerfile") is False
300+
assert allowed_file_path("nested/path/to/Dockerfile") is True
301+
302+
state = {
303+
"infracost_result": {"_deploy_inputs": {"artifacts": {
304+
"docker_images": [
305+
{"name": "devguard-app", "dockerfile": "OLD1", "context": "."},
306+
{"name": "devguard-app-frontend", "dockerfile": "OLD2", "context": "frontend"},
307+
]
308+
}}}
309+
}
310+
_apply_artifact_edits(state, [
311+
ArtifactEditRequest(file_path="Dockerfile", content="NEW1"),
312+
ArtifactEditRequest(file_path="frontend/Dockerfile", content="NEW2"),
313+
])
314+
images = state["infracost_result"]["_deploy_inputs"]["artifacts"]["docker_images"]
315+
assert images[0]["dockerfile"] == "NEW1"
316+
assert images[1]["dockerfile"] == "NEW2"

‎src/backend/tests/test_persistence.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,52 @@ def test_persist_dockerfile_from_real_deploy_inputs(db: Session) -> None:
7272
assert "sha-a1b2c3d" in by_path["docker-image.json"].content
7373

7474

75+
def test_persist_multi_container_docker_images(db: Session) -> None:
76+
"""Multi-container runs carry the canonical plural docker_images list;
77+
each image must be persisted under its build-context path."""
78+
state = _make_state(
79+
{
80+
"files": {
81+
"main.tf": "resource \"aws_ecs_cluster\" \"this\" {}",
82+
"variables.tf": "variable \"region\" {}",
83+
"outputs.tf": "output \"url\" {}",
84+
},
85+
"variables": {"region": "us-east-1"},
86+
}
87+
)
88+
state["infracost_result"]["_deploy_inputs"] = {
89+
"compute_type": "ecs",
90+
"artifacts": {
91+
"terraform": {
92+
"files": {"main.tf": "x"},
93+
"variables": {"region": "us-east-1"},
94+
},
95+
"docker_images": [
96+
{
97+
"name": "devguard-app",
98+
"tag": "sha-a1b2c3d",
99+
"dockerfile": "FROM python:3.12-slim\nEXPOSE 8000\n",
100+
"context": ".",
101+
},
102+
{
103+
"name": "devguard-app-frontend",
104+
"tag": "sha-a1b2c3d",
105+
"dockerfile": "FROM nginx:1.27\nEXPOSE 80\n",
106+
"context": "frontend",
107+
},
108+
],
109+
"source_code": ".",
110+
},
111+
}
112+
persist_results(db, "test-run-docker-multi", state)
113+
114+
artifacts = db.query(models.TerraformArtifact).filter_by(run_id="test-run-docker-multi").all()
115+
by_path = {a.file_path: a for a in artifacts}
116+
assert by_path["Dockerfile"].content == "FROM python:3.12-slim\nEXPOSE 8000\n"
117+
assert by_path["frontend/Dockerfile"].content == "FROM nginx:1.27\nEXPOSE 80\n"
118+
assert "docker-image.json" not in by_path
119+
120+
75121
def test_persist_dockerfile_from_mock_deployops_artifacts(db: Session) -> None:
76122
"""Mock/legacy runs carry Docker artifacts on deployops_result.artifacts;
77123
those must be persisted too."""

0 commit comments

Comments
 (0)