Skip to content
Merged
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
7 changes: 5 additions & 2 deletions docs/tooling/workspace-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions odoo_devkit/local_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
33 changes: 33 additions & 0 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
*,
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down