From fc03a5d3ccd46ed42d8206823860558be99d0bda Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Sat, 18 Jul 2026 23:31:23 -0400 Subject: [PATCH] fix: preflight locks against exact base image --- docs/tooling/workspace-cli.md | 7 ++- odoo_devkit/local_runtime.py | 82 +++++++++++++++++++++++++++++++++++ tests/test_runtime.py | 33 ++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index d7ac072..4d728d8 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -327,8 +327,11 @@ Notes tenant/devkit/shared Git commits; stages only tracked regular files; hashes the exact support/runtime and tenant lock bytes; resolves configured addon selectors to exact Git SHAs; resolves both base images to immutable digests - and verifies their OCI source/revision labels; then builds and pushes the - requested artifact tag. + and verifies their OCI source/revision labels; dry-runs the exact exported + support and tenant requirements against each selected base-runtime platform's + installed package constraints; then builds and pushes the requested artifact + tag. Base-package overlap fails before Buildx starts instead of surfacing only + inside the artifact image build. - After the push succeeds, publish reads the immutable artifact index digest from Buildx metadata, extracts each target platform's dependency sidecar from that digest, verifies the sidecars against the staged lock hashes and source diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index e3437be..2c01b7e 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -58,6 +58,43 @@ ARTIFACT_PUBLISH_BUILD_ARG_KEYS = tuple(key for key in ARTIFACT_PUBLISH_RUNTIME_ENV_KEYS if key != "ODOO_PYTHON_SYNC_SKIP_ADDONS") DEPENDENCY_SOURCE_MARKER_FILE = ".odoo-python-source.json" DEPENDENCY_LAYOUT_MARKER_FILE = ".odoo-python-sync-layout" +BASE_RUNTIME_DEPENDENCY_PREFLIGHT_SCRIPT = textwrap.dedent( + """\ + set -euo pipefail + temporary_root="$(mktemp -d)" + trap 'rm -rf "${temporary_root}"' EXIT + support_requirements="${temporary_root}/support-runtime.txt" + tenant_requirements="${temporary_root}/tenant.txt" + base_constraints="${temporary_root}/base-constraints.txt" + uv lock --quiet --project /opt/runtime --check + uv export --quiet --project /opt/runtime --frozen --format requirements.txt \ + --no-emit-workspace --no-default-groups \ + --output-file "${support_requirements}" + uv lock --quiet --project /opt/project --check + uv export --quiet --project /opt/project --frozen --format requirements.txt \ + --no-emit-workspace --no-default-groups --all-packages \ + --output-file "${tenant_requirements}" + /venv/bin/python - <<'PY' >"${base_constraints}" + from importlib import metadata + import re + + versions = {} + for distribution in metadata.distributions(): + name = re.sub(r"[-_.]+", "-", distribution.metadata["Name"].strip()).lower() + version = distribution.version.strip() + previous = versions.get(name) + if previous is not None and previous != version: + raise SystemExit(f"Conflicting base distributions for {name}: {previous}, {version}") + versions[name] = version + for name, version in sorted(versions.items()): + print(f"{name}=={version}") + PY + uv pip install --dry-run --python /venv/bin/python --no-deps \ + --constraint "${base_constraints}" \ + -r "${support_requirements}" \ + -r "${tenant_requirements}" + """ +).strip() ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY = "ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64" LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY = "LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED" LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY = "LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED" @@ -618,6 +655,14 @@ def publish_runtime_artifact( ) except DependencyWorkspaceError as error: raise RuntimeCommandError(str(error)) from error + require_base_runtime_dependency_compatibility( + base_runtime_image=runtime_base_provenance.digest_reference, + staged_support_root=staged_context_root / "runtime", + staged_tenant_root=staged_context_root / "project", + platforms=normalized_platforms, + build_environment=build_environment, + ) + require_staged_artifact_context_unchanged(staged_context_root=staged_context_root, staged_context=staged_context) build_command = [ "docker", "buildx", @@ -714,6 +759,43 @@ def publish_runtime_artifact( ) +def require_base_runtime_dependency_compatibility( + *, + base_runtime_image: str, + staged_support_root: Path, + staged_tenant_root: Path, + platforms: tuple[str, ...], + build_environment: dict[str, str], +) -> None: + for target_platform in platforms: + command = [ + "docker", + "run", + "--rm", + "--platform", + target_platform, + "--volume", + f"{staged_support_root.resolve()}:/opt/runtime:ro", + "--volume", + f"{staged_tenant_root.resolve()}:/opt/project:ro", + "--entrypoint", + "/bin/bash", + base_runtime_image, + "-lc", + BASE_RUNTIME_DEPENDENCY_PREFLIGHT_SCRIPT, + ] + try: + run_command( + runtime_repo_path=staged_tenant_root, + command=command, + environment_overrides=build_environment, + ) + except RuntimeCommandError as error: + raise RuntimeCommandError( + f"Base runtime dependency preflight failed for {target_platform}; see resolver diagnostics above." + ) from error + + def validate_artifact_publish_runtime_values(runtime_values: dict[str, str]) -> None: if not runtime_values.get("ODOO_VERSION", "").strip(): raise RuntimeCommandError( diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 50d0c15..989dd85 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -2333,6 +2333,7 @@ def test_native_runtime_publish_accepts_build_only_payload_and_prefers_exact_ref ) captured_build_args: list[str] = [] + captured_commands: list[list[str]] = [] def fake_run_command( *, @@ -2342,6 +2343,7 @@ def fake_run_command( allowed_return_codes: object | None = None, ) -> None: _ = runtime_repo_path, environment_overrides, allowed_return_codes + captured_commands.append(command) if command[:3] == ["docker", "buildx", "build"]: self._write_artifact_build_outputs_for_command(command) if "--metadata-file" in command: @@ -2388,6 +2390,37 @@ def fake_run_command( ) self.assertEqual(payload["build_flags"]["values"]["odoo_version"], "20.0") self.assertEqual(payload["build_flags"]["addon_skip_flags"], []) + preflight_command = next(command for command in captured_commands if command[:2] == ["docker", "run"]) + build_command = next(command for command in captured_commands if command[:3] == ["docker", "buildx", "build"]) + self.assertLess(captured_commands.index(preflight_command), captured_commands.index(build_command)) + self.assertIn("ghcr.io/example/runtime@sha256:" + "2" * 64, preflight_command) + self.assertIn("linux/amd64", preflight_command) + self.assertTrue(any(value.endswith(":/opt/runtime:ro") for value in preflight_command)) + self.assertTrue(any(value.endswith(":/opt/project:ro") for value in preflight_command)) + + def test_base_runtime_dependency_preflight_wraps_platform_failure(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_root = Path(temporary_directory) + support_root = temporary_root / "runtime" + tenant_root = temporary_root / "project" + support_root.mkdir() + tenant_root.mkdir() + + with mock.patch( + "odoo_devkit.local_runtime.run_command", + side_effect=local_runtime.RuntimeCommandError("Command failed (1): docker run"), + ): + with self.assertRaisesRegex( + ValueError, + "Base runtime dependency preflight failed for linux/amd64; see resolver diagnostics above", + ): + local_runtime.require_base_runtime_dependency_compatibility( + base_runtime_image="ghcr.io/example/runtime@sha256:" + "2" * 64, + staged_support_root=support_root, + staged_tenant_root=tenant_root, + platforms=("linux/amd64",), + build_environment={}, + ) def test_native_runtime_publish_requires_explicit_payload_for_non_local_instance(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: