From e971e04d75566542328be275935cfd2d02ccae5d Mon Sep 17 00:00:00 2001 From: Nguyen Minh Phuc Date: Tue, 21 Jul 2026 02:40:37 +0000 Subject: [PATCH] fix: shared runtime helper must live where every image packages it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit services/_runtime.py crashed the worker and reconciler on boot with `ModuleNotFoundError: No module named 'services._runtime'`, while every unit and integration gate was green and the API rolled out fine. The cause is packaging, not code. Each service Dockerfile copies only its own `services//` subdir — `services/` itself is a namespace package with no __init__.py, so a file added at the `services/` root is never copied into any image. Tests import from the source tree, where the file exists, so nothing below the image boundary could catch it. The API survived only because it does not import the helper. Moved to svcforge_core.runtime, which `COPY libs/ libs/` packages into every image, next to adapters/tempyaml.py for the same reason. Added an import smoke test to the image job: `docker run --entrypoint python -c "import services..main"` loads the whole transitive graph inside the built image and fails the build before the digest is pushed. This is the one check the test suite structurally cannot perform — it runs against source, the image is a different filesystem — and it is exactly the gap this bug fell through. --- .gitea/workflows/ci.yaml | 15 +++++++++++++++ .../svcforge_core/svcforge_core/runtime.py | 0 services/reconciler/main.py | 2 +- services/worker/main.py | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) rename services/_runtime.py => libs/svcforge_core/svcforge_core/runtime.py (100%) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 4fb965b..a25235b 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -373,6 +373,21 @@ jobs: -t svcforge/${{ matrix.svc }}:ci \ . + - name: import smoke test + # Import the service's entrypoint module INSIDE the built image, which every unit and + # integration test that passes cannot do: they import from the source tree, where + # every file exists. The image is a different filesystem — each Dockerfile copies only + # its own `services//`, so a shared module added at the `services/` root, or any + # dependency the Dockerfile forgets, is present in the tests and absent in the image. + # + # That gap shipped a reconciler that crashed on boot with + # `ModuleNotFoundError: No module named 'services._runtime'` while every gate was + # green. Importing main here loads the whole transitive graph and fails the build + # before the digest is pushed, instead of after ArgoCD has rolled it out. + run: | + docker run --rm --entrypoint python svcforge/${{ matrix.svc }}:ci \ + -c "import services.${{ matrix.svc }}.main" + - name: trivy # Run trivy directly rather than via aquasecurity/trivy-action, for the same reason # gitleaks is run directly above: the command is the documented one, pinned by diff --git a/services/_runtime.py b/libs/svcforge_core/svcforge_core/runtime.py similarity index 100% rename from services/_runtime.py rename to libs/svcforge_core/svcforge_core/runtime.py diff --git a/services/reconciler/main.py b/services/reconciler/main.py index b60b28f..8a94620 100644 --- a/services/reconciler/main.py +++ b/services/reconciler/main.py @@ -34,7 +34,6 @@ from dataclasses import dataclass import typer -from services._runtime import install_stop_signals, sleep_or_stop from svcforge_core.adapters.clock import Clock, SystemClock from svcforge_core.adapters.helm import HelmProvisioner, Provisioner from svcforge_core.adapters.notify import LogNotifier, Notifier @@ -54,6 +53,7 @@ from svcforge_core.repo.db import DictPool, make_pool from svcforge_core.repo.instances import InstanceRepo from svcforge_core.repo.reconcile import ReconcileRepo from svcforge_core.repo.tasks import TaskRepo +from svcforge_core.runtime import install_stop_signals, sleep_or_stop from svcforge_core.settings import Settings, load_settings log = get_logger("svcforge.reconciler") diff --git a/services/worker/main.py b/services/worker/main.py index f855ab4..1733627 100644 --- a/services/worker/main.py +++ b/services/worker/main.py @@ -15,7 +15,6 @@ from collections.abc import Awaitable from opentelemetry import trace -from services._runtime import install_stop_signals, sleep_or_stop from services.worker.deps import WorkerDeps from services.worker.handlers import HANDLERS from svcforge_core import obs @@ -27,6 +26,7 @@ from svcforge_core.domain.models import Task, TaskKind from svcforge_core.repo.db import make_pool from svcforge_core.repo.instances import InstanceRepo from svcforge_core.repo.tasks import TaskRepo +from svcforge_core.runtime import install_stop_signals, sleep_or_stop from svcforge_core.settings import Settings, load_settings log = obs.get_logger("svcforge.worker")