From c806a9bb54eb39fe62b7a88c69e22ba27dacd3ea Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Mon, 15 Jun 2026 11:02:04 -0400 Subject: [PATCH] fix: render per-instance container image from the verified manifest (${PIC_IMAGE}) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connectivity compose-templates hardcoded an unpinned image:tag (proxy even referenced the renamed-away svc-redsocks), so the per-instance container that actually ran pulled an unverified :latest — bypassing the cosign/digest verification the store performs at install. Add a ${PIC_IMAGE} render variable that resolves to the manifest's digest-pinned, verified image; the matching pic-services templates switch to image: ${PIC_IMAGE} so the container that runs is exactly the ref the store verified. Verified on pic1: rendering the proxy template yields the pinned svc-proxy@sha256 image. Co-Authored-By: Claude Fable 5 --- api/service_composer.py | 5 +++++ tests/test_service_composer.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/api/service_composer.py b/api/service_composer.py index a85160d..dff548b 100644 --- a/api/service_composer.py +++ b/api/service_composer.py @@ -157,6 +157,11 @@ class ServiceComposer: result = result.replace('${PIC_CELL_NAME}', cell_name) result = result.replace('${PIC_SERVICE_ID}', service_id) result = result.replace('${PIC_DATA_DIR}', str(Path(self.data_dir).resolve())) + # ${PIC_IMAGE} resolves to the manifest's image — the digest-pinned, + # cosign-verified reference. Templates (especially instanceable ones) + # MUST use this rather than hardcoding an image:tag, so the container + # that actually runs is the same image the store verified at install. + result = result.replace('${PIC_IMAGE}', str(manifest.get('image', ''))) if instance_vars: for var in ('INSTANCE_ID', 'REDIRECT_PORT'): diff --git a/tests/test_service_composer.py b/tests/test_service_composer.py index 77b92dd..d869c2e 100644 --- a/tests/test_service_composer.py +++ b/tests/test_service_composer.py @@ -88,6 +88,23 @@ class TestRenderTemplate(unittest.TestCase): result = self.composer.render_template('myservice', manifest, template) self.assertEqual(result, 'ID=myservice') + def test_pic_image_substituted_from_manifest(self): + # ${PIC_IMAGE} must resolve to the manifest's digest-pinned image so the + # per-instance container runs the exact ref the store verified — not an + # unpinned :latest hardcoded in the template. + digest = 'git.pic.ngo/roof/svc-proxy:latest@sha256:' + 'a' * 64 + manifest = _make_manifest() + manifest['image'] = digest + template = 'image: ${PIC_IMAGE}' + result = self.composer.render_template('myservice', manifest, template) + self.assertEqual(result, f'image: {digest}') + + def test_pic_image_empty_when_manifest_has_no_image(self): + manifest = _make_manifest() + result = self.composer.render_template( + 'myservice', manifest, 'image: ${PIC_IMAGE}') + self.assertEqual(result, 'image: ') + def test_pic_secret_generated_and_substituted(self): with tempfile.TemporaryDirectory() as tmpdir: composer = _composer(data_dir=tmpdir)