diff --git a/README.md b/README.md index 9a1efd0..e49ff82 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ The current workspace flow: - treats the active tenant checkout as the source of truth for handwritten code, - emits a `workspace.lock.toml` file with the exact assembled refs, +- inspects combined tenant/shared-addon uv workspaces before artifact publish, - generates a minimal runtime config scaffold under `.generated/`, and - generates workspace-root `AGENTS.md`, `docs/README.md`, and `docs/session-prompt.md` so Every Code, Codex Lab, and other coding-agent @@ -39,6 +40,8 @@ uv run platform workspace scaffold-cockpit-root \ --output-dir /path/to/workspace-root --force uv run platform workspace clean --manifest /path/to/workspace.toml uv run platform workspace run --manifest /path/to/workspace.toml -- pwd +uv run platform dependencies inspect --manifest /path/to/workspace.toml +uv run platform dependencies check --manifest /path/to/workspace.toml uv run platform runtime select --manifest /path/to/workspace.toml uv run platform runtime build --manifest /path/to/workspace.toml --no-cache uv run platform runtime up --manifest /path/to/workspace.toml --build diff --git a/docker/artifact.Dockerfile b/docker/artifact.Dockerfile new file mode 100644 index 0000000..4086d00 --- /dev/null +++ b/docker/artifact.Dockerfile @@ -0,0 +1,80 @@ +# syntax=docker/dockerfile:1.6 +ARG ODOO_VERSION=19.0 +ARG ODOO_BASE_RUNTIME_IMAGE +ARG ODOO_BASE_DEVTOOLS_IMAGE +ARG ODOO_ADDON_REPOSITORIES +ARG OPENUPGRADE_ADDON_REPOSITORY +ARG ODOO_PYTHON_SYNC_SKIP_ADDONS + +FROM scratch AS project-payload +COPY /platform/config /payload/volumes/config +COPY /docker/scripts /payload/volumes/scripts +COPY /runtime /payload/opt/runtime +COPY /project /payload/opt/project +COPY /addons /payload/opt/project/addons + +FROM ${ODOO_BASE_RUNTIME_IMAGE} AS addon-sources +ARG ODOO_ADDON_REPOSITORIES +ARG OPENUPGRADE_ADDON_REPOSITORY +USER root +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +RUN --mount=type=secret,id=github_token \ + rm -rf /opt/extra_addons \ + && mkdir -p /opt/extra_addons \ + && GITHUB_TOKEN="$(cat /run/secrets/github_token 2>/dev/null || true)" \ + ODOO_ADDON_REPOSITORIES="${ODOO_ADDON_REPOSITORIES}" \ + /usr/local/bin/odoo-fetch-addons.sh + +FROM ${ODOO_BASE_RUNTIME_IMAGE} AS base-runtime +ARG ODOO_ADDON_REPOSITORIES +ARG OPENUPGRADE_ADDON_REPOSITORY +ARG ODOO_PYTHON_SYNC_SKIP_ADDONS +USER root +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN rm -rf /opt/runtime /opt/project /opt/extra_addons /opt/launchplane/evidence /volumes/config /volumes/scripts \ + && mkdir -p /opt/runtime /opt/project /opt/extra_addons /opt/launchplane/evidence /volumes + +COPY --from=project-payload /payload/ / +COPY --from=addon-sources --chown=ubuntu:ubuntu /opt/extra_addons /opt/extra_addons + +RUN mkdir -p /volumes /opt/project \ + && rm -f /volumes/pyproject.toml /volumes/uv.lock \ + && ln -s /opt/project/pyproject.toml /volumes/pyproject.toml \ + && ln -s /opt/project/uv.lock /volumes/uv.lock + +FROM ${ODOO_BASE_DEVTOOLS_IMAGE} AS base-devtools +ARG ODOO_ADDON_REPOSITORIES +ARG OPENUPGRADE_ADDON_REPOSITORY +ARG ODOO_PYTHON_SYNC_SKIP_ADDONS +USER root +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN rm -rf /opt/runtime /opt/project /opt/extra_addons /opt/launchplane/evidence /volumes/config /volumes/scripts \ + && mkdir -p /opt/runtime /opt/project /opt/extra_addons /opt/launchplane/evidence /volumes + +COPY --from=project-payload /payload/ / +COPY --from=addon-sources --chown=ubuntu:ubuntu /opt/extra_addons /opt/extra_addons + +RUN mkdir -p /volumes /opt/project \ + && rm -f /volumes/pyproject.toml /volumes/uv.lock \ + && ln -s /opt/project/pyproject.toml /volumes/pyproject.toml \ + && ln -s /opt/project/uv.lock /volumes/uv.lock \ + && rm -rf /opt/project/tools \ + && ln -s /volumes/tools /opt/project/tools + +FROM base-runtime AS production +ARG TARGETPLATFORM +WORKDIR /opt/project +RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000,sharing=locked \ + TARGETPLATFORM="${TARGETPLATFORM}" ODOO_PYTHON_SYNC_SKIP_ADDONS="${ODOO_PYTHON_SYNC_SKIP_ADDONS}" /usr/local/bin/odoo-python-sync.sh prod +WORKDIR / +USER ubuntu + +FROM base-devtools AS development +ARG TARGETPLATFORM +WORKDIR /opt/project + +RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000,sharing=locked \ + TARGETPLATFORM="${TARGETPLATFORM}" ODOO_PYTHON_SYNC_SKIP_ADDONS="${ODOO_PYTHON_SYNC_SKIP_ADDONS}" /usr/local/bin/odoo-python-sync.sh dev +USER ubuntu diff --git a/docker/dependency-evidence.Dockerfile b/docker/dependency-evidence.Dockerfile new file mode 100644 index 0000000..c37f97f --- /dev/null +++ b/docker/dependency-evidence.Dockerfile @@ -0,0 +1,7 @@ +# syntax=docker/dockerfile:1.6 +ARG ARTIFACT_IMAGE + +FROM --platform=$TARGETPLATFORM ${ARTIFACT_IMAGE} AS artifact + +FROM scratch +COPY --from=artifact /opt/launchplane/evidence/dependency-provenance.json /dependency-provenance.json diff --git a/docker/runtime-python/pyproject.toml b/docker/runtime-python/pyproject.toml new file mode 100644 index 0000000..66a6ce0 --- /dev/null +++ b/docker/runtime-python/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "odoo-runtime-support" +version = "0.0.0" +requires-python = ">=3.13" +dependencies = [ + "hatchling==1.27.0", + "passlib>=1.7.4", + "pydantic>=2.13.4", + "pydantic-settings>=2.14.2", +] + +[tool.uv] +package = false diff --git a/docker/runtime-python/uv.lock b/docker/runtime-python/uv.lock new file mode 100644 index 0000000..e5edeac --- /dev/null +++ b/docker/runtime-python/uv.lock @@ -0,0 +1,206 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "hatchling" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/8a/cc1debe3514da292094f1c3a700e4ca25442489731ef7c0814358816bb03/hatchling-1.27.0.tar.gz", hash = "sha256:971c296d9819abb3811112fc52c7a9751c8d381898f36533bb16f9791e941fd6", size = 54983, upload-time = "2024-12-15T17:08:11.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/e7/ae38d7a6dfba0533684e0b2136817d667588ae3ec984c1a4e5df5eb88482/hatchling-1.27.0-py3-none-any.whl", hash = "sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b", size = 75794, upload-time = "2024-12-15T17:08:10.364Z" }, +] + +[[package]] +name = "odoo-runtime-support" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "hatchling" }, + { name = "passlib" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, +] + +[package.metadata] +requires-dist = [ + { name = "hatchling", specifier = "==1.27.0" }, + { name = "passlib", specifier = ">=1.7.4" }, + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "pydantic-settings", specifier = ">=2.14.2" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "trove-classifiers" +version = "2026.6.1.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/e3/7ca82ee24c82d344584abd5b8637b3bd056f2900226e8d82fc22f1184b92/trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745", size = 17059, upload-time = "2026-06-01T19:41:34.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3", size = 14211, upload-time = "2026-06-01T19:41:33.434Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/docs/tooling/artifact-inputs.md b/docs/tooling/artifact-inputs.md index e671f5b..5eb239c 100644 --- a/docs/tooling/artifact-inputs.md +++ b/docs/tooling/artifact-inputs.md @@ -27,6 +27,52 @@ When - `platform runtime` reads this file for source repository selection used by local runtime and publish flows. +## Immutable publish handoff + +Before `platform runtime publish`, run `platform dependencies check` and commit +the exact tenant, devkit, and shared-addon inputs. Publish fails closed for +dirty source repos, nonordinary index flags or Git replacement refs, untracked +or symlinked staged files, stale/missing tenant lock pairs, mutable VCS refs, +source-supplied `.odoo-python-source.json` markers, or staged-byte changes. +Devkit alone writes those markers from the verified Git snapshots used for the +build. Each recorded source commit must also be advertised by a ref in its +normalized GitHub origin; changing only `.git/config` cannot reattribute a +local commit to another repository. + +The artifact build uses two explicit uv roots: + +- `/opt/runtime` contains devkit's static support/runtime catalog and lock; its + source locator remains `docker/runtime-python/uv.lock` in the devkit commit. +- `/opt/project` contains the tenant root workspace, tenant lock, and the exact + tenant/shared-addon member metadata validated by the dependency checker. + +Artifact publish uses `docker/artifact.Dockerfile`; the existing +`docker/Dockerfile` remains the local Compose build contract. Shared-addon +members receive a nested source marker so package inventory attributes their +installed distributions to the shared repository/commit while the tenant lock +remains tenant-owned. Payload-owned config, scripts, external addons, and prior +dependency evidence are cleared before the staged artifact inputs are copied, +so base-image residue cannot survive into the final image. + +Both configured base images are resolved to registry digests before the build, +and their OCI source/revision labels must agree across every requested +platform. After Buildx pushes the artifact, devkit extracts dependency evidence +from the resulting immutable image digest—not from the mutable tag—and emits +Launchplane artifact-manifest schema v2. + +The v2 handoff records both lock hashes and source commits, exact per-platform +Python package inventories, external compatibility descriptors, base-image +provenance, devkit build-tool provenance, and the final artifact digest. It +never persists secret values, authenticated URLs, local absolute paths, or +operator-local overrides. Dependencies may not be installed after evidence is +written; they must be represented by one of the two locks or exact external +compatibility evidence. + +The current layout-2 producer marker contract uses GitHub-style +`owner/repository` identities. A non-GitHub tenant, devkit, or shared-addon +origin fails before build rather than passing a URL that the image producer +cannot consume. + ## Current schema ```toml diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index 570a97c..60e205b 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -35,6 +35,8 @@ uv run platform workspace status-cockpit-root \ --config /path/to/workspace-root/workspace-cockpit.toml uv run platform workspace clean --manifest /path/to/workspace.toml uv run platform workspace run --manifest /path/to/workspace.toml -- pwd +uv run platform dependencies inspect --manifest /path/to/workspace.toml +uv run platform dependencies check --manifest /path/to/workspace.toml uv run platform runtime select --manifest /path/to/workspace.toml uv run platform runtime build --manifest /path/to/workspace.toml --no-cache uv run platform runtime up --manifest /path/to/workspace.toml --build @@ -99,6 +101,35 @@ Purpose generated guidance, source materialization, or override state is not current. Baseline drift on editable path-linked sources alone does not fail the check. +## `dependencies inspect` and `dependencies check` + +Purpose + +- Inspect tenant and shared-addon `pyproject.toml` files as one staged owned + dependency workspace without moving shared-addon ownership into the tenant + repository. +- Require every owned addon project to set `tool.uv.package = false`, retain an + explicit exactly pinned build backend, and avoid `tool.uv.managed = false`, + mutable VCS refs, local/archive references, or `requirements*.txt` fallback. +- When a tenant root `pyproject.toml` and `uv.lock` exist, require them as a + complete pair, expand workspace members against the combined staged layout, + and require the expanded members to exactly match all tenant and shared-addon + projects. +- Run `uv lock --check --offline --no-config` against that combined staged + layout with operator `UV_*`/`PIP_*` overrides removed. Devkit does not parse + uv's lock internals as a substitute for uv's own currentness decision. +- Treat publishable dependency metadata as Git-attributed input: root/member + pyprojects and the tenant lock must be tracked regular files, symlinks and + operator-local paths are rejected, and custom uv indexes/find-links cannot + enter the staged contract. +- Allow a pure-addon workspace with no runtime Python dependency declarations + to remain lockless and current for local development. Such a workspace is + reported as `publishable = false` because Launchplane artifact schema v2 + requires both support/runtime and tenant lock evidence; devkit never invents + a tenant lock that is absent from the tenant repository. +- `inspect` prints structured JSON. `check` prints the same report and exits + nonzero when `current` is false. + ## `workspace scaffold-cockpit-root` Purpose @@ -282,12 +313,30 @@ Notes - `platform runtime build` follows the same local-only rule and gives tenant manifests a native build-only entry point when operators want image prep without starting the stack. -- `platform runtime publish` is the release-handoff path. It stages tenant and - shared addon sources into a clean build context, resolves configured addon - repository selectors to exact git SHAs before build and artifact minting, - pushes the requested image tag, reads the pushed image digest from Buildx's - build metadata output, and writes a control-plane-compatible artifact manifest - JSON file. +- `platform runtime publish` is the release-handoff path. It requires clean + 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. +- 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 + commits, and writes Launchplane artifact-manifest schema v2. The manifest + includes base-image/build-tool provenance, both uv locks, per-platform exact + Python package inventories, and external compatibility descriptors. +- The artifact path uses `docker/artifact.Dockerfile`; local Compose continues + to use `docker/Dockerfile`. Support lock evidence identifies + `docker/runtime-python/uv.lock`, and nested shared-addon source markers keep + installed shared distributions attributed to the shared repository commit. +- Publish does not mutate `/venv` after dependency evidence is written. An + OpenUpgrade or other Python dependency must be represented by the locked + support/tenant catalogs or by exact external compatibility evidence; an ad + hoc post-sync install is not part of the artifact contract. +- `ODOO_PYTHON_SYNC_SKIP_ADDONS` is legacy-layout behavior and is rejected for + schema-v2 publish because exporting the full workspace lock while skipping a + member would make the evidence false. Remove the project from the tenant + workspace instead. - Non-local publish requires Launchplane to supply `ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON`. The payload is authoritative for artifact build runtime keys and can synthesize a missing context or instance diff --git a/odoo_devkit/artifact_provenance.py b/odoo_devkit/artifact_provenance.py new file mode 100644 index 0000000..265b885 --- /dev/null +++ b/odoo_devkit/artifact_provenance.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path, PurePosixPath +from urllib.parse import urlsplit, urlunsplit + +_GIT_COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$") +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_PACKAGE_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") +_PACKAGE_VERSION_PATTERN = re.compile(r"^(?=.*[0-9])[A-Za-z0-9][A-Za-z0-9.!+_-]*$") +_PYTHON_VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[a-z0-9.+-]+)?$") +_PLATFORM_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)?$") +_REPOSITORY_SLUG_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +_REPOSITORY_IDENTITY_PATTERN = re.compile(r"^[\x21-\x7e]+$") +_REPOSITORY_URL_PATH_PATTERN = re.compile(r"^/[A-Za-z0-9._/-]+$") +_SCP_REPOSITORY_PATTERN = re.compile(r"^(?P[A-Za-z0-9._-]+)@(?P[A-Za-z0-9.-]+):(?P[A-Za-z0-9._/-]+)$") +_SSH_USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$") +_REPO_RELATIVE_PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$") +_WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:[\\/]") + + +class ArtifactProvenanceError(ValueError): + pass + + +def aggregate_dependency_evidence( + *, + evidence_root: Path, + expected_platforms: tuple[str, ...], + expected_uv_locks: tuple[dict[str, str], ...], +) -> dict[str, object]: + normalized_platforms = tuple(sorted(_normalize_platform(platform) for platform in expected_platforms)) + if not normalized_platforms or len(normalized_platforms) != len(set(normalized_platforms)): + raise ArtifactProvenanceError("Artifact dependency evidence requires unique target platforms") + expected_locks = _normalize_uv_locks(expected_uv_locks) + evidence_paths = tuple(sorted(evidence_root.rglob("dependency-provenance.json"))) + if not evidence_paths: + raise ArtifactProvenanceError("Published artifact image did not expose dependency provenance evidence") + + environments: dict[str, dict[str, object]] = {} + common_locks: tuple[dict[str, str], ...] | None = None + common_external_inputs: tuple[dict[str, str], ...] | None = None + for evidence_path in evidence_paths: + payload = _load_json_object(evidence_path) + if set(payload) != { + "schema_version", + "layout", + "publishable", + "target_platform", + "uv_locks", + "python_environment", + "external_compatibility_inputs", + }: + raise ArtifactProvenanceError("Dependency evidence sidecar contains unsupported fields") + if payload.get("schema_version") != 1 or payload.get("layout") != "two_lock": + raise ArtifactProvenanceError("Dependency evidence sidecar must use schema 1 two_lock layout") + if payload.get("publishable") is not True: + raise ArtifactProvenanceError("Dependency evidence sidecar is not publishable") + platform = _normalize_platform(_required_string(payload, "target_platform")) + if platform not in normalized_platforms: + raise ArtifactProvenanceError(f"Dependency evidence contains unexpected target platform: {platform}") + if platform in environments: + raise ArtifactProvenanceError(f"Dependency evidence contains duplicate target platform: {platform}") + raw_locks = payload.get("uv_locks") + if not isinstance(raw_locks, list): + raise ArtifactProvenanceError("Dependency evidence uv_locks must be an array") + locks = _normalize_uv_locks(tuple(_required_object(item, label="uv lock") for item in raw_locks)) + if locks != expected_locks: + raise ArtifactProvenanceError("Published dependency lock evidence does not match the exact staged lock inputs") + raw_external_inputs = payload.get("external_compatibility_inputs") + if not isinstance(raw_external_inputs, list): + raise ArtifactProvenanceError("Dependency evidence external_compatibility_inputs must be an array") + external_inputs = _normalize_external_inputs( + tuple(_required_object(item, label="external compatibility input") for item in raw_external_inputs) + ) + if common_locks is None: + common_locks = locks + elif common_locks != locks: + raise ArtifactProvenanceError("Dependency lock evidence differs across target platforms") + if common_external_inputs is None: + common_external_inputs = external_inputs + elif common_external_inputs != external_inputs: + raise ArtifactProvenanceError("External compatibility evidence differs across target platforms") + environments[platform] = _normalize_python_environment( + _required_object(payload.get("python_environment"), label="python environment") + ) + + if set(environments) != set(normalized_platforms): + missing = sorted(set(normalized_platforms) - set(environments)) + raise ArtifactProvenanceError(f"Dependency evidence is missing target platforms: {missing}") + assert common_locks is not None + return { + "target_platforms": list(normalized_platforms), + "uv_locks": [dict(lock) for lock in common_locks], + "python_environments": {platform: environments[platform] for platform in normalized_platforms}, + "external_compatibility_inputs": [dict(item) for item in common_external_inputs or ()], + } + + +def normalize_repository_identity(value: str) -> str: + normalized = value.strip() + lowered = normalized.lower() + if not normalized: + raise ArtifactProvenanceError("Repository identity cannot be empty") + if _REPOSITORY_IDENTITY_PATTERN.fullmatch(normalized) is None: + raise ArtifactProvenanceError("Repository identity contains unsafe characters") + if ( + normalized.startswith(("/", "./", "../", "~")) + or _WINDOWS_DRIVE_PATTERN.match(normalized) + or "\\" in normalized + or lowered.startswith(("file:", "git+file:")) + ): + raise ArtifactProvenanceError("Repository identity cannot use a local path") + if _REPOSITORY_SLUG_PATTERN.fullmatch(normalized): + if any(part in {".", ".."} for part in normalized.split("/")): + raise ArtifactProvenanceError("Repository identity cannot contain path traversal") + normalized_slug = normalized.removesuffix(".git") + if _REPOSITORY_SLUG_PATTERN.fullmatch(normalized_slug) is None: + raise ArtifactProvenanceError("Repository identity must use owner/repository syntax") + return normalized_slug + + scp_match = _SCP_REPOSITORY_PATTERN.fullmatch(normalized) + if scp_match is not None: + host = scp_match.group("host").lower() + path = _normalize_repository_path(scp_match.group("path"), label="Repository identity").removesuffix(".git") + if host == "github.com" and _REPOSITORY_SLUG_PATTERN.fullmatch(path): + return path + return f"ssh://{scp_match.group('username')}@{host}/{path}" + + if "://" not in normalized: + raise ArtifactProvenanceError("Repository identity must use owner/repository or a sanitized URL") + parsed = urlsplit(normalized) + if parsed.scheme not in {"https", "ssh"} or not parsed.hostname or not parsed.path.strip("/"): + raise ArtifactProvenanceError("Repository identity must use an https or ssh repository URL") + if parsed.password is not None or parsed.query or parsed.fragment: + raise ArtifactProvenanceError("Repository identity cannot contain credentials, queries, or fragments") + if parsed.username is not None and (parsed.scheme != "ssh" or _SSH_USERNAME_PATTERN.fullmatch(parsed.username) is None): + raise ArtifactProvenanceError("Repository identity contains invalid userinfo") + path = _normalize_repository_path(parsed.path, label="Repository identity").removesuffix(".git") + if parsed.hostname.lower() == "github.com" and _REPOSITORY_SLUG_PATTERN.fullmatch(path): + return path + username = parsed.username if parsed.scheme == "ssh" else None + authority = f"{username}@{parsed.hostname.lower()}" if username else parsed.hostname.lower() + try: + port = parsed.port + except ValueError as error: + raise ArtifactProvenanceError("Repository identity contains an invalid port") from error + if port is not None: + authority += f":{port}" + return urlunsplit((parsed.scheme, authority, f"/{path}", "", "")) + + +def _normalize_repository_path(value: str, *, label: str) -> str: + normalized = f"/{value.strip('/')}" + if _REPOSITORY_URL_PATH_PATTERN.fullmatch(normalized) is None: + raise ArtifactProvenanceError(f"{label} contains unsafe path characters") + parts = normalized.strip("/").split("/") + if any(not part or part in {".", ".."} for part in parts): + raise ArtifactProvenanceError(f"{label} contains path traversal") + return normalized.strip("/") + + +def normalize_git_commit(value: str) -> str: + normalized = value.strip() + if _GIT_COMMIT_PATTERN.fullmatch(normalized) is None: + raise ArtifactProvenanceError("Source ref must be an exact lowercase 40-character git commit") + return normalized + + +def _normalize_uv_locks(raw_locks: tuple[dict[str, object] | dict[str, str], ...]) -> tuple[dict[str, str], ...]: + normalized_locks: list[dict[str, str]] = [] + for raw_lock in raw_locks: + if set(raw_lock) != {"scope", "source_repository", "source_ref", "path", "sha256"}: + raise ArtifactProvenanceError("uv lock evidence contains unsupported fields") + scope = _required_string(raw_lock, "scope") + if scope not in {"support_runtime", "tenant"}: + raise ArtifactProvenanceError(f"Unsupported uv lock scope: {scope}") + path = _normalize_relative_path(_required_string(raw_lock, "path")) + if PurePosixPath(path).name != "uv.lock": + raise ArtifactProvenanceError("uv lock evidence path must identify uv.lock") + sha256 = _required_string(raw_lock, "sha256") + if _SHA256_PATTERN.fullmatch(sha256) is None: + raise ArtifactProvenanceError("uv lock evidence requires a lowercase SHA-256") + normalized_locks.append( + { + "scope": scope, + "source_repository": normalize_repository_identity(_required_string(raw_lock, "source_repository")), + "source_ref": normalize_git_commit(_required_string(raw_lock, "source_ref")), + "path": path, + "sha256": sha256, + } + ) + scopes = [lock["scope"] for lock in normalized_locks] + if set(scopes) != {"support_runtime", "tenant"} or len(scopes) != 2: + raise ArtifactProvenanceError("Dependency evidence requires support_runtime and tenant uv locks") + order = {"support_runtime": 0, "tenant": 1} + return tuple(sorted(normalized_locks, key=lambda lock: order[lock["scope"]])) + + +def _normalize_python_environment(payload: dict[str, object]) -> dict[str, object]: + if set(payload) != {"python_version", "packages", "package_count", "packages_sha256"}: + raise ArtifactProvenanceError("Python environment evidence contains unsupported fields") + python_version = _required_string(payload, "python_version").lower() + if _PYTHON_VERSION_PATTERN.fullmatch(python_version) is None: + raise ArtifactProvenanceError("Python environment evidence requires an exact Python version") + raw_packages = payload.get("packages") + if not isinstance(raw_packages, list): + raise ArtifactProvenanceError("Python environment packages must be an array") + packages: list[dict[str, object]] = [] + names: set[str] = set() + for raw_package in raw_packages: + package = _required_object(raw_package, label="python package") + if set(package) != {"name", "version", "source"}: + raise ArtifactProvenanceError("Python package evidence contains unsupported fields") + name = re.sub(r"[-_.]+", "-", _required_string(package, "name")).lower() + version = _required_string(package, "version") + if _PACKAGE_NAME_PATTERN.fullmatch(name) is None or name in names: + raise ArtifactProvenanceError(f"Python package evidence has invalid or duplicate name: {name}") + if _PACKAGE_VERSION_PATTERN.fullmatch(version) is None: + raise ArtifactProvenanceError(f"Python package evidence has invalid exact version: {version}") + names.add(name) + source = _normalize_package_source(_required_object(package.get("source"), label="python package source")) + packages.append({"name": name, "version": version, "source": source}) + packages.sort(key=lambda package: str(package["name"])) + package_count = payload.get("package_count") + if not isinstance(package_count, int) or isinstance(package_count, bool) or package_count != len(packages): + raise ArtifactProvenanceError("Python package_count does not match package evidence") + canonical_json = json.dumps(packages, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + packages_sha256 = _required_string(payload, "packages_sha256") + expected_sha256 = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest() + if packages_sha256 != expected_sha256: + raise ArtifactProvenanceError("Python packages_sha256 does not match canonical package evidence") + return { + "python_version": python_version, + "packages": packages, + "package_count": package_count, + "packages_sha256": packages_sha256, + } + + +def _normalize_package_source(payload: dict[str, object]) -> dict[str, str]: + if set(payload) != {"kind", "repository", "commit"}: + raise ArtifactProvenanceError("Python package source contains unsupported fields") + kind = _required_string(payload, "kind") + repository = str(payload.get("repository", "")).strip() + commit = str(payload.get("commit", "")).strip() + if kind == "registry": + if repository or commit: + raise ArtifactProvenanceError("Registry package source cannot contain repository or commit") + return {"kind": "registry", "repository": "", "commit": ""} + if kind != "vcs": + raise ArtifactProvenanceError(f"Unsupported Python package source kind: {kind}") + return { + "kind": "vcs", + "repository": normalize_repository_identity(repository), + "commit": normalize_git_commit(commit), + } + + +def _normalize_external_inputs(raw_inputs: tuple[dict[str, object], ...]) -> tuple[dict[str, str], ...]: + normalized_inputs: list[dict[str, str]] = [] + for raw_input in raw_inputs: + if set(raw_input) != { + "source_repository", + "source_ref", + "dependency_file_path", + "dependency_file_sha256", + "format", + "resolution_posture", + }: + raise ArtifactProvenanceError("External compatibility evidence contains unsupported fields") + dependency_format = _required_string(raw_input, "format") + if dependency_format not in {"pyproject_toml", "requirements_txt"}: + raise ArtifactProvenanceError(f"Unsupported external dependency format: {dependency_format}") + resolution_posture = _required_string(raw_input, "resolution_posture") + if resolution_posture not in {"locked", "exact_source_unlocked"}: + raise ArtifactProvenanceError(f"Unsupported external dependency posture: {resolution_posture}") + path = _normalize_relative_path(_required_string(raw_input, "dependency_file_path")) + file_name = PurePosixPath(path).name + if dependency_format == "pyproject_toml" and file_name != "pyproject.toml": + raise ArtifactProvenanceError("pyproject_toml evidence must identify pyproject.toml") + if dependency_format == "requirements_txt" and not file_name.endswith(".txt"): + raise ArtifactProvenanceError("requirements_txt evidence must identify a .txt file") + sha256 = _required_string(raw_input, "dependency_file_sha256") + if _SHA256_PATTERN.fullmatch(sha256) is None: + raise ArtifactProvenanceError("External dependency evidence requires a lowercase SHA-256") + normalized_inputs.append( + { + "source_repository": normalize_repository_identity(_required_string(raw_input, "source_repository")), + "source_ref": normalize_git_commit(_required_string(raw_input, "source_ref")), + "dependency_file_path": path, + "dependency_file_sha256": sha256, + "format": dependency_format, + "resolution_posture": resolution_posture, + } + ) + normalized_inputs.sort(key=lambda item: (item["source_repository"], item["source_ref"], item["dependency_file_path"])) + identities = [(item["source_repository"], item["source_ref"], item["dependency_file_path"]) for item in normalized_inputs] + if len(identities) != len(set(identities)): + raise ArtifactProvenanceError("External dependency evidence contains duplicate inputs") + return tuple(normalized_inputs) + + +def _normalize_relative_path(value: str) -> str: + normalized = value.strip() + path = PurePosixPath(normalized) + if ( + not normalized + or normalized.startswith(("/", "~")) + or _WINDOWS_DRIVE_PATTERN.match(normalized) + or "\\" in normalized + or "://" in normalized + or _REPO_RELATIVE_PATH_PATTERN.fullmatch(normalized) is None + or any(part in {"", ".", ".."} for part in path.parts) + or path.as_posix() != normalized + ): + raise ArtifactProvenanceError("Dependency evidence path must be a safe repository-relative path") + return path.as_posix() + + +def _normalize_platform(value: str) -> str: + normalized = value.strip().lower() + if _PLATFORM_PATTERN.fullmatch(normalized) is None: + raise ArtifactProvenanceError(f"Invalid OCI platform: {value}") + return normalized + + +def _load_json_object(path: Path) -> dict[str, object]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ArtifactProvenanceError(f"Invalid dependency evidence JSON: {path.name}") from error + return _required_object(payload, label="dependency evidence") + + +def _required_object(value: object, *, label: str) -> dict[str, object]: + if not isinstance(value, dict): + raise ArtifactProvenanceError(f"{label} must be a JSON object") + return value + + +def _required_string(source: dict[str, object] | dict[str, str], key: str) -> str: + value = source.get(key) + if not isinstance(value, str) or not value.strip(): + raise ArtifactProvenanceError(f"Dependency evidence {key} must be a non-empty string") + return value.strip() diff --git a/odoo_devkit/cli.py b/odoo_devkit/cli.py index ec5b4e8..0b2a901 100644 --- a/odoo_devkit/cli.py +++ b/odoo_devkit/cli.py @@ -6,6 +6,7 @@ from dataclasses import replace from pathlib import Path +from .dependency_workspace import inspect_dependency_workspace from .manifest import WorkspaceManifest, load_workspace_manifest from .runtime import ( run_native_runtime_build, @@ -91,6 +92,19 @@ def build_parser() -> argparse.ArgumentParser: run_parser.add_argument("command", nargs=argparse.REMAINDER) run_parser.set_defaults(handler=_handle_workspace_run) + dependencies_parser = subparsers.add_parser("dependencies", help="Inspect owned-addon dependency workspaces") + dependencies_subparsers = dependencies_parser.add_subparsers(dest="dependencies_command") + + dependencies_inspect_parser = _add_manifest_argument( + dependencies_subparsers.add_parser("inspect", help="Report the tenant dependency workspace contract") + ) + dependencies_inspect_parser.set_defaults(handler=_handle_dependencies_inspect) + + dependencies_check_parser = _add_manifest_argument( + dependencies_subparsers.add_parser("check", help="Fail when the tenant dependency workspace is not current") + ) + dependencies_check_parser.set_defaults(handler=_handle_dependencies_check) + runtime_parser = subparsers.add_parser("runtime", help="Run local runtime workflows via the workspace manifest") runtime_subparsers = runtime_parser.add_subparsers(dest="runtime_command") @@ -339,6 +353,20 @@ def _handle_workspace_run(arguments: argparse.Namespace) -> None: raise SystemExit(exit_code) +def _handle_dependencies_inspect(arguments: argparse.Namespace) -> None: + manifest = _load_manifest(arguments.manifest) + inspection = inspect_dependency_workspace(manifest=manifest) + print(json.dumps(inspection.to_dict(), indent=2, sort_keys=True)) + + +def _handle_dependencies_check(arguments: argparse.Namespace) -> None: + manifest = _load_manifest(arguments.manifest) + inspection = inspect_dependency_workspace(manifest=manifest) + print(json.dumps(inspection.to_dict(), indent=2, sort_keys=True)) + if not inspection.current: + raise SystemExit(1) + + def _handle_runtime_select(arguments: argparse.Namespace) -> None: manifest = _load_runtime_manifest(arguments) exit_code = _run_runtime_handler(lambda: run_native_runtime_select(manifest=manifest)) diff --git a/odoo_devkit/dependency_workspace.py b/odoo_devkit/dependency_workspace.py new file mode 100644 index 0000000..2562500 --- /dev/null +++ b/odoo_devkit/dependency_workspace.py @@ -0,0 +1,865 @@ +from __future__ import annotations + +import hashlib +import os +import re +import subprocess +import tempfile +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlsplit + +from .manifest import WorkspaceManifest + +DependencyProjectOwner = Literal["tenant", "shared_addons"] + +_GIT_COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$") +_PACKAGE_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") +_EXACT_BUILD_REQUIREMENT_PATTERN = re.compile( + r"^(?P[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)" + r"\s*==\s*(?P[A-Za-z0-9][A-Za-z0-9.!+_-]*)$" +) +_VCS_REFERENCE_PATTERN = re.compile(r"git\+(?:https|ssh)://[^\s]+@([^#\s]+)", re.IGNORECASE) +_DIRECT_REFERENCE_PATTERN = re.compile( + r"^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?" + r"(?:\[[A-Za-z0-9._,-]+\])?\s*@\s*(?P\S+)", + re.IGNORECASE, +) +_UNSAFE_DIRECT_REFERENCE_PATTERN = re.compile( + r"(?:^|\s)(?:-e\s+|--editable\s+|file:|\.\.?/|/)", + re.IGNORECASE, +) +_IGNORED_PATH_PARTS = frozenset({"__pycache__", "build", "dist"}) +_FORBIDDEN_UV_SOURCE_KEYS = frozenset( + { + "allow-insecure-host", + "default-index", + "dependency-metadata", + "extra-index-url", + "find-links", + "index", + "index-url", + "keyring-provider", + "no-index", + } +) +_UV_DEPENDENCY_LIST_KEYS = ( + "build-constraint-dependencies", + "constraint-dependencies", + "dev-dependencies", + "override-dependencies", +) + + +class DependencyWorkspaceError(ValueError): + pass + + +@dataclass(frozen=True) +class DependencyProjectInspection: + owner: DependencyProjectOwner + path: str + name: str + runtime_dependencies: tuple[str, ...] + + def to_dict(self) -> dict[str, object]: + return { + "owner": self.owner, + "path": self.path, + "name": self.name, + "runtime_dependencies": list(self.runtime_dependencies), + } + + +@dataclass(frozen=True) +class DependencyWorkspaceInspection: + tenant: str + current: bool + publishable: bool + requires_tenant_lock: bool + tenant_root_pyproject_present: bool + tenant_lock_present: bool + tenant_lock_current: bool | None + tenant_lock_sha256: str + workspace_members: tuple[str, ...] + projects: tuple[DependencyProjectInspection, ...] + findings: tuple[str, ...] + + def to_dict(self) -> dict[str, object]: + return { + "tenant": self.tenant, + "current": self.current, + "publishable": self.publishable, + "requires_tenant_lock": self.requires_tenant_lock, + "tenant_root_pyproject_present": self.tenant_root_pyproject_present, + "tenant_lock_present": self.tenant_lock_present, + "tenant_lock_current": self.tenant_lock_current, + "tenant_lock_sha256": self.tenant_lock_sha256, + "workspace_members": list(self.workspace_members), + "projects": [project.to_dict() for project in self.projects], + "findings": list(self.findings), + } + + +@dataclass(frozen=True) +class _ProjectInput: + owner: DependencyProjectOwner + source_repo_path: Path + source_pyproject_path: Path + staged_pyproject_path: Path + + +def inspect_dependency_workspace(*, manifest: WorkspaceManifest) -> DependencyWorkspaceInspection: + tenant_repo_path = manifest.tenant_repo.resolve_path(manifest_directory=manifest.manifest_directory) + if tenant_repo_path is None or not tenant_repo_path.is_dir(): + raise DependencyWorkspaceError("Tenant repo path must exist before dependency inspection.") + shared_addons_repo_path = _resolve_shared_addons_repo_path(manifest) + if manifest.shared_addons_repo is not None and (shared_addons_repo_path is None or not shared_addons_repo_path.is_dir()): + raise DependencyWorkspaceError( + "Shared addons repo must be materialized before dependency inspection. Run `platform workspace sync` first." + ) + + tenant_repo_path = tenant_repo_path.resolve() + shared_addons_repo_path = shared_addons_repo_path.resolve() if shared_addons_repo_path is not None else None + project_inputs = _discover_project_inputs( + tenant_repo_path=tenant_repo_path, + shared_addons_repo_path=shared_addons_repo_path, + ) + findings: list[str] = [] + findings.extend( + _reserved_dependency_namespace_findings( + tenant_repo_path=tenant_repo_path, + shared_addons_repo_path=shared_addons_repo_path, + ) + ) + projects: list[DependencyProjectInspection] = [] + staged_project_paths: set[str] = set() + requires_tenant_lock = False + + for project_input in project_inputs: + project_path = project_input.staged_pyproject_path.as_posix() + if project_path in staged_project_paths: + findings.append(f"Duplicate staged dependency project path: {project_path}") + continue + staged_project_paths.add(project_path) + try: + payload = _load_pyproject(project_input.source_pyproject_path) + runtime_dependencies = _runtime_dependencies(payload=payload, display_path=project_path) + requires_tenant_lock = requires_tenant_lock or bool(runtime_dependencies) + project_name = _project_name(payload=payload, display_path=project_path) + except DependencyWorkspaceError as error: + findings.append(str(error)) + continue + projects.append( + DependencyProjectInspection( + owner=project_input.owner, + path=project_path, + name=project_name, + runtime_dependencies=_sanitized_dependency_names(runtime_dependencies), + ) + ) + findings.extend(_member_pyproject_findings(payload=payload, display_path=project_path)) + + findings.extend(_owned_requirements_findings(tenant_repo_path=tenant_repo_path, shared_addons_repo_path=shared_addons_repo_path)) + + root_pyproject_path = tenant_repo_path / "pyproject.toml" + tenant_lock_path = tenant_repo_path / "uv.lock" + root_pyproject_present = root_pyproject_path.is_file() + tenant_lock_present = tenant_lock_path.is_file() + tenant_lock_sha256 = _sha256_file(tenant_lock_path) if tenant_lock_present else "" + tenant_lock_current: bool | None = None + workspace_members: tuple[str, ...] = () + + if root_pyproject_present != tenant_lock_present: + findings.append("Tenant dependency workspace requires pyproject.toml and uv.lock as a complete pair.") + if requires_tenant_lock and not (root_pyproject_present and tenant_lock_present): + findings.append("Owned runtime dependency declarations require a tenant root pyproject.toml and uv.lock.") + + if root_pyproject_present and tenant_lock_present: + findings.extend( + _publish_input_findings( + tenant_repo_path=tenant_repo_path, + root_pyproject_path=root_pyproject_path, + tenant_lock_path=tenant_lock_path, + project_inputs=project_inputs, + ) + ) + with tempfile.TemporaryDirectory(prefix="odoo-dependency-workspace-") as temporary_directory_name: + staged_root = Path(temporary_directory_name) + _stage_dependency_metadata( + root_pyproject_path=root_pyproject_path, + tenant_lock_path=tenant_lock_path, + project_inputs=project_inputs, + staged_root=staged_root, + ) + try: + root_payload = _load_pyproject(staged_root / "pyproject.toml") + root_runtime_dependencies = _validate_root_pyproject(payload=root_payload) + requires_tenant_lock = requires_tenant_lock or bool(root_runtime_dependencies) + workspace_member_set = _workspace_members(root=staged_root, payload=root_payload) + workspace_members = tuple(sorted(path.as_posix() for path in workspace_member_set)) + expected_members = { + project_input.staged_pyproject_path.parent + for project_input in project_inputs + if project_input.staged_pyproject_path.as_posix() in staged_project_paths + } + if workspace_member_set != expected_members: + extra_members = sorted(path.as_posix() for path in workspace_member_set - expected_members) + missing_members = sorted(path.as_posix() for path in expected_members - workspace_member_set) + findings.append( + "Tenant workspace members must exactly match owned addon projects; " + f"extra={extra_members}, missing={missing_members}" + ) + except DependencyWorkspaceError as error: + findings.append(str(error)) + if not findings: + tenant_lock_current = _uv_lock_is_current(staged_root) + if not tenant_lock_current: + findings.append("Tenant uv.lock is not current for the combined owned-addon workspace.") + else: + tenant_lock_current = False + + current = not findings + publishable = current and root_pyproject_present and tenant_lock_present and tenant_lock_current is True + return DependencyWorkspaceInspection( + tenant=manifest.tenant, + current=current, + publishable=publishable, + requires_tenant_lock=requires_tenant_lock, + tenant_root_pyproject_present=root_pyproject_present, + tenant_lock_present=tenant_lock_present, + tenant_lock_current=tenant_lock_current, + tenant_lock_sha256=tenant_lock_sha256, + workspace_members=workspace_members, + projects=tuple(sorted(projects, key=lambda project: (project.path, project.owner))), + findings=tuple(findings), + ) + + +def require_publishable_dependency_workspace(*, manifest: WorkspaceManifest) -> DependencyWorkspaceInspection: + inspection = inspect_dependency_workspace(manifest=manifest) + if not inspection.current: + raise DependencyWorkspaceError("Dependency workspace check failed: " + "; ".join(inspection.findings)) + if not inspection.publishable: + raise DependencyWorkspaceError( + "Artifact schema v2 requires a tracked tenant pyproject.toml and uv.lock, even when a lockless pure-addon workspace is valid locally." + ) + return inspection + + +def stage_publishable_dependency_workspace( + *, + manifest: WorkspaceManifest, + destination_root: Path, + tenant_commit: str | None = None, + shared_addons_commit: str | None = None, +) -> DependencyWorkspaceInspection: + inspection = require_publishable_dependency_workspace(manifest=manifest) + tenant_repo_path = manifest.tenant_repo.resolve_path(manifest_directory=manifest.manifest_directory) + assert tenant_repo_path is not None + tenant_repo_path = tenant_repo_path.resolve() + shared_addons_repo_path = _resolve_shared_addons_repo_path(manifest) + shared_addons_repo_path = shared_addons_repo_path.resolve() if shared_addons_repo_path is not None else None + if destination_root.exists() and any(destination_root.iterdir()): + raise DependencyWorkspaceError("Dependency workspace staging destination must be empty") + destination_root.mkdir(parents=True, exist_ok=True) + _stage_dependency_metadata( + root_pyproject_path=tenant_repo_path / "pyproject.toml", + tenant_lock_path=tenant_repo_path / "uv.lock", + project_inputs=_discover_project_inputs( + tenant_repo_path=tenant_repo_path, + shared_addons_repo_path=shared_addons_repo_path, + ), + staged_root=destination_root, + source_commits={ + tenant_repo_path: tenant_commit or _git_head_commit(tenant_repo_path), + **( + { + shared_addons_repo_path: shared_addons_commit or _git_head_commit(shared_addons_repo_path), + } + if shared_addons_repo_path is not None + else {} + ), + }, + ) + return inspection + + +def require_staged_dependency_workspace_current(*, staged_root: Path, label: str = "dependency") -> None: + if not _uv_lock_is_current(staged_root): + raise DependencyWorkspaceError(f"Staged {label} uv.lock changed or is not current for the exact artifact inputs.") + + +def require_staged_build_requirements_supplied(*, support_root: Path, tenant_root: Path) -> None: + supplied_requirements: set[tuple[str, str]] = set() + for catalog_root in (support_root, tenant_root): + payload = _load_pyproject(catalog_root / "pyproject.toml") + for dependency in _runtime_dependencies(payload=payload, display_path="pyproject.toml"): + match = _EXACT_BUILD_REQUIREMENT_PATTERN.fullmatch(dependency.strip()) + if match is not None: + supplied_requirements.add( + ( + re.sub(r"[-_.]+", "-", match.group("name")).lower(), + match.group("version"), + ) + ) + + missing_requirements: set[str] = set() + addons_root = tenant_root / "addons" + for pyproject_path in _discover_pyproject_paths(addons_root) if addons_root.is_dir() else (): + payload = _load_pyproject(pyproject_path) + build_system = payload.get("build-system") + requirements = build_system.get("requires", []) if isinstance(build_system, dict) else [] + for requirement in requirements: + if not isinstance(requirement, str): + raise DependencyWorkspaceError("Staged addon build requirements must be strings") + match = _EXACT_BUILD_REQUIREMENT_PATTERN.fullmatch(requirement.strip()) + if match is None: + raise DependencyWorkspaceError("Staged addon build requirements must use exact registry versions") + requirement_key = ( + re.sub(r"[-_.]+", "-", match.group("name")).lower(), + match.group("version"), + ) + if requirement_key not in supplied_requirements: + missing_requirements.add(f"{requirement_key[0]}=={requirement_key[1]}") + if missing_requirements: + raise DependencyWorkspaceError( + "Addon build requirements must be supplied by the support/runtime or tenant lock catalog: " + + ", ".join(sorted(missing_requirements)) + ) + + +def _resolve_shared_addons_repo_path(manifest: WorkspaceManifest) -> Path | None: + from .workspace import resolve_optional_repo_path_with_managed_checkout, resolve_workspace_path + + workspace_path = resolve_workspace_path(manifest) + return resolve_optional_repo_path_with_managed_checkout( + manifest.shared_addons_repo, + manifest=manifest, + managed_checkout_path=workspace_path / "sources" / "shared-addons", + ) + + +def _discover_project_inputs(*, tenant_repo_path: Path, shared_addons_repo_path: Path | None) -> tuple[_ProjectInput, ...]: + projects: list[_ProjectInput] = [] + tenant_addons_root = tenant_repo_path / "addons" + if tenant_addons_root.is_dir(): + for pyproject_path in _discover_pyproject_paths(tenant_addons_root): + if shared_addons_repo_path is not None and pyproject_path.is_relative_to(tenant_addons_root / "shared"): + continue + projects.append( + _ProjectInput( + owner="tenant", + source_repo_path=tenant_repo_path, + source_pyproject_path=pyproject_path, + staged_pyproject_path=Path("addons") / pyproject_path.relative_to(tenant_addons_root), + ) + ) + if shared_addons_repo_path is not None: + for pyproject_path in _discover_pyproject_paths(shared_addons_repo_path): + projects.append( + _ProjectInput( + owner="shared_addons", + source_repo_path=shared_addons_repo_path, + source_pyproject_path=pyproject_path, + staged_pyproject_path=Path("addons/shared") / pyproject_path.relative_to(shared_addons_repo_path), + ) + ) + return tuple(sorted(projects, key=lambda project: project.staged_pyproject_path.as_posix())) + + +def _discover_pyproject_paths(root: Path) -> tuple[Path, ...]: + return tuple( + path + for path in sorted(root.rglob("pyproject.toml")) + if not any(part.startswith(".") or part in _IGNORED_PATH_PARTS for part in path.relative_to(root).parts) + ) + + +def _reserved_dependency_namespace_findings( + *, + tenant_repo_path: Path, + shared_addons_repo_path: Path | None, +) -> tuple[str, ...]: + if shared_addons_repo_path is None: + return () + reserved_root = tenant_repo_path / "addons" / "shared" + if not reserved_root.is_dir(): + return () + return tuple( + "Tenant dependency metadata cannot use the reserved shared-addons namespace: " + + (Path("addons/shared") / path.relative_to(reserved_root)).as_posix() + for path in _discover_pyproject_paths(reserved_root) + ) + + +def _owned_requirements_findings(*, tenant_repo_path: Path, shared_addons_repo_path: Path | None) -> tuple[str, ...]: + findings: list[str] = [] + roots = (("tenant", tenant_repo_path / "addons"), ("shared_addons", shared_addons_repo_path)) + for owner, root in roots: + if root is None or not root.is_dir(): + continue + for requirements_path in sorted(root.rglob("requirements*.txt")): + relative_path = requirements_path.relative_to(root).as_posix() + if any(part.startswith(".") or part in _IGNORED_PATH_PARTS for part in requirements_path.relative_to(root).parts): + continue + findings.append(f"Owned {owner} requirements must move into pyproject.toml dependency metadata: {relative_path}") + return tuple(findings) + + +def _load_pyproject(path: Path) -> dict[str, Any]: + try: + payload = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as error: + raise DependencyWorkspaceError(f"Invalid dependency metadata {path.name}: {error}") from error + if not isinstance(payload, dict): + raise DependencyWorkspaceError(f"Dependency metadata must be a TOML table: {path.name}") + return payload + + +def _member_pyproject_findings(*, payload: dict[str, Any], display_path: str) -> tuple[str, ...]: + findings: list[str] = [] + config = _uv_config(payload) + if config.get("managed") is False: + findings.append(f"{display_path} cannot set tool.uv.managed = false") + if config.get("package") is not False: + findings.append(f"{display_path} must set tool.uv.package = false") + for validator in ( + lambda: _validate_build_system(payload=payload, display_path=display_path), + lambda: _validate_dependency_references(payload=payload, display_path=display_path, allow_workspace_sources=True), + ): + try: + validator() + except DependencyWorkspaceError as error: + findings.append(str(error)) + return tuple(findings) + + +def _validate_root_pyproject(*, payload: dict[str, Any]) -> tuple[str, ...]: + display_path = "pyproject.toml" + config = _uv_config(payload) + if config.get("managed") is False: + raise DependencyWorkspaceError("pyproject.toml cannot set tool.uv.managed = false") + if config.get("package") is not False: + raise DependencyWorkspaceError("pyproject.toml must set tool.uv.package = false") + if "build-system" in payload: + raise DependencyWorkspaceError("pyproject.toml dependency catalog cannot define build-system") + project = payload.get("project") + if isinstance(project, dict) and project.get("dynamic"): + raise DependencyWorkspaceError("pyproject.toml dependency catalog cannot use dynamic metadata") + _validate_dependency_references(payload=payload, display_path=display_path, allow_workspace_sources=True) + return _runtime_dependencies(payload=payload, display_path=display_path) + + +def _validate_build_system(*, payload: dict[str, Any], display_path: str) -> None: + build_system = payload.get("build-system") + if not isinstance(build_system, dict): + raise DependencyWorkspaceError(f"{display_path} requires an explicit build-system table") + if set(build_system) != {"requires", "build-backend"}: + raise DependencyWorkspaceError(f"{display_path} build-system may contain only requires and build-backend") + backend = build_system.get("build-backend") + requirements = build_system.get("requires") + if not isinstance(backend, str) or not backend.strip(): + raise DependencyWorkspaceError(f"{display_path} build-system requires a build-backend") + if not isinstance(requirements, list) or not requirements or not all(isinstance(value, str) for value in requirements): + raise DependencyWorkspaceError(f"{display_path} build-system requires a non-empty string list") + for requirement in requirements: + if _EXACT_BUILD_REQUIREMENT_PATTERN.fullmatch(requirement.strip()) is None: + raise DependencyWorkspaceError(f"{display_path} build requirements must use exact registry versions") + + +def _project_name(*, payload: dict[str, Any], display_path: str) -> str: + project = payload.get("project") + name = str(project.get("name", "")).strip() if isinstance(project, dict) else "" + normalized = re.sub(r"[-_.]+", "-", name).lower() + if not normalized or _PACKAGE_NAME_PATTERN.fullmatch(normalized) is None: + raise DependencyWorkspaceError(f"{display_path} requires a valid project.name") + if isinstance(project, dict) and project.get("dynamic"): + raise DependencyWorkspaceError(f"{display_path} cannot use dynamic project metadata") + return normalized + + +def _runtime_dependencies(*, payload: dict[str, Any], display_path: str) -> tuple[str, ...]: + project = payload.get("project") + if project is None: + return () + if not isinstance(project, dict): + raise DependencyWorkspaceError(f"{display_path} project must be a table") + dependencies = project.get("dependencies", []) + if not isinstance(dependencies, list) or not all(isinstance(value, str) for value in dependencies): + raise DependencyWorkspaceError(f"{display_path} project.dependencies must be a string array") + return tuple(value.strip() for value in dependencies if value.strip()) + + +def _validate_dependency_references(*, payload: dict[str, Any], display_path: str, allow_workspace_sources: bool) -> None: + for dependency in _dependency_strings(payload): + _validate_git_reference(value=dependency, display_path=display_path) + _validate_uv_sources(payload=payload, display_path=display_path, allow_workspace=allow_workspace_sources) + _validate_uv_resolution_controls(payload=payload, display_path=display_path) + + +def _dependency_strings(payload: dict[str, Any]) -> tuple[str, ...]: + values: list[str] = [] + build_system = payload.get("build-system") + if isinstance(build_system, dict): + requirements = build_system.get("requires", []) + if isinstance(requirements, list): + values.extend(str(value) for value in requirements) + project = payload.get("project") + if isinstance(project, dict): + dependencies = project.get("dependencies", []) + if isinstance(dependencies, list): + values.extend(str(value) for value in dependencies) + optional = project.get("optional-dependencies", {}) + if isinstance(optional, dict): + for group in optional.values(): + if isinstance(group, list): + values.extend(str(value) for value in group) + dependency_groups = payload.get("dependency-groups", {}) + if isinstance(dependency_groups, dict): + for group in dependency_groups.values(): + if isinstance(group, list): + values.extend(str(value) for value in group if isinstance(value, str)) + return tuple(values) + + +def _validate_git_reference(*, value: str, display_path: str) -> None: + stripped = value.strip() + if not stripped: + raise DependencyWorkspaceError(f"{display_path} contains a blank dependency declaration") + if _UNSAFE_DIRECT_REFERENCE_PATTERN.search(stripped): + raise DependencyWorkspaceError(f"{display_path} cannot use editable, local-path, or non-VCS direct references") + direct_reference = _DIRECT_REFERENCE_PATTERN.match(stripped) + if direct_reference is not None and not direct_reference.group("url").lower().startswith(("git+https://", "git+ssh://")): + raise DependencyWorkspaceError(f"{display_path} cannot use editable, local-path, or non-VCS direct references") + matches = tuple(_VCS_REFERENCE_PATTERN.finditer(stripped)) + for match in matches: + if _GIT_COMMIT_PATTERN.fullmatch(match.group(1)) is None: + raise DependencyWorkspaceError(f"{display_path} VCS dependencies must use exact lowercase git commits") + _validate_vcs_url(match.group(0).rsplit("@", 1)[0], display_path=display_path) + if "git+" in stripped.lower() and not matches: + raise DependencyWorkspaceError(f"{display_path} contains an invalid VCS dependency") + + +def _validate_vcs_url(value: str, *, display_path: str) -> None: + parsed = urlsplit(value.removeprefix("git+")) + if parsed.scheme not in {"https", "ssh"} or not parsed.hostname or not parsed.path.strip("/"): + raise DependencyWorkspaceError(f"{display_path} contains an invalid VCS repository") + if parsed.password is not None or parsed.query or parsed.fragment: + raise DependencyWorkspaceError(f"{display_path} VCS repository cannot contain credentials, queries, or fragments") + if parsed.scheme == "https" and parsed.username is not None: + raise DependencyWorkspaceError(f"{display_path} HTTPS VCS repository cannot contain userinfo") + + +def _validate_uv_sources(*, payload: dict[str, Any], display_path: str, allow_workspace: bool) -> None: + sources = _uv_config(payload).get("sources", {}) + if not isinstance(sources, dict): + raise DependencyWorkspaceError(f"{display_path} tool.uv.sources must be a table") + for package_name, raw_source in sources.items(): + package_label = _safe_package_label(package_name) + source_values = raw_source if isinstance(raw_source, list) else [raw_source] + for source in source_values: + if not isinstance(source, dict): + raise DependencyWorkspaceError(f"{display_path} source for {package_label} must be a table") + if source.get("workspace") is True: + if allow_workspace and set(source) == {"workspace"}: + continue + raise DependencyWorkspaceError(f"{display_path} has an invalid workspace source for {package_label}") + if "path" in source: + raise DependencyWorkspaceError(f"{display_path} cannot use local path source for {package_label}") + if "git" in source: + if "tag" in source or "branch" in source: + raise DependencyWorkspaceError(f"{display_path} VCS source for {package_label} cannot use tag or branch") + if set(source) - {"git", "rev", "subdirectory", "marker"}: + raise DependencyWorkspaceError(f"{display_path} has unsupported git source fields for {package_label}") + repository = str(source.get("git", "")) + revision = str(source.get("rev", "")) + _validate_git_reference(value=f"{package_label} @ git+{repository}@{revision}", display_path=display_path) + continue + raise DependencyWorkspaceError(f"{display_path} contains unsupported source for {package_label}") + + +def _validate_uv_resolution_controls(*, payload: dict[str, Any], display_path: str) -> None: + config = _uv_config(payload) + forbidden_keys = sorted(_FORBIDDEN_UV_SOURCE_KEYS.intersection(config)) + if forbidden_keys: + raise DependencyWorkspaceError(f"{display_path} cannot configure custom uv package sources: {', '.join(forbidden_keys)}") + for key in _UV_DEPENDENCY_LIST_KEYS: + if key not in config: + continue + values = config[key] + if not isinstance(values, list) or not all(isinstance(value, str) for value in values): + raise DependencyWorkspaceError(f"{display_path} tool.uv.{key} must be a string array") + for value in values: + _validate_git_reference(value=value, display_path=display_path) + + +def _safe_package_label(value: object) -> str: + normalized = re.sub(r"[-_.]+", "-", str(value).strip()).lower() + return normalized if _PACKAGE_NAME_PATTERN.fullmatch(normalized) is not None else "" + + +def _sanitized_dependency_names(dependencies: tuple[str, ...]) -> tuple[str, ...]: + names: set[str] = set() + for dependency in dependencies: + match = re.match(r"^\s*([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)", dependency) + if match is not None: + names.add(re.sub(r"[-_.]+", "-", match.group(1)).lower()) + return tuple(sorted(names)) + + +def _uv_config(payload: dict[str, Any]) -> dict[str, Any]: + tool = payload.get("tool") + if not isinstance(tool, dict): + return {} + uv = tool.get("uv") + return uv if isinstance(uv, dict) else {} + + +def _workspace_members(*, root: Path, payload: dict[str, Any]) -> set[Path]: + workspace = _uv_config(payload).get("workspace") + if not isinstance(workspace, dict): + raise DependencyWorkspaceError("pyproject.toml must define tool.uv.workspace") + raw_members = workspace.get("members", []) + raw_exclude = workspace.get("exclude", []) + if not isinstance(raw_members, list) or not raw_members or not all(isinstance(value, str) for value in raw_members): + raise DependencyWorkspaceError("pyproject.toml workspace members must be a non-empty string array") + if not isinstance(raw_exclude, list) or not all(isinstance(value, str) for value in raw_exclude): + raise DependencyWorkspaceError("pyproject.toml workspace exclude values must be a string array") + for pattern in [*raw_members, *raw_exclude]: + if pattern.startswith(("/", "~")) or "\\" in pattern or ".." in Path(pattern).parts: + raise DependencyWorkspaceError("pyproject.toml contains an unsafe workspace pattern") + excluded: set[Path] = set() + for pattern in raw_exclude: + excluded.update(path.relative_to(root) for path in root.glob(pattern) if path.is_dir()) + members: set[Path] = set() + for pattern in raw_members: + for path in root.glob(pattern): + relative_path = path.relative_to(root) + if path.is_dir() and (path / "pyproject.toml").is_file() and relative_path not in excluded: + members.add(relative_path) + return members + + +def _publish_input_findings( + *, + tenant_repo_path: Path, + root_pyproject_path: Path, + tenant_lock_path: Path, + project_inputs: tuple[_ProjectInput, ...], +) -> tuple[str, ...]: + inputs = [ + (tenant_repo_path, root_pyproject_path, "pyproject.toml"), + (tenant_repo_path, tenant_lock_path, "uv.lock"), + *( + (project_input.source_repo_path, project_input.source_pyproject_path, project_input.staged_pyproject_path.as_posix()) + for project_input in project_inputs + ), + ] + findings: list[str] = [] + for repo_path, source_path, display_path in inputs: + finding = _publish_input_finding(repo_path=repo_path, source_path=source_path, display_path=display_path) + if finding is not None: + findings.append(finding) + return tuple(findings) + + +def _publish_input_finding(*, repo_path: Path, source_path: Path, display_path: str) -> str | None: + normalized_repo_path = repo_path.resolve() + normalized_source_path = source_path.parent.resolve() / source_path.name + try: + relative_path = normalized_source_path.relative_to(normalized_repo_path) + except ValueError: + return f"Publish dependency input escapes its source repository: {display_path}" + current_path = normalized_repo_path + for part in relative_path.parts: + current_path = current_path / part + if current_path.is_symlink(): + return f"Publish dependency inputs cannot use symlinks: {display_path}" + if not source_path.is_file(): + return f"Publish dependency input must be a regular file: {display_path}" + try: + top_level_result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=normalized_repo_path, + capture_output=True, + text=True, + env=_git_command_env(), + ) + tracked_result = subprocess.run( + ["git", "ls-files", "--error-unmatch", "--", relative_path.as_posix()], + cwd=normalized_repo_path, + capture_output=True, + text=True, + env=_git_command_env(), + ) + except FileNotFoundError: + return f"Publish dependency input requires Git tracking: {display_path}" + if top_level_result.returncode != 0: + return f"Publish dependency input requires a Git worktree: {display_path}" + try: + top_level = Path(top_level_result.stdout.strip()).resolve() + except OSError: + return f"Publish dependency input requires a Git worktree: {display_path}" + if top_level != normalized_repo_path or tracked_result.returncode != 0: + return f"Publish dependency input must be tracked by its source repository: {display_path}" + return None + + +def _stage_dependency_metadata( + *, + root_pyproject_path: Path, + tenant_lock_path: Path, + project_inputs: tuple[_ProjectInput, ...], + staged_root: Path, + source_commits: dict[Path, str] | None = None, +) -> None: + _copy_regular_dependency_file( + repo_path=root_pyproject_path.parent, + source_commit=(source_commits or {}).get(root_pyproject_path.parent.resolve()), + source_path=root_pyproject_path, + destination_path=staged_root / "pyproject.toml", + display_path="pyproject.toml", + ) + _copy_regular_dependency_file( + repo_path=root_pyproject_path.parent, + source_commit=(source_commits or {}).get(root_pyproject_path.parent.resolve()), + source_path=tenant_lock_path, + destination_path=staged_root / "uv.lock", + display_path="uv.lock", + ) + for project_input in project_inputs: + destination = staged_root / project_input.staged_pyproject_path + destination.parent.mkdir(parents=True, exist_ok=True) + _copy_regular_dependency_file( + repo_path=project_input.source_repo_path, + source_commit=(source_commits or {}).get(project_input.source_repo_path.resolve()), + source_path=project_input.source_pyproject_path, + destination_path=destination, + display_path=project_input.staged_pyproject_path.as_posix(), + ) + + +def _copy_regular_dependency_file( + *, + repo_path: Path, + source_commit: str | None, + source_path: Path, + destination_path: Path, + display_path: str, +) -> None: + normalized_repo_path = repo_path.resolve() + normalized_source_path = source_path.parent.resolve() / source_path.name + if source_commit is not None: + try: + relative_path = normalized_source_path.relative_to(normalized_repo_path).as_posix() + except ValueError as error: + raise DependencyWorkspaceError(f"Dependency input escapes its source repository: {display_path}") from error + tree_result = subprocess.run( + ["git", "ls-tree", "-z", source_commit, "--", relative_path], + cwd=normalized_repo_path, + capture_output=True, + env=_git_command_env(), + ) + entries = tuple(entry for entry in tree_result.stdout.split(b"\0") if entry) + if tree_result.returncode != 0 or len(entries) != 1: + raise DependencyWorkspaceError(f"Dependency input is missing from source commit: {display_path}") + try: + raw_metadata, raw_entry_path = entries[0].split(b"\t", 1) + mode, entry_type, object_id = os.fsdecode(raw_metadata).split(" ", 2) + except ValueError as error: + raise DependencyWorkspaceError(f"Unable to parse committed dependency input: {display_path}") from error + if os.fsdecode(raw_entry_path) != relative_path or entry_type != "blob" or mode not in {"100644", "100755"}: + raise DependencyWorkspaceError(f"Dependency input must be a committed regular file: {display_path}") + blob_result = subprocess.run( + ["git", "cat-file", "blob", object_id], + cwd=normalized_repo_path, + capture_output=True, + env=_git_command_env(), + ) + if blob_result.returncode != 0: + raise DependencyWorkspaceError(f"Unable to materialize committed dependency input: {display_path}") + destination_path.parent.mkdir(parents=True, exist_ok=True) + destination_path.write_bytes(blob_result.stdout) + destination_path.chmod(0o755 if mode == "100755" else 0o644) + return + if source_path.is_symlink() or not source_path.is_file(): + raise DependencyWorkspaceError(f"Dependency staging requires a regular file: {display_path}") + destination_path.parent.mkdir(parents=True, exist_ok=True) + destination_path.write_bytes(source_path.read_bytes()) + + +def _git_head_commit(repo_path: Path) -> str: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo_path, + capture_output=True, + text=True, + env=_git_command_env(), + ) + commit = result.stdout.strip() + if result.returncode != 0 or _GIT_COMMIT_PATTERN.fullmatch(commit) is None: + raise DependencyWorkspaceError("Dependency workspace staging requires an exact Git commit") + return commit + + +def _uv_lock_is_current(staged_root: Path) -> bool: + environment = { + key: value + for key, value in os.environ.items() + if not key.startswith(("PIP_", "UV_")) and key not in {"PYTHONPATH", "VIRTUAL_ENV"} + } + environment["UV_NO_PROGRESS"] = "1" + try: + result = subprocess.run( + ["uv", "lock", "--check", "--offline", "--no-config", "--project", str(staged_root)], + cwd=staged_root, + capture_output=True, + text=True, + env=environment, + ) + except FileNotFoundError as error: + raise DependencyWorkspaceError("uv is required for dependency workspace checks") from error + return result.returncode == 0 + + +def _git_command_env() -> dict[str, str]: + environment = dict(os.environ) + repository_context_keys = { + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CEILING_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_SYSTEM", + "GIT_DIR", + "GIT_INDEX_FILE", + "GIT_NAMESPACE", + "GIT_OBJECT_DIRECTORY", + "GIT_PREFIX", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", + } + for environment_key in tuple(environment): + if environment_key in repository_context_keys or environment_key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): + environment.pop(environment_key, None) + environment["GIT_CONFIG_GLOBAL"] = os.devnull + environment["GIT_CONFIG_NOSYSTEM"] = "1" + environment["GIT_CONFIG_SYSTEM"] = os.devnull + environment["GIT_NO_REPLACE_OBJECTS"] = "1" + environment["GIT_OPTIONAL_LOCKS"] = "0" + return environment + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source_file: + for chunk in iter(lambda: source_file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index c9a97ba..37f8ada 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import hashlib import json import os import re @@ -13,7 +14,7 @@ import tomllib from collections.abc import Callable, Iterable from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import TextIO from .artifact_inputs import ( @@ -22,6 +23,18 @@ effective_artifact_input_sources, load_artifact_inputs_definition, ) +from .artifact_provenance import ( + ArtifactProvenanceError, + aggregate_dependency_evidence, + normalize_git_commit, + normalize_repository_identity, +) +from .dependency_workspace import ( + DependencyWorkspaceError, + require_staged_build_requirements_supplied, + require_staged_dependency_workspace_current, + stage_publishable_dependency_workspace, +) from .ide_support import write_pycharm_odoo_conf from .manifest import WorkspaceManifest from .runtime_environment import RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR, sanitized_subprocess_environment @@ -30,6 +43,7 @@ ScalarMap = dict[str, ScalarValue] DEFAULT_ARTIFACT_IMAGE_PLATFORMS = ("linux/amd64", "linux/arm64") GIT_SHA_PATTERN = re.compile(r"[0-9a-fA-F]{7,40}") +DEPENDENCY_SOURCE_REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") ENVIRONMENT_VARIABLE_NAME_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") ARTIFACT_SOURCE_ENV_KEYS = ("ODOO_ADDON_REPOSITORIES", "OPENUPGRADE_ADDON_REPOSITORY") SOURCE_GITHUB_TOKEN_ENV_KEYS = ("ODOO_DEVKIT_SOURCE_GITHUB_TOKEN", "ODOO_SOURCE_GITHUB_TOKEN") @@ -39,9 +53,11 @@ "ODOO_BASE_DEVTOOLS_IMAGE", "ODOO_ADDON_REPOSITORIES", "OPENUPGRADE_ADDON_REPOSITORY", - "OPENUPGRADELIB_INSTALL_SPEC", "ODOO_PYTHON_SYNC_SKIP_ADDONS", ) +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" 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" @@ -334,6 +350,44 @@ class RuntimeArtifactPublishResult: output_file: Path | None +@dataclass(frozen=True) +class GitSourceSnapshot: + label: str + repo_path: Path + repository: str + commit: str + + +@dataclass(frozen=True) +class BaseImageProvenance: + role: str + repository: str + digest: str + digest_reference: str + tags: tuple[str, ...] + source_repository: str + source_ref: str + + def to_dict(self) -> dict[str, object]: + return { + "role": self.role, + "image": { + "repository": self.repository, + "digest": self.digest, + "tags": list(self.tags), + }, + "source_repository": self.source_repository, + "source_ref": self.source_ref, + } + + +@dataclass(frozen=True) +class StagedArtifactContext: + file_hashes: dict[str, str] + support_lock_sha256: str + tenant_lock_sha256: str + + class RuntimeCommandError(ValueError): pass @@ -491,32 +545,79 @@ def publish_runtime_artifact( environment_values=runtime_values, source_description="Resolved publish runtime environment", ) + source_github_token = resolve_source_github_token(runtime_values) + tenant_source, runtime_source, shared_addons_source = preflight_artifact_git_sources( + manifest=manifest, + runtime_repo_path=runtime_repo_path, + github_token=source_github_token, + ) ensure_registry_auth_for_base_images(runtime_values) ensure_registry_auth_for_image_push( environment_values=runtime_values, image_repository=normalized_image_repository, ) + base_runtime_image, base_devtools_image = resolve_base_images_for_build(runtime_values) + runtime_base_provenance = resolve_base_image_provenance( + image_reference=base_runtime_image, + role="runtime", + required_platforms=normalized_platforms, + ) + devtools_base_provenance = resolve_base_image_provenance( + image_reference=base_devtools_image, + role="devtools", + required_platforms=normalized_platforms, + ) + runtime_values = { + **runtime_values, + "ODOO_BASE_RUNTIME_IMAGE": runtime_base_provenance.digest_reference, + "ODOO_BASE_DEVTOOLS_IMAGE": devtools_base_provenance.digest_reference, + } + build_environment = command_execution_env() github_token = resolve_github_token_for_build(runtime_values) if github_token is not None: build_environment["GITHUB_TOKEN"] = github_token - artifact_image_digest: str | None = None + artifact_source_entries = collect_artifact_source_entries( + runtime_values=runtime_values, + shared_addons_source=shared_addons_source, + ) + dependency_provenance: dict[str, object] with tempfile.TemporaryDirectory(prefix="odoo-artifact-") as temporary_directory_name: staged_context_root = Path(temporary_directory_name) build_metadata_file = staged_context_root / "build-metadata.json" - stage_artifact_build_context( + staged_context = stage_artifact_build_context( manifest=manifest, runtime_repo_path=runtime_repo_path, staged_context_root=staged_context_root, + tenant_source=tenant_source, + runtime_source=runtime_source, + shared_addons_source=shared_addons_source, ) + require_artifact_git_sources_unchanged((tenant_source, runtime_source, shared_addons_source)) + require_staged_artifact_context_unchanged(staged_context_root=staged_context_root, staged_context=staged_context) + try: + require_staged_build_requirements_supplied( + support_root=staged_context_root / "runtime", + tenant_root=staged_context_root / "project", + ) + require_staged_dependency_workspace_current( + staged_root=staged_context_root / "runtime", + label="support/runtime", + ) + require_staged_dependency_workspace_current( + staged_root=staged_context_root / "project", + label="tenant", + ) + except DependencyWorkspaceError as error: + raise RuntimeCommandError(str(error)) from error build_command = [ "docker", "buildx", "build", "--file", - str(staged_context_root / "docker" / "Dockerfile"), + str(staged_context_root / "docker" / "artifact.Dockerfile"), "--target", "production", "--platform", @@ -531,7 +632,7 @@ def publish_runtime_artifact( build_command.extend(["--secret", "id=github_token,env=GITHUB_TOKEN"]) if no_cache: build_command.append("--no-cache") - for build_argument_name in ARTIFACT_PUBLISH_RUNTIME_ENV_KEYS: + for build_argument_name in ARTIFACT_PUBLISH_BUILD_ARG_KEYS: build_command.extend(["--build-arg", f"{build_argument_name}={runtime_values.get(build_argument_name, '')}"]) build_command.append(str(staged_context_root)) run_command( @@ -540,37 +641,57 @@ def publish_runtime_artifact( environment_overrides=build_environment, ) artifact_image_digest = resolve_buildx_metadata_image_digest(build_metadata_file) - tenant_repo_path = manifest.tenant_repo.resolve_path(manifest_directory=manifest.manifest_directory) - if tenant_repo_path is None or not tenant_repo_path.exists(): - raise RuntimeCommandError("Tenant repo path must exist before publishing an artifact.") - tenant_commit = require_clean_git_commit( - repo_path=tenant_repo_path.resolve(), - label=manifest.tenant_repo.name, - ) - runtime_commit = require_clean_git_commit( - repo_path=runtime_repo_path.resolve(), - label=(manifest.runtime_repo.name if manifest.runtime_repo is not None else runtime_repo_path.name), - ) - artifact_source_entries = collect_artifact_source_entries( - manifest=manifest, - runtime_values=runtime_values, - ) - base_runtime_image, _ = resolve_base_images_for_build(runtime_values) + require_staged_artifact_context_unchanged(staged_context_root=staged_context_root, staged_context=staged_context) + evidence_root = staged_context_root / "evidence" + extract_published_dependency_evidence( + staged_context_root=staged_context_root, + image_reference=f"{normalized_image_repository}@{artifact_image_digest}", + platforms=normalized_platforms, + evidence_root=evidence_root, + build_environment=build_environment, + ) + expected_uv_locks = ( + { + "scope": "support_runtime", + "source_repository": runtime_source.repository, + "source_ref": runtime_source.commit, + "path": "docker/runtime-python/uv.lock", + "sha256": staged_context.support_lock_sha256, + }, + { + "scope": "tenant", + "source_repository": tenant_source.repository, + "source_ref": tenant_source.commit, + "path": "uv.lock", + "sha256": staged_context.tenant_lock_sha256, + }, + ) + try: + dependency_provenance = aggregate_dependency_evidence( + evidence_root=evidence_root, + expected_platforms=normalized_platforms, + expected_uv_locks=expected_uv_locks, + ) + except ArtifactProvenanceError as error: + raise RuntimeCommandError(str(error)) from error + require_staged_artifact_context_unchanged(staged_context_root=staged_context_root, staged_context=staged_context) + manifest_payload = build_runtime_artifact_manifest_payload( context_name=runtime_context.selection.context_name, - source_commit=tenant_commit, + source_commit=tenant_source.commit, runtime_repo_name=(manifest.runtime_repo.name if manifest.runtime_repo is not None else runtime_repo_path.name), - runtime_repo_commit=runtime_commit, + runtime_repo_commit=runtime_source.commit, + runtime_repo_repository=runtime_source.repository, artifact_source_entries=artifact_source_entries, source_selector_entries=artifact_source_selectors, odoo_install_modules=runtime_context.selection.effective_install_modules, - openupgrade_addon_repository=runtime_values.get("OPENUPGRADE_ADDON_REPOSITORY", ""), - openupgradelib_install_spec=runtime_values.get("OPENUPGRADELIB_INSTALL_SPEC", ""), addon_skip_flags=parse_csv_values(runtime_values.get("ODOO_PYTHON_SYNC_SKIP_ADDONS", "")), image_repository=normalized_image_repository, image_tag=normalized_image_tag, image_digest=artifact_image_digest, - enterprise_base_digest=resolve_image_digest(base_runtime_image), + runtime_base_provenance=runtime_base_provenance, + devtools_base_provenance=devtools_base_provenance, + dependency_provenance=dependency_provenance, odoo_version=runtime_values.get("ODOO_VERSION", ""), ) @@ -592,6 +713,10 @@ def validate_artifact_publish_runtime_values(runtime_values: dict[str, str]) -> raise RuntimeCommandError( f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment must include ODOO_VERSION for artifact publish." ) + if parse_csv_values(runtime_values.get("ODOO_PYTHON_SYNC_SKIP_ADDONS", "")): + raise RuntimeCommandError( + "Schema-v2 artifact publish does not support ODOO_PYTHON_SYNC_SKIP_ADDONS; remove skipped projects from the tenant workspace." + ) def down_runtime(*, manifest: WorkspaceManifest, runtime_repo_path: Path, volumes: bool) -> None: @@ -1822,7 +1947,9 @@ def build_runtime_env_values( runtime_values[environment_key] = source_environment[environment_key] for runtime_key, runtime_value in runtime_selection.effective_runtime_env.items(): runtime_values[runtime_key] = runtime_value - if explicit_runtime_environment_payload_is_configured() and runtime_selection.instance_name != "local": + if explicit_runtime_environment_payload_is_configured() and ( + runtime_selection.instance_name != "local" or not include_selection_sources + ): for runtime_key in ARTIFACT_PUBLISH_RUNTIME_ENV_KEYS: runtime_values[runtime_key] = source_environment.get(runtime_key, "") apply_typed_odoo_instance_override_payload( @@ -2040,12 +2167,94 @@ def resolve_manifest_local_addons_mount_paths(*, manifest: WorkspaceManifest) -> ) +def write_dependency_source_marker( + destination_root: Path, + *, + source: GitSourceSnapshot, + lock_path: str | None = None, +) -> None: + if DEPENDENCY_SOURCE_REPOSITORY_PATTERN.fullmatch(source.repository) is None: + raise RuntimeCommandError("Artifact dependency source markers currently require owner/repository GitHub identities.") + payload = {"repository": source.repository, "ref": source.commit} + if lock_path is not None: + normalized_lock_path = PurePosixPath(lock_path) + if ( + normalized_lock_path.is_absolute() + or normalized_lock_path.name != "uv.lock" + or any(part in {"", ".", ".."} for part in normalized_lock_path.parts) + ): + raise RuntimeCommandError("Artifact dependency source marker lock_path must be a safe repository-relative uv.lock path.") + payload["lock_path"] = normalized_lock_path.as_posix() + destination_root.mkdir(parents=True, exist_ok=True) + (destination_root / DEPENDENCY_SOURCE_MARKER_FILE).write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def require_no_embedded_dependency_source_markers(*, roots: tuple[tuple[Path, str], ...]) -> None: + embedded_markers: list[str] = [] + for root, label in roots: + if not root.exists(): + continue + for marker_path in sorted(root.rglob(DEPENDENCY_SOURCE_MARKER_FILE)): + embedded_markers.append(f"{label}:{marker_path.relative_to(root).as_posix()}") + if embedded_markers: + raise RuntimeCommandError( + "Artifact source repos cannot provide reserved dependency source markers; " + f"devkit generates them from verified Git snapshots: {embedded_markers}" + ) + + +def snapshot_staged_artifact_files(staged_context_root: Path) -> dict[str, str]: + file_hashes: dict[str, str] = {} + for relative_root in ("docker", "platform", "runtime", "project", "addons"): + source_root = staged_context_root / relative_root + if not source_root.exists(): + continue + for file_path in sorted(path for path in source_root.rglob("*") if path.is_file()): + relative_path = file_path.relative_to(staged_context_root).as_posix() + file_hashes[relative_path] = sha256_file(file_path) + return file_hashes + + +def require_staged_artifact_context_unchanged( + *, + staged_context_root: Path, + staged_context: StagedArtifactContext, +) -> None: + current_hashes = snapshot_staged_artifact_files(staged_context_root) + if current_hashes == staged_context.file_hashes: + return + changed_paths = sorted( + path + for path in set(current_hashes) | set(staged_context.file_hashes) + if current_hashes.get(path) != staged_context.file_hashes.get(path) + ) + raise RuntimeCommandError(f"Artifact staged inputs changed before provenance was finalized: {changed_paths}") + + +def sha256_file(path: Path) -> str: + try: + source_file = path.open("rb") + except OSError as error: + raise RuntimeCommandError(f"Unable to hash staged artifact input: {path.name}") from error + digest = hashlib.sha256() + with source_file: + for chunk in iter(lambda: source_file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + def stage_artifact_build_context( *, manifest: WorkspaceManifest, runtime_repo_path: Path, staged_context_root: Path, -) -> None: + tenant_source: GitSourceSnapshot, + runtime_source: GitSourceSnapshot, + shared_addons_source: GitSourceSnapshot | None, +) -> StagedArtifactContext: tenant_repo_path = manifest.tenant_repo.resolve_path(manifest_directory=manifest.manifest_directory) if tenant_repo_path is None or not tenant_repo_path.exists(): raise RuntimeCommandError("Tenant repo path must exist before staging an artifact build context.") @@ -2053,59 +2262,202 @@ def stage_artifact_build_context( staged_context_root.mkdir(parents=True, exist_ok=True) copy_required_path( + repo_path=runtime_source.repo_path, + source_commit=runtime_source.commit, source_path=runtime_repo_path / "docker", destination_path=staged_context_root / "docker", label="runtime docker directory", ) copy_required_path( + repo_path=runtime_source.repo_path, + source_commit=runtime_source.commit, source_path=runtime_repo_path / "platform" / "config", destination_path=staged_context_root / "platform" / "config", label="runtime platform config directory", ) copy_required_path( - source_path=runtime_repo_path / "pyproject.toml", - destination_path=staged_context_root / "pyproject.toml", - label="runtime pyproject.toml", - ) - copy_required_path( - source_path=runtime_repo_path / "uv.lock", - destination_path=staged_context_root / "uv.lock", - label="runtime uv.lock", + repo_path=runtime_source.repo_path, + source_commit=runtime_source.commit, + source_path=runtime_repo_path / "docker" / "runtime-python", + destination_path=staged_context_root / "runtime", + label="runtime Python dependency catalog", ) + try: + stage_publishable_dependency_workspace( + manifest=manifest, + destination_root=staged_context_root / "project", + tenant_commit=tenant_source.commit, + shared_addons_commit=shared_addons_source.commit if shared_addons_source is not None else None, + ) + except DependencyWorkspaceError as error: + raise RuntimeCommandError(str(error)) from error + (staged_context_root / "runtime" / DEPENDENCY_LAYOUT_MARKER_FILE).write_text("2\n", encoding="utf-8") staged_addons_root = staged_context_root / "addons" tenant_addons_root = tenant_repo_path / "addons" - if not tenant_addons_root.exists(): - raise RuntimeCommandError(f"Tenant addons path does not exist: {tenant_addons_root}") - staged_addons_root.mkdir(parents=True, exist_ok=True) - for child_path in sorted(tenant_addons_root.iterdir()): - if shared_addons_repo_path is not None and child_path.name == "shared": - continue - copy_required_path( - source_path=child_path, - destination_path=staged_addons_root / child_path.name, - label=f"tenant addon path {child_path}", - ) - + copy_required_path( + repo_path=tenant_source.repo_path, + source_commit=tenant_source.commit, + source_path=tenant_addons_root, + destination_path=staged_addons_root, + label="tenant addons directory", + ) + staged_tenant_shared_path = staged_addons_root / "shared" + if staged_tenant_shared_path.is_dir(): + shutil.rmtree(staged_tenant_shared_path) + elif staged_tenant_shared_path.exists(): + staged_tenant_shared_path.unlink() + + staged_shared_addons_root: Path | None = None if shared_addons_repo_path is not None: + if shared_addons_source is None or shared_addons_source.repo_path != shared_addons_repo_path.resolve(): + raise RuntimeCommandError("Shared addon source snapshot does not match the staged shared addon repository.") staged_shared_addons_root = staged_addons_root / "shared" - staged_shared_addons_root.mkdir(parents=True, exist_ok=True) - for child_path in sorted(shared_addons_repo_path.iterdir()): - copy_required_path( - source_path=child_path, - destination_path=staged_shared_addons_root / child_path.name, - label=f"shared addon path {child_path}", - ) + copy_required_path( + repo_path=shared_addons_source.repo_path, + source_commit=shared_addons_source.commit, + source_path=shared_addons_repo_path, + destination_path=staged_shared_addons_root, + label="shared addons repository", + ) + require_no_embedded_dependency_source_markers( + roots=( + (staged_context_root / "runtime", "support/runtime"), + (staged_context_root / "project", "tenant dependency workspace"), + (staged_addons_root, "owned addons"), + ) + ) + write_dependency_source_marker( + staged_context_root / "runtime", + source=runtime_source, + lock_path="docker/runtime-python/uv.lock", + ) + write_dependency_source_marker( + staged_context_root / "project", + source=tenant_source, + lock_path="uv.lock", + ) + if staged_shared_addons_root is not None and shared_addons_source is not None: + write_dependency_source_marker(staged_shared_addons_root, source=shared_addons_source) + support_lock_path = staged_context_root / "runtime" / "uv.lock" + tenant_lock_path = staged_context_root / "project" / "uv.lock" + return StagedArtifactContext( + file_hashes=snapshot_staged_artifact_files(staged_context_root), + support_lock_sha256=sha256_file(support_lock_path), + tenant_lock_sha256=sha256_file(tenant_lock_path), + ) -def copy_required_path(*, source_path: Path, destination_path: Path, label: str) -> None: - if not source_path.exists(): - raise RuntimeCommandError(f"Missing required {label}: {source_path}") - if source_path.is_dir(): - shutil.copytree(source_path, destination_path, dirs_exist_ok=True) - return - destination_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source_path, destination_path) +def copy_required_path( + *, + repo_path: Path, + source_commit: str, + source_path: Path, + destination_path: Path, + label: str, +) -> None: + normalized_repo_path = require_git_repository_root(repo_path=repo_path, label=label) + lexical_repo_path = repo_path.absolute() + lexical_source_path = source_path.absolute() + try: + lexical_relative_path = lexical_source_path.relative_to(lexical_repo_path) + except ValueError: + lexical_relative_path = None + if lexical_relative_path is not None: + current_path = lexical_repo_path + for part in lexical_relative_path.parts: + current_path = current_path / part + if current_path.is_symlink(): + raise RuntimeCommandError(f"Required {label} cannot traverse source-repository symlinks.") + elif source_path.is_symlink(): + raise RuntimeCommandError(f"Required {label} cannot use a source-repository symlink.") + normalized_source_path = source_path.resolve() + try: + relative_source_path = normalized_source_path.relative_to(normalized_repo_path) + except ValueError as error: + raise RuntimeCommandError(f"Required {label} escapes its source repository.") from error + if relative_source_path == Path("."): + object_type = "tree" + else: + object_spec = f"{source_commit}:{relative_source_path.as_posix()}" + object_type_result = subprocess.run( + ["git", "cat-file", "-t", object_spec], + cwd=normalized_repo_path, + capture_output=True, + text=True, + env=artifact_git_command_env(), + ) + object_type = object_type_result.stdout.strip() + if object_type_result.returncode != 0 or object_type not in {"blob", "tree"}: + raise RuntimeCommandError(f"Missing required {label} in source commit {source_commit}.") + + tree_command = ["git", "ls-tree", "-r", "-z", "--full-tree", source_commit] + if relative_source_path != Path("."): + tree_command.extend(["--", relative_source_path.as_posix()]) + tree_result = subprocess.run( + tree_command, + cwd=normalized_repo_path, + capture_output=True, + env=artifact_git_command_env(), + ) + if tree_result.returncode != 0: + raise RuntimeCommandError(f"Unable to inspect committed files for required {label}.") + entries = tuple(entry for entry in tree_result.stdout.split(b"\0") if entry) + if not entries: + raise RuntimeCommandError(f"Required {label} contains no committed files.") + + for entry in entries: + try: + raw_metadata, raw_entry_path = entry.split(b"\t", 1) + mode, entry_type, object_id = os.fsdecode(raw_metadata).split(" ", 2) + entry_path = Path(os.fsdecode(raw_entry_path)) + except ValueError as error: + raise RuntimeCommandError(f"Unable to parse committed files for required {label}.") from error + if entry_type != "blob" or mode not in {"100644", "100755"}: + raise RuntimeCommandError(f"Artifact staging accepts only committed regular files for {label}: {entry_path.as_posix()}") + if object_type == "blob": + if entry_path != relative_source_path: + raise RuntimeCommandError(f"Committed file lookup changed for required {label}.") + target_path = destination_path + else: + try: + relative_entry_path = ( + entry_path if relative_source_path == Path(".") else entry_path.relative_to(relative_source_path) + ) + except ValueError as error: + raise RuntimeCommandError(f"Committed file escapes required {label}: {entry_path.as_posix()}") from error + target_path = destination_path / relative_entry_path + blob_result = subprocess.run( + ["git", "cat-file", "blob", object_id], + cwd=normalized_repo_path, + capture_output=True, + env=artifact_git_command_env(), + ) + if blob_result.returncode != 0: + raise RuntimeCommandError(f"Unable to materialize committed file for required {label}: {entry_path.as_posix()}") + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(blob_result.stdout) + target_path.chmod(0o755 if mode == "100755" else 0o644) + + +def require_git_repository_root(*, repo_path: Path, label: str) -> Path: + normalized_repo_path = repo_path.resolve() + top_level_result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=normalized_repo_path, + capture_output=True, + text=True, + env=artifact_git_command_env(), + ) + if top_level_result.returncode != 0 or not top_level_result.stdout.strip(): + raise RuntimeCommandError(f"Artifact source for {label} must be a Git worktree root: {repo_path}") + try: + top_level_path = Path(top_level_result.stdout.strip()).resolve() + except OSError as error: + raise RuntimeCommandError(f"Unable to resolve Git worktree root for {label}: {repo_path}") from error + if top_level_path != normalized_repo_path: + raise RuntimeCommandError(f"Artifact source for {label} must use the Git worktree root, not a nested path: {repo_path}") + return normalized_repo_path def resolve_manifest_shared_addons_repo_path(*, manifest: WorkspaceManifest) -> Path | None: @@ -2121,19 +2473,15 @@ def resolve_manifest_shared_addons_repo_path(*, manifest: WorkspaceManifest) -> def collect_artifact_source_entries( *, - manifest: WorkspaceManifest, runtime_values: dict[str, str], + shared_addons_source: GitSourceSnapshot | None, ) -> tuple[dict[str, str], ...]: source_entries: list[dict[str, str]] = [] - shared_addons_repo_path = resolve_manifest_shared_addons_repo_path(manifest=manifest) - if manifest.shared_addons_repo is not None and shared_addons_repo_path is not None: + if shared_addons_source is not None: source_entries.append( { - "repository": manifest.shared_addons_repo.url or manifest.shared_addons_repo.name, - "ref": require_clean_git_commit( - repo_path=shared_addons_repo_path.resolve(), - label=manifest.shared_addons_repo.name, - ), + "repository": shared_addons_source.repository, + "ref": shared_addons_source.commit, } ) @@ -2141,11 +2489,16 @@ def collect_artifact_source_entries( for env_key in ARTIFACT_SOURCE_ENV_KEYS: raw_value = runtime_values.get(env_key, "") for repository, ref in parse_artifact_source_repository_entries(raw_value, require_exact_shas=True): - repository_key = (repository, ref) + try: + normalized_repository = normalize_repository_identity(repository) + normalized_ref = normalize_git_commit(ref) + except ArtifactProvenanceError as error: + raise RuntimeCommandError(str(error)) from error + repository_key = (normalized_repository, normalized_ref) if repository_key in seen_repository_refs: continue seen_repository_refs.add(repository_key) - source_entries.append({"repository": repository, "ref": ref}) + source_entries.append({"repository": normalized_repository, "ref": normalized_ref}) return tuple(source_entries) @@ -2224,7 +2577,7 @@ def resolve_source_repository_ref_to_git_sha(*, repository: str, ref: str, githu if GIT_SHA_PATTERN.fullmatch(normalized_ref): return normalized_ref remote_url = resolve_source_repository_remote_url(normalized_repository) - execution_env = command_execution_env() + execution_env = artifact_git_command_env() normalized_token = clean_optional_value(github_token) if normalized_token and remote_url.startswith("https://github.com/"): execution_env.update( @@ -2278,31 +2631,172 @@ def resolve_source_repository_remote_url(repository: str) -> str: return normalized_repository +def preflight_artifact_git_sources( + *, + manifest: WorkspaceManifest, + runtime_repo_path: Path, + github_token: str | None = None, +) -> tuple[GitSourceSnapshot, GitSourceSnapshot, GitSourceSnapshot | None]: + tenant_repo_path = manifest.tenant_repo.resolve_path(manifest_directory=manifest.manifest_directory) + if tenant_repo_path is None or not tenant_repo_path.is_dir(): + raise RuntimeCommandError("Tenant repo path must exist before publishing an artifact.") + tenant_source = require_clean_git_source( + repo_path=tenant_repo_path.resolve(), + label=manifest.tenant_repo.name, + github_token=github_token, + ) + runtime_source = require_clean_git_source( + repo_path=runtime_repo_path.resolve(), + label=(manifest.runtime_repo.name if manifest.runtime_repo is not None else runtime_repo_path.name), + github_token=github_token, + ) + shared_addons_source = None + shared_addons_repo_path = resolve_manifest_shared_addons_repo_path(manifest=manifest) + if manifest.shared_addons_repo is not None: + if shared_addons_repo_path is None or not shared_addons_repo_path.is_dir(): + raise RuntimeCommandError("Shared addons repo must exist before publishing an artifact.") + shared_addons_source = require_clean_git_source( + repo_path=shared_addons_repo_path.resolve(), + label=manifest.shared_addons_repo.name, + github_token=github_token, + ) + return tenant_source, runtime_source, shared_addons_source + + +def require_clean_git_source(*, repo_path: Path, label: str, github_token: str | None = None) -> GitSourceSnapshot: + commit = require_clean_git_commit(repo_path=repo_path, label=label) + remote_result = subprocess.run( + ["git", "config", "--local", "--get", "remote.origin.url"], + cwd=repo_path, + capture_output=True, + text=True, + env=artifact_git_command_env(), + ) + if remote_result.returncode != 0 or not remote_result.stdout.strip(): + raise RuntimeCommandError(f"Artifact publish requires an origin repository identity for {label}: {repo_path}") + try: + repository = normalize_repository_identity(remote_result.stdout.strip()) + except ArtifactProvenanceError as error: + raise RuntimeCommandError(f"Artifact publish requires a safe origin repository identity for {label}.") from error + if DEPENDENCY_SOURCE_REPOSITORY_PATTERN.fullmatch(repository) is None: + raise RuntimeCommandError(f"Artifact publish currently requires an owner/repository GitHub origin for {label}: {repository}") + require_remote_source_commit( + repository=repository, + commit=commit, + label=label, + github_token=github_token, + ) + return GitSourceSnapshot(label=label, repo_path=repo_path, repository=repository, commit=commit) + + +def require_remote_source_commit( + *, + repository: str, + commit: str, + label: str, + github_token: str | None = None, +) -> None: + remote_url = f"https://github.com/{repository}.git" + execution_env = artifact_git_command_env() + normalized_token = clean_optional_value(github_token) + if normalized_token: + execution_env.update( + { + "ODOO_DEVKIT_GITHUB_TOKEN": normalized_token, + "GIT_CONFIG_COUNT": "2", + "GIT_CONFIG_KEY_0": "credential.https://github.com.helper", + "GIT_CONFIG_VALUE_0": "!f() { echo username=x-access-token; echo password=$ODOO_DEVKIT_GITHUB_TOKEN; }; f", + "GIT_CONFIG_KEY_1": "credential.useHttpPath", + "GIT_CONFIG_VALUE_1": "true", + } + ) + remote_result = subprocess.run( + ["git", "ls-remote", remote_url], + cwd=Path(os.sep), + capture_output=True, + text=True, + env=execution_env, + ) + if remote_result.returncode != 0: + details = clean_optional_value(remote_result.stderr) or clean_optional_value(remote_result.stdout) + raise RuntimeCommandError( + f"Artifact publish could not verify the origin repository for {label}: {repository}." + + (f"\nGit reported: {details}" if details else "") + ) + advertised_commits = { + line.split("\t", 1)[0].strip() + for line in remote_result.stdout.splitlines() + if "\t" in line and GIT_SHA_PATTERN.fullmatch(line.split("\t", 1)[0].strip()) + } + if commit not in advertised_commits: + raise RuntimeCommandError(f"Artifact publish requires {label} commit {commit} to be advertised by a ref in {repository}.") + + +def require_artifact_git_sources_unchanged(sources: tuple[GitSourceSnapshot | None, ...]) -> None: + for source in sources: + if source is None: + continue + current_commit = require_clean_git_commit(repo_path=source.repo_path, label=source.label) + if current_commit != source.commit: + raise RuntimeCommandError( + f"Artifact publish source changed during staging for {source.label}: expected {source.commit}, got {current_commit}" + ) + + def require_clean_git_commit(*, repo_path: Path, label: str) -> str: + normalized_repo_path = require_git_repository_root(repo_path=repo_path, label=label) head_result = subprocess.run( ["git", "rev-parse", "HEAD"], - cwd=repo_path, + cwd=normalized_repo_path, capture_output=True, text=True, - env=command_execution_env(), + env=artifact_git_command_env(), ) if head_result.returncode != 0: raise RuntimeCommandError(f"Unable to resolve git commit for {label}: {repo_path}") head_commit = head_result.stdout.strip() - if not re.fullmatch(r"[0-9a-fA-F]{40}", head_commit): + if not re.fullmatch(r"[0-9a-f]{40}", head_commit): raise RuntimeCommandError(f"Unable to resolve a full git commit for {label}: {repo_path}") dirty_result = subprocess.run( - ["git", "status", "--porcelain"], - cwd=repo_path, + ["git", "status", "--porcelain", "--untracked-files=all"], + cwd=normalized_repo_path, capture_output=True, text=True, - env=command_execution_env(), + env=artifact_git_command_env(), ) if dirty_result.returncode != 0: raise RuntimeCommandError(f"Unable to determine git status for {label}: {repo_path}") if dirty_result.stdout.strip(): raise RuntimeCommandError(f"Artifact publish requires a clean git worktree for {label}: {repo_path}") + flags_result = subprocess.run( + ["git", "ls-files", "-v", "-z"], + cwd=normalized_repo_path, + capture_output=True, + env=artifact_git_command_env(), + ) + if flags_result.returncode != 0: + raise RuntimeCommandError(f"Unable to inspect git index flags for {label}: {repo_path}") + nonordinary_paths = sorted( + os.fsdecode(entry[2:]) for entry in flags_result.stdout.split(b"\0") if entry and not entry.startswith(b"H ") + ) + if nonordinary_paths: + raise RuntimeCommandError( + f"Artifact publish rejects assume-unchanged, skip-worktree, and nonordinary index entries for {label}: " + f"{nonordinary_paths}" + ) + replace_result = subprocess.run( + ["git", "replace", "--list"], + cwd=normalized_repo_path, + capture_output=True, + text=True, + env=artifact_git_command_env(), + ) + if replace_result.returncode != 0: + raise RuntimeCommandError(f"Unable to inspect git replace refs for {label}: {repo_path}") + replace_refs = sorted(line.strip() for line in replace_result.stdout.splitlines() if line.strip()) + if replace_refs: + raise RuntimeCommandError(f"Artifact publish rejects git replace refs for {label}: {replace_refs}") return head_commit @@ -2359,7 +2853,7 @@ def resolve_image_digest(image_reference: str) -> str: raise RuntimeCommandError("Image digest resolution requires a non-empty image reference.") digest_match = re.search(r"@(sha256:[0-9a-fA-F]{64})$", candidate) if digest_match is not None: - return digest_match.group(1) + return digest_match.group(1).lower() inspect_result = subprocess.run( ["docker", "buildx", "imagetools", "inspect", candidate], capture_output=True, @@ -2374,7 +2868,125 @@ def resolve_image_digest(image_reference: str) -> str: digest_match = re.search(r"^Digest:\s*(sha256:[0-9a-fA-F]{64})\s*$", inspect_result.stdout, flags=re.MULTILINE) if digest_match is None: raise RuntimeCommandError(f"Unable to parse image digest from docker output for {candidate}.") - return digest_match.group(1) + return digest_match.group(1).lower() + + +def resolve_base_image_provenance( + *, + image_reference: str, + role: str, + required_platforms: tuple[str, ...], +) -> BaseImageProvenance: + repository, tags = split_image_reference(image_reference) + digest = resolve_image_digest(image_reference) + digest_reference = f"{repository}@{digest}" + inspect_result = subprocess.run( + ["docker", "buildx", "imagetools", "inspect", digest_reference, "--format", "{{json .Image}}"], + capture_output=True, + text=True, + env=command_execution_env(), + ) + if inspect_result.returncode != 0: + details = clean_optional_value(inspect_result.stderr) or clean_optional_value(inspect_result.stdout) + raise RuntimeCommandError( + f"Unable to inspect immutable {role} base image metadata." + (f"\nDocker reported: {details}" if details else "") + ) + try: + images = json.loads(inspect_result.stdout) + except json.JSONDecodeError as error: + raise RuntimeCommandError(f"Unable to parse immutable {role} base image metadata.") from error + if not isinstance(images, dict): + raise RuntimeCommandError(f"Immutable {role} base image metadata must be platform-keyed.") + if isinstance(images.get("config"), dict) and isinstance(images.get("os"), str) and isinstance(images.get("architecture"), str): + single_platform = f"{images['os'].strip().lower()}/{images['architecture'].strip().lower()}" + variant = images.get("variant") + if isinstance(variant, str) and variant.strip(): + single_platform += f"/{variant.strip().lower()}" + images = {single_platform: images} + + source_pairs: set[tuple[str, str]] = set() + missing_platforms: list[str] = [] + for platform in required_platforms: + image = images.get(platform) + if not isinstance(image, dict): + missing_platforms.append(platform) + continue + config = image.get("config") + labels = config.get("Labels") if isinstance(config, dict) else None + if not isinstance(labels, dict): + raise RuntimeCommandError(f"Immutable {role} base image lacks OCI labels for {platform}.") + source_repository = labels.get("org.opencontainers.image.source") + source_ref = labels.get("org.opencontainers.image.revision") + if not isinstance(source_repository, str) or not isinstance(source_ref, str): + raise RuntimeCommandError(f"Immutable {role} base image lacks source/revision labels for {platform}.") + try: + source_pairs.add((normalize_repository_identity(source_repository), normalize_git_commit(source_ref))) + except ArtifactProvenanceError as error: + raise RuntimeCommandError(f"Immutable {role} base image has invalid source provenance for {platform}.") from error + if missing_platforms: + raise RuntimeCommandError(f"Immutable {role} base image is missing target platforms: {sorted(missing_platforms)}") + if len(source_pairs) != 1: + raise RuntimeCommandError(f"Immutable {role} base image source provenance differs across target platforms.") + source_repository, source_ref = next(iter(source_pairs)) + return BaseImageProvenance( + role=role, + repository=repository, + digest=digest, + digest_reference=digest_reference, + tags=tags, + source_repository=source_repository, + source_ref=source_ref, + ) + + +def split_image_reference(image_reference: str) -> tuple[str, tuple[str, ...]]: + candidate = image_reference.strip() + if not candidate or "://" in candidate or candidate.startswith(("/", ".", "~")): + raise RuntimeCommandError("Artifact base image reference must use a registry repository.") + repository_with_tag = candidate.split("@", 1)[0] + last_slash = repository_with_tag.rfind("/") + last_colon = repository_with_tag.rfind(":") + tags: tuple[str, ...] = () + repository = repository_with_tag + if last_colon > last_slash: + repository = repository_with_tag[:last_colon] + tag = repository_with_tag[last_colon + 1 :] + if not repository or not tag: + raise RuntimeCommandError("Artifact base image reference contains an invalid tag.") + tags = (tag,) + if not repository or any(character.isspace() for character in repository) or "?" in repository or "#" in repository: + raise RuntimeCommandError("Artifact base image repository is invalid.") + return repository, tags + + +def extract_published_dependency_evidence( + *, + staged_context_root: Path, + image_reference: str, + platforms: tuple[str, ...], + evidence_root: Path, + build_environment: dict[str, str], +) -> None: + evidence_root.mkdir(parents=True, exist_ok=True) + command = [ + "docker", + "buildx", + "build", + "--file", + str(staged_context_root / "docker" / "dependency-evidence.Dockerfile"), + "--platform", + ",".join(platforms), + "--build-arg", + f"ARTIFACT_IMAGE={image_reference}", + "--output", + f"type=local,dest={evidence_root},platform-split=true", + str(staged_context_root), + ] + run_command( + runtime_repo_path=staged_context_root, + command=command, + environment_overrides=build_environment, + ) def resolve_buildx_metadata_image_digest(metadata_file: Path) -> str: @@ -2395,7 +3007,7 @@ def resolve_buildx_metadata_image_digest(metadata_file: Path) -> str: candidates.append(descriptor.get("digest")) for candidate in candidates: if isinstance(candidate, str) and re.fullmatch(r"sha256:[0-9a-fA-F]{64}", candidate.strip()): - return candidate.strip() + return candidate.strip().lower() raise RuntimeCommandError(f"Buildx metadata file did not include a valid container image digest: {metadata_file}") @@ -2405,16 +3017,17 @@ def build_runtime_artifact_manifest_payload( source_commit: str, runtime_repo_name: str, runtime_repo_commit: str, + runtime_repo_repository: str, artifact_source_entries: tuple[dict[str, str], ...], source_selector_entries: tuple[dict[str, str], ...], odoo_install_modules: tuple[str, ...], - openupgrade_addon_repository: str, - openupgradelib_install_spec: str, addon_skip_flags: tuple[str, ...], image_repository: str, image_tag: str, image_digest: str, - enterprise_base_digest: str, + runtime_base_provenance: BaseImageProvenance, + devtools_base_provenance: BaseImageProvenance, + dependency_provenance: dict[str, object], odoo_version: str, ) -> dict[str, object]: artifact_id = f"artifact-{context_name}-{image_digest.removeprefix('sha256:')[:16]}" @@ -2425,22 +3038,46 @@ def build_runtime_artifact_manifest_payload( "runtime_repo": runtime_repo_name, "runtime_repo_commit": runtime_repo_commit, } + normalized_selectors: list[dict[str, str]] = [] + for selector_entry in source_selector_entries: + try: + normalized_selectors.append( + { + "repository": normalize_repository_identity(selector_entry["repository"]), + "selector": selector_entry["selector"].strip(), + "resolved_ref": normalize_git_commit(selector_entry["resolved_ref"]), + } + ) + except (ArtifactProvenanceError, KeyError) as error: + raise RuntimeCommandError("Artifact source selector evidence is invalid.") from error return { - "schema_version": 1, + "schema_version": 2, "artifact_id": artifact_id, "source_commit": source_commit, - "enterprise_base_digest": enterprise_base_digest, + "enterprise_base_digest": runtime_base_provenance.digest, "addon_sources": list(artifact_source_entries), - "addon_selectors": list(source_selector_entries), + "addon_selectors": normalized_selectors, "odoo_install_modules": list(odoo_install_modules), "openupgrade_inputs": { - "addon_repository": openupgrade_addon_repository, - "install_spec": openupgradelib_install_spec, + "addon_repository": "", + "install_spec": "", }, "build_flags": { "addon_skip_flags": list(addon_skip_flags), "values": build_flag_values, }, + "build_provenance": { + "base_images": [runtime_base_provenance.to_dict(), devtools_base_provenance.to_dict()], + "build_tools": [ + { + "name": "odoo-devkit", + "version": "", + "source_repository": runtime_repo_repository, + "source_ref": runtime_repo_commit, + } + ], + }, + "dependency_provenance": dependency_provenance, "image": { "repository": image_repository, "digest": image_digest, @@ -3051,6 +3688,38 @@ def command_execution_env() -> dict[str, str]: return execution_env +def artifact_git_command_env() -> dict[str, str]: + execution_env = command_execution_env() + repository_context_keys = { + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CEILING_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_SYSTEM", + "GIT_DIR", + "GIT_INDEX_FILE", + "GIT_NAMESPACE", + "GIT_OBJECT_DIRECTORY", + "GIT_PREFIX", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", + } + for environment_key in tuple(execution_env): + if environment_key in repository_context_keys or environment_key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): + execution_env.pop(environment_key, None) + execution_env["GIT_CONFIG_GLOBAL"] = os.devnull + execution_env["GIT_CONFIG_NOSYSTEM"] = "1" + execution_env["GIT_CONFIG_SYSTEM"] = os.devnull + execution_env["GIT_NO_REPLACE_OBJECTS"] = "1" + execution_env["GIT_OPTIONAL_LOCKS"] = "0" + return execution_env + + def clean_optional_value(value: str | None) -> str | None: if value is None: return None diff --git a/odoo_devkit/workspace_surface.py b/odoo_devkit/workspace_surface.py index e28d11a..55b91c1 100644 --- a/odoo_devkit/workspace_surface.py +++ b/odoo_devkit/workspace_surface.py @@ -129,6 +129,9 @@ def _render_workspace_agents( f"uv --directory {devkit_source.resolved_path} run platform workspace status --manifest {manifest.manifest_path}" ), ) + tenant_dependency_check_command = ( + f"uv --directory {devkit_source.resolved_path} run platform dependencies check --manifest {manifest.manifest_path}" + ) return ( "\n\n" "# AGENTS.md — Workspace Operating Guide\n\n" @@ -154,6 +157,8 @@ def _render_workspace_agents( f"- Resync this workspace with `{tenant_workspace_sync_command}`.\n" f"- Inspect workspace status with `{tenant_workspace_status_command}`.\n" f"- Fail closed before relying on generated guidance with `{tenant_workspace_status_command} --check`.\n" + f"- Validate the exact tenant/shared dependency workspace before artifact publish with `{tenant_dependency_check_command}`.\n" + "- Artifact publish requires clean Git commits, tracked regular inputs, immutable base-image digests, and schema-v2 provenance; do not bypass those checks with operator-local files.\n" "- Use the generated PyCharm run configurations from the tenant repo for infrequent but important workflows.\n\n" "## Editing Guardrails\n\n" "- Do not move source-of-truth docs or code into the workspace root just because an agent starts here. The workspace root is generated, not canonical.\n" diff --git a/tests/test_artifact_provenance.py b/tests/test_artifact_provenance.py new file mode 100644 index 0000000..cf9fa06 --- /dev/null +++ b/tests/test_artifact_provenance.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from odoo_devkit.artifact_provenance import ( + ArtifactProvenanceError, + aggregate_dependency_evidence, + normalize_repository_identity, +) + + +class ArtifactProvenanceTests(unittest.TestCase): + def test_aggregate_dependency_evidence_emits_launchplane_v2_shape(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + evidence_root = Path(temporary_directory) + locks = self._locks() + external_inputs = self._external_inputs() + self._write_sidecar( + evidence_root=evidence_root, + platform="linux/arm64", + locks=locks, + external_inputs=external_inputs, + ) + self._write_sidecar( + evidence_root=evidence_root, + platform="linux/amd64", + locks=locks, + external_inputs=external_inputs, + ) + + provenance = aggregate_dependency_evidence( + evidence_root=evidence_root, + expected_platforms=("linux/arm64", "linux/amd64"), + expected_uv_locks=tuple(locks), + ) + + self.assertEqual(provenance["target_platforms"], ["linux/amd64", "linux/arm64"]) + self.assertEqual([lock["scope"] for lock in provenance["uv_locks"]], ["support_runtime", "tenant"]) + self.assertEqual( + provenance["external_compatibility_inputs"][0]["source_repository"], + "example/external-addon", + ) + self.assertEqual( + provenance["python_environments"]["linux/amd64"]["packages"][1]["source"], + { + "kind": "vcs", + "repository": "example/openupgradelib", + "commit": "d" * 40, + }, + ) + + def test_aggregate_dependency_evidence_rejects_lock_bytes_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + evidence_root = Path(temporary_directory) + locks = self._locks() + self._write_sidecar( + evidence_root=evidence_root, + platform="linux/amd64", + locks=locks, + external_inputs=self._external_inputs(), + ) + expected_locks = copy.deepcopy(locks) + expected_locks[1]["sha256"] = "f" * 64 + + with self.assertRaisesRegex(ArtifactProvenanceError, "exact staged lock inputs"): + aggregate_dependency_evidence( + evidence_root=evidence_root, + expected_platforms=("linux/amd64",), + expected_uv_locks=tuple(expected_locks), + ) + + def test_aggregate_dependency_evidence_rejects_cross_platform_external_drift(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + evidence_root = Path(temporary_directory) + locks = self._locks() + external_inputs = self._external_inputs() + self._write_sidecar( + evidence_root=evidence_root, + platform="linux/amd64", + locks=locks, + external_inputs=external_inputs, + ) + changed_inputs = copy.deepcopy(external_inputs) + changed_inputs[0]["dependency_file_sha256"] = "e" * 64 + self._write_sidecar( + evidence_root=evidence_root, + platform="linux/arm64", + locks=locks, + external_inputs=changed_inputs, + ) + + with self.assertRaisesRegex(ArtifactProvenanceError, "differs across target platforms"): + aggregate_dependency_evidence( + evidence_root=evidence_root, + expected_platforms=("linux/amd64", "linux/arm64"), + expected_uv_locks=tuple(locks), + ) + + def test_repository_identity_rejects_local_and_authenticated_values(self) -> None: + for value in ( + "/Users/operator/private", + "file:///tmp/private", + "https://operator:secret@example.invalid/repo.git", + "git@example.invalid:owner/../repo", + "https://example.invalid/owner/../repo", + ): + with self.subTest(value=value): + with self.assertRaises(ArtifactProvenanceError): + normalize_repository_identity(value) + + def test_aggregate_dependency_evidence_rejects_unsafe_relative_path_characters(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + evidence_root = Path(temporary_directory) + locks = self._locks() + external_inputs = self._external_inputs() + external_inputs[0]["dependency_file_path"] = "dependencies/private requirements.txt" + self._write_sidecar( + evidence_root=evidence_root, + platform="linux/amd64", + locks=locks, + external_inputs=external_inputs, + ) + + with self.assertRaisesRegex(ArtifactProvenanceError, "safe repository-relative path"): + aggregate_dependency_evidence( + evidence_root=evidence_root, + expected_platforms=("linux/amd64",), + expected_uv_locks=tuple(locks), + ) + + @staticmethod + def _locks() -> list[dict[str, str]]: + return [ + { + "scope": "support_runtime", + "source_repository": "example/odoo-devkit", + "source_ref": "a" * 40, + "path": "docker/runtime-python/uv.lock", + "sha256": "b" * 64, + }, + { + "scope": "tenant", + "source_repository": "example/tenant", + "source_ref": "c" * 40, + "path": "uv.lock", + "sha256": "d" * 64, + }, + ] + + @staticmethod + def _external_inputs() -> list[dict[str, str]]: + return [ + { + "source_repository": "https://github.com/example/external-addon.git", + "source_ref": "e" * 40, + "dependency_file_path": "requirements.txt", + "dependency_file_sha256": "f" * 64, + "format": "requirements_txt", + "resolution_posture": "exact_source_unlocked", + } + ] + + @staticmethod + def _write_sidecar( + *, + evidence_root: Path, + platform: str, + locks: list[dict[str, str]], + external_inputs: list[dict[str, str]], + ) -> None: + packages = [ + { + "name": "httpx", + "version": "0.28.1", + "source": {"kind": "registry", "repository": "", "commit": ""}, + }, + { + "name": "openupgradelib", + "version": "3.12.0", + "source": { + "kind": "vcs", + "repository": "example/openupgradelib", + "commit": "d" * 40, + }, + }, + ] + packages_sha256 = hashlib.sha256( + json.dumps(packages, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8") + ).hexdigest() + output_directory = evidence_root / platform.replace("/", "_") + output_directory.mkdir(parents=True, exist_ok=True) + (output_directory / "dependency-provenance.json").write_text( + json.dumps( + { + "schema_version": 1, + "layout": "two_lock", + "publishable": True, + "target_platform": platform, + "uv_locks": locks, + "python_environment": { + "python_version": "3.13.7", + "packages": packages, + "package_count": len(packages), + "packages_sha256": packages_sha256, + }, + "external_compatibility_inputs": external_inputs, + }, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_compose_contract.py b/tests/test_compose_contract.py index 33155f9..58fec2f 100644 --- a/tests/test_compose_contract.py +++ b/tests/test_compose_contract.py @@ -22,6 +22,19 @@ def test_shared_compose_requires_generated_runtime_env(self) -> None: self.assertNotIn("PLATFORM_RUNTIME_ENV_FILE:-.env", shared_compose_text) self.assertEqual(shared_compose_text.count("${PLATFORM_RUNTIME_ENV_FILE:?missing}"), 3) + def test_artifact_publish_uses_a_dedicated_two_lock_dockerfile(self) -> None: + repo_root = Path(__file__).resolve().parent.parent + local_dockerfile = (repo_root / "docker" / "Dockerfile").read_text(encoding="utf-8") + artifact_dockerfile = (repo_root / "docker" / "artifact.Dockerfile").read_text(encoding="utf-8") + + self.assertNotIn("COPY /runtime /payload/opt/runtime", local_dockerfile) + self.assertNotIn("COPY /project /payload/opt/project", local_dockerfile) + self.assertIn("COPY /runtime /payload/opt/runtime", artifact_dockerfile) + self.assertIn("COPY /project /payload/opt/project", artifact_dockerfile) + self.assertNotIn("install_openupgradelib_if_needed.sh", artifact_dockerfile) + self.assertIn("rm -rf /opt/extra_addons", artifact_dockerfile) + self.assertIn("/opt/launchplane/evidence /volumes/config /volumes/scripts", artifact_dockerfile) + def test_override_compose_file_owns_local_web_build(self) -> None: repo_root = Path(__file__).resolve().parent.parent override_compose_path = repo_root / "docker-compose.override.yml" diff --git a/tests/test_dependency_workspace.py b/tests/test_dependency_workspace.py new file mode 100644 index 0000000..29b27b2 --- /dev/null +++ b/tests/test_dependency_workspace.py @@ -0,0 +1,563 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from odoo_devkit import dependency_workspace +from odoo_devkit.cli import build_parser +from odoo_devkit.dependency_workspace import ( + inspect_dependency_workspace, + require_publishable_dependency_workspace, + require_staged_build_requirements_supplied, + stage_publishable_dependency_workspace, +) +from odoo_devkit.manifest import WorkspaceManifest, load_workspace_manifest + + +class DependencyWorkspaceTests(unittest.TestCase): + def test_uv_lock_check_ignores_operator_configuration(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + staged_root = Path(temporary_directory_name) + with mock.patch.dict( + dependency_workspace.os.environ, + {"KEEP_ME": "yes", "PIP_INDEX_URL": "https://private.invalid", "UV_INDEX_URL": "https://private.invalid"}, + clear=True, + ): + with mock.patch( + "odoo_devkit.dependency_workspace.subprocess.run", + return_value=mock.Mock(returncode=0), + ) as run_mock: + self.assertTrue(dependency_workspace._uv_lock_is_current(staged_root)) + + command = run_mock.call_args.args[0] + execution_environment = run_mock.call_args.kwargs["env"] + self.assertIn("--offline", command) + self.assertIn("--no-config", command) + self.assertEqual(execution_environment["KEEP_ME"], "yes") + self.assertNotIn("PIP_INDEX_URL", execution_environment) + self.assertNotIn("UV_INDEX_URL", execution_environment) + + def test_committed_dependency_reads_ignore_git_replace_refs(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + repo_path = temp_root / "tenant" + repo_path.mkdir() + source_path = repo_path / "pyproject.toml" + source_path.write_text("[tool.uv]\npackage = false\n", encoding="utf-8") + self._commit_repo(repo_path) + original_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo_path, check=True, capture_output=True, text=True + ).stdout.strip() + source_path.write_text("[tool.uv]\npackage = true\n", encoding="utf-8") + subprocess.run(["git", "commit", "-am", "replacement metadata"], cwd=repo_path, check=True, capture_output=True) + replacement_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo_path, check=True, capture_output=True, text=True + ).stdout.strip() + subprocess.run( + ["git", "replace", original_commit, replacement_commit], + cwd=repo_path, + check=True, + capture_output=True, + ) + destination_path = temp_root / "staged.toml" + + dependency_workspace._copy_regular_dependency_file( + repo_path=repo_path, + source_commit=original_commit, + source_path=source_path, + destination_path=destination_path, + display_path="pyproject.toml", + ) + + self.assertEqual(destination_path.read_text(encoding="utf-8"), "[tool.uv]\npackage = false\n") + + def test_lockless_pure_addon_workspace_is_current_but_not_publishable(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject(tenant_repo_path / "addons" / "pure_addon") + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertTrue(inspection.current) + self.assertFalse(inspection.publishable) + self.assertFalse(inspection.requires_tenant_lock) + self.assertIsNone(inspection.tenant_lock_current) + self.assertEqual(inspection.projects[0].path, "addons/pure_addon/pyproject.toml") + with self.assertRaisesRegex(ValueError, "Artifact schema v2 requires"): + require_publishable_dependency_workspace(manifest=manifest) + + def test_runtime_dependencies_require_root_workspace_and_lock(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject( + tenant_repo_path / "addons" / "runtime_addon", + dependencies=("httpx==0.28.1",), + ) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertFalse(inspection.current) + self.assertTrue(inspection.requires_tenant_lock) + self.assertIn( + "Owned runtime dependency declarations require a tenant root pyproject.toml and uv.lock.", + inspection.findings, + ) + + def test_dependency_workspace_requires_root_and_lock_as_a_pair(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject(tenant_repo_path / "addons" / "tenant_addon") + self._write_root_workspace(tenant_repo_path=tenant_repo_path, members=("addons/*",)) + (tenant_repo_path / "uv.lock").unlink() + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertFalse(inspection.current) + self.assertIn( + "Tenant dependency workspace requires pyproject.toml and uv.lock as a complete pair.", + inspection.findings, + ) + + def test_combined_tenant_and_shared_workspace_uses_uv_as_lock_authority(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + shared_repo_path = temp_root / "shared" + self._write_member_pyproject( + tenant_repo_path / "addons" / "tenant_addon", + dependencies=("httpx==0.28.1",), + ) + self._write_member_pyproject( + shared_repo_path / "shared_addon", + project_name="shared_addon", + dependencies=("pydantic==2.13.4",), + ) + lock_bytes = b"version = 1\n" + self._write_root_workspace( + tenant_repo_path=tenant_repo_path, + members=("addons/*", "addons/shared/*"), + lock_bytes=lock_bytes, + ) + self._commit_repo(tenant_repo_path) + self._commit_repo(shared_repo_path) + manifest = self._write_manifest( + temp_root=temp_root, + tenant_repo_path=tenant_repo_path, + shared_repo_path=shared_repo_path, + ) + + def validate_staged_workspace(staged_root: Path) -> bool: + self.assertTrue((staged_root / "addons" / "tenant_addon" / "pyproject.toml").is_file()) + self.assertTrue((staged_root / "addons" / "shared" / "shared_addon" / "pyproject.toml").is_file()) + return True + + with mock.patch("odoo_devkit.dependency_workspace._uv_lock_is_current", side_effect=validate_staged_workspace): + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertTrue(inspection.current) + self.assertTrue(inspection.publishable) + self.assertTrue(inspection.tenant_lock_current) + self.assertEqual(inspection.tenant_lock_sha256, hashlib.sha256(lock_bytes).hexdigest()) + self.assertEqual( + inspection.workspace_members, + ("addons/shared/shared_addon", "addons/tenant_addon"), + ) + self.assertEqual({project.owner for project in inspection.projects}, {"tenant", "shared_addons"}) + + def test_workspace_member_drift_fails_before_uv_check(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + shared_repo_path = temp_root / "shared" + self._write_member_pyproject(tenant_repo_path / "addons" / "tenant_addon") + self._write_member_pyproject(shared_repo_path / "shared_addon", project_name="shared_addon") + self._write_root_workspace( + tenant_repo_path=tenant_repo_path, + members=("addons/tenant_addon",), + ) + self._commit_repo(tenant_repo_path) + self._commit_repo(shared_repo_path) + manifest = self._write_manifest( + temp_root=temp_root, + tenant_repo_path=tenant_repo_path, + shared_repo_path=shared_repo_path, + ) + + with mock.patch("odoo_devkit.dependency_workspace._uv_lock_is_current") as uv_lock_is_current: + inspection = inspect_dependency_workspace(manifest=manifest) + + uv_lock_is_current.assert_not_called() + self.assertFalse(inspection.current) + self.assertIn("missing=['addons/shared/shared_addon']", inspection.findings[-1]) + + def test_mutable_vcs_dependency_fails(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject( + tenant_repo_path / "addons" / "runtime_addon", + dependencies=("simple-zpl2 @ git+https://github.com/example/simple-zpl2@main",), + ) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertFalse(inspection.current) + self.assertIn("VCS dependencies must use exact lowercase git commits", inspection.findings[0]) + + def test_build_requirement_extras_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + addon_root = tenant_repo_path / "addons" / "runtime_addon" + self._write_member_pyproject(addon_root) + pyproject_path = addon_root / "pyproject.toml" + pyproject_path.write_text( + pyproject_path.read_text(encoding="utf-8").replace( + "hatchling==1.27.0", + "hatchling[extra]==1.27.0", + ), + encoding="utf-8", + ) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertFalse(inspection.current) + self.assertTrue(any("build requirements must use exact registry versions" in item for item in inspection.findings)) + + def test_uv_git_source_fields_match_strict_runtime_policy(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + addon_root = tenant_repo_path / "addons" / "runtime_addon" + self._write_member_pyproject(addon_root, dependencies=("private-package==1.0.0",)) + pyproject_path = addon_root / "pyproject.toml" + pyproject_path.write_text( + pyproject_path.read_text(encoding="utf-8") + + '\n[tool.uv.sources]\nprivate-package = { git = "https://github.com/example/private-package", rev = "' + + "a" * 40 + + '", lfs = true }\n', + encoding="utf-8", + ) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertFalse(inspection.current) + self.assertTrue(any("unsupported git source fields" in finding for finding in inspection.findings)) + + def test_stale_lock_fails(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject(tenant_repo_path / "addons" / "tenant_addon") + self._write_root_workspace(tenant_repo_path=tenant_repo_path, members=("addons/*",)) + self._commit_repo(tenant_repo_path) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + with mock.patch("odoo_devkit.dependency_workspace._uv_lock_is_current", return_value=False): + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertFalse(inspection.current) + self.assertFalse(inspection.tenant_lock_current) + self.assertIn("Tenant uv.lock is not current", inspection.findings[-1]) + + def test_publish_stage_preserves_exact_tenant_and_shared_members(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + shared_repo_path = temp_root / "shared" + self._write_member_pyproject(tenant_repo_path / "addons" / "tenant_addon") + self._write_member_pyproject(shared_repo_path / "shared_addon", project_name="shared_addon") + self._write_root_workspace( + tenant_repo_path=tenant_repo_path, + members=("addons/*", "addons/shared/*"), + ) + self._commit_repo(tenant_repo_path) + self._commit_repo(shared_repo_path) + manifest = self._write_manifest( + temp_root=temp_root, + tenant_repo_path=tenant_repo_path, + shared_repo_path=shared_repo_path, + ) + destination_root = temp_root / "staged" + + with mock.patch("odoo_devkit.dependency_workspace._uv_lock_is_current", return_value=True): + inspection = stage_publishable_dependency_workspace( + manifest=manifest, + destination_root=destination_root, + ) + + self.assertTrue(inspection.publishable) + self.assertTrue((destination_root / "addons" / "tenant_addon" / "pyproject.toml").is_file()) + self.assertTrue((destination_root / "addons" / "shared" / "shared_addon" / "pyproject.toml").is_file()) + + def test_publishable_workspace_requires_tracked_regular_inputs(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject(tenant_repo_path / "addons" / "tenant_addon") + self._write_root_workspace(tenant_repo_path=tenant_repo_path, members=("addons/*",)) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertFalse(inspection.publishable) + self.assertTrue(any("requires a Git worktree" in finding for finding in inspection.findings)) + + def test_custom_uv_package_sources_fail_without_echoing_credentials(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject( + tenant_repo_path / "addons" / "tenant_addon", + dependencies=("private-package @ https://operator:secret@example.invalid/package.whl",), + ) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + inspection = inspect_dependency_workspace(manifest=manifest) + serialized = json.dumps(inspection.to_dict(), sort_keys=True) + + self.assertFalse(inspection.current) + self.assertNotIn("operator", serialized) + self.assertNotIn("secret", serialized) + self.assertEqual(inspection.projects[0].runtime_dependencies, ("private-package",)) + + def test_custom_uv_index_configuration_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject(tenant_repo_path / "addons" / "tenant_addon") + self._write_root_workspace(tenant_repo_path=tenant_repo_path, members=("addons/*",)) + root_pyproject = tenant_repo_path / "pyproject.toml" + root_pyproject.write_text( + root_pyproject.read_text(encoding="utf-8") + + '\n[[tool.uv.index]]\nname = "private"\nurl = "https://packages.example.invalid/simple"\n', + encoding="utf-8", + ) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertFalse(inspection.current) + self.assertTrue(any("cannot configure custom uv package sources" in finding for finding in inspection.findings)) + + def test_tenant_dependency_metadata_cannot_shadow_shared_namespace(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + shared_repo_path = temp_root / "shared" + self._write_member_pyproject(tenant_repo_path / "addons" / "shared" / "shadow") + shared_repo_path.mkdir(parents=True) + manifest = self._write_manifest( + temp_root=temp_root, + tenant_repo_path=tenant_repo_path, + shared_repo_path=shared_repo_path, + ) + + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertFalse(inspection.current) + self.assertTrue(any("reserved shared-addons namespace" in finding for finding in inspection.findings)) + + def test_real_uv_lock_check_accepts_exact_staged_workspace(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject(tenant_repo_path / "addons" / "tenant_addon") + self._write_root_workspace(tenant_repo_path=tenant_repo_path, members=("addons/*",), lock_bytes=b"") + (tenant_repo_path / "uv.lock").unlink() + subprocess.run( + ["uv", "lock", "--project", str(tenant_repo_path)], + cwd=tenant_repo_path, + check=True, + capture_output=True, + text=True, + ) + self._commit_repo(tenant_repo_path) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertTrue(inspection.current, inspection.findings) + self.assertTrue(inspection.publishable) + + def test_addon_build_requirements_must_be_supplied_by_a_lock_catalog(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + support_root = temp_root / "runtime" + tenant_root = temp_root / "project" + self._write_member_pyproject(tenant_root / "addons" / "tenant_addon") + self._write_root_workspace(tenant_repo_path=tenant_root, members=("addons/*",)) + support_root.mkdir(parents=True) + (support_root / "pyproject.toml").write_text( + '[project]\nname = "runtime-support"\nversion = "0.0.0"\ndependencies = []\n\n[tool.uv]\npackage = false\n', + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "hatchling==1.27.0"): + require_staged_build_requirements_supplied( + support_root=support_root, + tenant_root=tenant_root, + ) + + support_pyproject = support_root / "pyproject.toml" + support_pyproject.write_text( + support_pyproject.read_text(encoding="utf-8").replace( + "dependencies = []", + 'dependencies = ["hatchling==1.27.0"]', + ), + encoding="utf-8", + ) + require_staged_build_requirements_supplied( + support_root=support_root, + tenant_root=tenant_root, + ) + + def test_owned_requirements_file_fails(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + addon_root = tenant_repo_path / "addons" / "tenant_addon" + self._write_member_pyproject(addon_root) + (addon_root / "requirements.txt").write_text("httpx==0.28.1\n", encoding="utf-8") + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + + inspection = inspect_dependency_workspace(manifest=manifest) + + self.assertFalse(inspection.current) + self.assertIn("requirements must move into pyproject.toml", inspection.findings[0]) + + def test_cli_inspect_and_check_emit_structured_status(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory_name: + temp_root = Path(temporary_directory_name) + tenant_repo_path = temp_root / "tenant" + self._write_member_pyproject( + tenant_repo_path / "addons" / "runtime_addon", + dependencies=("httpx==0.28.1",), + ) + manifest = self._write_manifest(temp_root=temp_root, tenant_repo_path=tenant_repo_path) + parser = build_parser() + + inspect_arguments = parser.parse_args(["dependencies", "inspect", "--manifest", str(manifest.manifest_path)]) + inspect_output = io.StringIO() + with contextlib.redirect_stdout(inspect_output): + inspect_arguments.handler(inspect_arguments) + self.assertFalse(json.loads(inspect_output.getvalue())["current"]) + + check_arguments = parser.parse_args(["dependencies", "check", "--manifest", str(manifest.manifest_path)]) + with contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(SystemExit, "1"): + check_arguments.handler(check_arguments) + + @staticmethod + def _write_member_pyproject( + project_root: Path, + *, + project_name: str = "tenant_addon", + dependencies: tuple[str, ...] = (), + ) -> None: + project_root.mkdir(parents=True, exist_ok=True) + dependency_lines = ",\n".join(f" {json.dumps(dependency)}" for dependency in dependencies) + if dependency_lines: + dependency_lines += ",\n" + (project_root / "pyproject.toml").write_text( + "[build-system]\n" + 'requires = ["hatchling==1.27.0"]\n' + 'build-backend = "hatchling.build"\n\n' + "[project]\n" + f'name = "{project_name}"\n' + 'version = "0.0.0"\n' + "dependencies = [\n" + f"{dependency_lines}" + "]\n\n" + "[tool.uv]\n" + "package = false\n", + encoding="utf-8", + ) + + @staticmethod + def _write_root_workspace( + *, + tenant_repo_path: Path, + members: tuple[str, ...], + lock_bytes: bytes = b"version = 1\n", + ) -> None: + tenant_repo_path.mkdir(parents=True, exist_ok=True) + member_lines = ", ".join(json.dumps(member) for member in members) + (tenant_repo_path / "pyproject.toml").write_text( + "[project]\n" + 'name = "tenant-dependencies"\n' + 'version = "0.0.0"\n' + "dependencies = []\n\n" + "[tool.uv]\n" + "package = false\n\n" + "[tool.uv.workspace]\n" + f"members = [{member_lines}]\n", + encoding="utf-8", + ) + (tenant_repo_path / "uv.lock").write_bytes(lock_bytes) + + @staticmethod + def _commit_repo(repo_path: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=repo_path, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "tests@example.invalid"], cwd=repo_path, check=True) + subprocess.run(["git", "config", "user.name", "Tests"], cwd=repo_path, check=True) + subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True) + subprocess.run(["git", "commit", "-q", "-m", "dependency workspace"], cwd=repo_path, check=True) + + @staticmethod + def _write_manifest( + *, + temp_root: Path, + tenant_repo_path: Path, + shared_repo_path: Path | None = None, + ) -> WorkspaceManifest: + tenant_repo_path.mkdir(parents=True, exist_ok=True) + shared_repo_table = "" + if shared_repo_path is not None: + shared_repo_path.mkdir(parents=True, exist_ok=True) + shared_repo_table = f'\n[repos.shared_addons]\nname = "shared-addons"\npath = "{shared_repo_path}"\nref = "main"\n' + manifest_path = tenant_repo_path / "workspace.toml" + manifest_path.write_text( + "schema_version = 1\n" + 'tenant = "test"\n\n' + "[workspace]\n" + 'name = "test"\n' + 'python = "3.13"\n' + f'workspace_root = "{temp_root / "workspaces"}"\n\n' + "[repos.tenant]\n" + 'name = "tenant"\n' + 'path = "."\n' + 'ref = "main"\n' + f"{shared_repo_table}\n" + "[runtime]\n" + 'context = "test"\n' + 'instance = "local"\n' + 'database = "test"\n' + 'addons_paths = ["sources/tenant/addons"]\n\n' + "[ide]\n" + 'mode = "tenant_repo"\n' + 'focus_paths = ["addons"]\n' + "attached_paths = []\n", + encoding="utf-8", + ) + return load_workspace_manifest(manifest_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime.py b/tests/test_runtime.py index f55102e..f119a61 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -3,6 +3,7 @@ import argparse import base64 import contextlib +import hashlib import io import json import os @@ -48,8 +49,11 @@ class RuntimeCommandTests(unittest.TestCase): def setUp(self) -> None: super().setUp() self.explicit_payload_loader = local_runtime.load_environment_from_explicit_payload + self.remote_source_commit_verifier = local_runtime.require_remote_source_commit self.environment_patch = mock.patch.dict(os.environ, {local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: "{}"}) self.environment_patch.start() + self.remote_source_commit_patch = mock.patch("odoo_devkit.local_runtime.require_remote_source_commit") + self.remote_source_commit_patch.start() self.load_environment_patch = mock.patch( "odoo_devkit.local_runtime.load_environment_from_explicit_payload", side_effect=self._load_environment_from_explicit_payload, @@ -58,6 +62,7 @@ def setUp(self) -> None: def tearDown(self) -> None: self.load_environment_patch.stop() + self.remote_source_commit_patch.stop() self.environment_patch.stop() super().tearDown() @@ -65,6 +70,95 @@ def _write_buildx_metadata_for_command(self, command: list[str]) -> None: metadata_file = Path(command[command.index("--metadata-file") + 1]) metadata_file.write_text(json.dumps({"containerimage.digest": self.artifact_image_digest}) + "\n", encoding="utf-8") + def _write_artifact_build_outputs_for_command(self, command: list[str]) -> None: + if "--metadata-file" in command: + self._write_buildx_metadata_for_command(command) + return + if "--output" in command: + self._write_dependency_evidence_for_command(command) + + @staticmethod + def _base_image_provenance(*, role: str, image_reference: str | None = None) -> local_runtime.BaseImageProvenance: + digest_character = "2" if role == "runtime" else "3" + digest = "sha256:" + digest_character * 64 + repository, tags = local_runtime.split_image_reference(image_reference or f"ghcr.io/example/{role}:19.0-{role}") + return local_runtime.BaseImageProvenance( + role=role, + repository=repository, + digest=digest, + digest_reference=f"{repository}@{digest}", + tags=tags, + source_repository="example/odoo-enterprise-docker", + source_ref="4" * 40, + ) + + def _resolve_base_image_provenance_fixture( + self, + *, + image_reference: str, + role: str, + required_platforms: tuple[str, ...], + ) -> local_runtime.BaseImageProvenance: + self.assertTrue(required_platforms) + return self._base_image_provenance(role=role, image_reference=image_reference) + + @staticmethod + def _write_dependency_evidence_for_command(command: list[str]) -> None: + output_value = command[command.index("--output") + 1] + output_options = dict(option.split("=", 1) for option in output_value.split(",") if "=" in option) + evidence_root = Path(output_options["dest"]) + staged_root = Path(command[-1]) + support_source = json.loads( + (staged_root / "runtime" / local_runtime.DEPENDENCY_SOURCE_MARKER_FILE).read_text(encoding="utf-8") + ) + tenant_source = json.loads( + (staged_root / "project" / local_runtime.DEPENDENCY_SOURCE_MARKER_FILE).read_text(encoding="utf-8") + ) + uv_locks = [ + { + "scope": "support_runtime", + "source_repository": support_source["repository"], + "source_ref": support_source["ref"], + "path": support_source["lock_path"], + "sha256": hashlib.sha256((staged_root / "runtime" / "uv.lock").read_bytes()).hexdigest(), + }, + { + "scope": "tenant", + "source_repository": tenant_source["repository"], + "source_ref": tenant_source["ref"], + "path": tenant_source["lock_path"], + "sha256": hashlib.sha256((staged_root / "project" / "uv.lock").read_bytes()).hexdigest(), + }, + ] + packages: list[dict[str, object]] = [] + packages_sha256 = hashlib.sha256( + json.dumps(packages, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8") + ).hexdigest() + for platform in command[command.index("--platform") + 1].split(","): + output_directory = evidence_root / platform.replace("/", "_") + output_directory.mkdir(parents=True, exist_ok=True) + (output_directory / "dependency-provenance.json").write_text( + json.dumps( + { + "schema_version": 1, + "layout": "two_lock", + "publishable": True, + "target_platform": platform, + "uv_locks": uv_locks, + "python_environment": { + "python_version": "3.13.7", + "packages": packages, + "package_count": 0, + "packages_sha256": packages_sha256, + }, + "external_compatibility_inputs": [], + }, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + def _configure_publish_runtime_payload( self, *, @@ -161,6 +255,19 @@ def test_resolve_buildx_metadata_image_digest_reads_descriptor_digest(self) -> N self.assertEqual(local_runtime.resolve_buildx_metadata_image_digest(metadata_file), self.artifact_image_digest) + def test_resolve_buildx_metadata_image_digest_normalizes_hex_case(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + metadata_file = Path(temporary_directory) / "build-metadata.json" + metadata_file.write_text( + json.dumps({"containerimage.digest": "sha256:" + "A" * 64}) + "\n", + encoding="utf-8", + ) + + self.assertEqual( + local_runtime.resolve_buildx_metadata_image_digest(metadata_file), + "sha256:" + "a" * 64, + ) + def test_resolve_buildx_metadata_image_digest_rejects_missing_digest(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: metadata_file = Path(temporary_directory) / "build-metadata.json" @@ -169,6 +276,238 @@ def test_resolve_buildx_metadata_image_digest_rejects_missing_digest(self) -> No with self.assertRaisesRegex(ValueError, "valid container image digest"): local_runtime.resolve_buildx_metadata_image_digest(metadata_file) + def test_resolve_base_image_provenance_inspects_the_resolved_digest(self) -> None: + digest = "sha256:" + "a" * 64 + image_metadata = { + platform: { + "config": { + "Labels": { + "org.opencontainers.image.source": "https://github.com/example/odoo-enterprise-docker", + "org.opencontainers.image.revision": "b" * 40, + } + } + } + for platform in ("linux/amd64", "linux/arm64") + } + with mock.patch("odoo_devkit.local_runtime.resolve_image_digest", return_value=digest): + with mock.patch( + "odoo_devkit.local_runtime.subprocess.run", + return_value=mock.Mock(returncode=0, stdout=json.dumps(image_metadata), stderr=""), + ) as run_mock: + provenance = local_runtime.resolve_base_image_provenance( + image_reference="ghcr.io/example/runtime:stable", + role="runtime", + required_platforms=("linux/amd64", "linux/arm64"), + ) + + self.assertEqual(provenance.digest_reference, f"ghcr.io/example/runtime@{digest}") + self.assertEqual(provenance.tags, ("stable",)) + self.assertEqual(provenance.source_repository, "example/odoo-enterprise-docker") + self.assertEqual( + run_mock.call_args.args[0][:5], + ["docker", "buildx", "imagetools", "inspect", f"ghcr.io/example/runtime@{digest}"], + ) + + def test_resolve_base_image_provenance_accepts_single_platform_image_shape(self) -> None: + digest = "sha256:" + "a" * 64 + image_metadata = { + "architecture": "arm64", + "os": "linux", + "config": { + "Labels": { + "org.opencontainers.image.source": "https://github.com/example/odoo-enterprise-docker", + "org.opencontainers.image.revision": "b" * 40, + } + }, + } + with mock.patch("odoo_devkit.local_runtime.resolve_image_digest", return_value=digest): + with mock.patch( + "odoo_devkit.local_runtime.subprocess.run", + return_value=mock.Mock(returncode=0, stdout=json.dumps(image_metadata), stderr=""), + ): + provenance = local_runtime.resolve_base_image_provenance( + image_reference=f"ghcr.io/example/runtime@{digest}", + role="runtime", + required_platforms=("linux/arm64",), + ) + + self.assertEqual(provenance.digest_reference, f"ghcr.io/example/runtime@{digest}") + + def test_copy_required_path_materializes_only_committed_bytes(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + temp_root = Path(temporary_directory) + repo_path = self._create_git_repo(temp_root / "source-repo") + source_path = repo_path / "payload" + source_path.mkdir() + (source_path / ".gitignore").write_text("*.secret\n", encoding="utf-8") + (source_path / "tracked.txt").write_text("tracked\n", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "tracked payload"], cwd=repo_path, check=True, capture_output=True) + (source_path / "operator.secret").write_text("do not stage\n", encoding="utf-8") + destination_path = temp_root / "destination" + source_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo_path, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + local_runtime.copy_required_path( + repo_path=repo_path, + source_commit=source_commit, + source_path=source_path, + destination_path=destination_path, + label="test payload", + ) + + self.assertEqual((destination_path / "tracked.txt").read_text(encoding="utf-8"), "tracked\n") + self.assertFalse((destination_path / "operator.secret").exists()) + + def test_copy_required_path_requires_git_root_and_rejects_source_symlinks(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + temp_root = Path(temporary_directory) + repo_path = self._create_git_repo(temp_root / "source-repo") + payload_root = repo_path / "payload" + payload_root.mkdir() + (payload_root / "tracked.txt").write_text("tracked\n", encoding="utf-8") + symlink_path = repo_path / "payload-link" + symlink_path.symlink_to("payload", target_is_directory=True) + subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "payload paths"], cwd=repo_path, check=True, capture_output=True) + source_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo_path, check=True, capture_output=True, text=True + ).stdout.strip() + + with self.assertRaisesRegex(ValueError, "Git worktree root"): + local_runtime.copy_required_path( + repo_path=payload_root, + source_commit=source_commit, + source_path=payload_root, + destination_path=temp_root / "nested-output", + label="nested payload", + ) + with self.assertRaisesRegex(ValueError, "source-repository symlinks"): + local_runtime.copy_required_path( + repo_path=repo_path, + source_commit=source_commit, + source_path=symlink_path, + destination_path=temp_root / "symlink-output", + label="symlink payload", + ) + + def test_artifact_git_reads_ignore_and_reject_replace_refs(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + temp_root = Path(temporary_directory) + repo_path = self._create_git_repo(temp_root / "source-repo") + payload_path = repo_path / "payload.txt" + payload_path.write_text("original\n", encoding="utf-8") + subprocess.run(["git", "add", "payload.txt"], cwd=repo_path, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "original payload"], cwd=repo_path, check=True, capture_output=True) + original_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo_path, check=True, capture_output=True, text=True + ).stdout.strip() + payload_path.write_text("replacement\n", encoding="utf-8") + subprocess.run(["git", "commit", "-am", "replacement payload"], cwd=repo_path, check=True, capture_output=True) + replacement_commit = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo_path, check=True, capture_output=True, text=True + ).stdout.strip() + subprocess.run( + ["git", "replace", original_commit, replacement_commit], + cwd=repo_path, + check=True, + capture_output=True, + ) + + destination_path = temp_root / "staged-payload.txt" + local_runtime.copy_required_path( + repo_path=repo_path, + source_commit=original_commit, + source_path=payload_path, + destination_path=destination_path, + label="test payload", + ) + + self.assertEqual(destination_path.read_text(encoding="utf-8"), "original\n") + with self.assertRaisesRegex(ValueError, "rejects git replace refs"): + local_runtime.require_clean_git_commit(repo_path=repo_path, label="source-repo") + + def test_remote_source_commit_must_be_advertised_by_origin_ref(self) -> None: + commit = "a" * 40 + with mock.patch( + "odoo_devkit.local_runtime.subprocess.run", + return_value=mock.Mock(returncode=0, stdout=f"{'b' * 40}\trefs/heads/main\n", stderr=""), + ) as run_mock: + with self.assertRaisesRegex(ValueError, "advertised by a ref"): + self.remote_source_commit_verifier( + repository="example/source-repo", + commit=commit, + label="source-repo", + github_token="secret-token", + ) + + self.assertEqual(run_mock.call_args.args[0], ["git", "ls-remote", "https://github.com/example/source-repo.git"]) + execution_environment = run_mock.call_args.kwargs["env"] + self.assertEqual(execution_environment["GIT_CONFIG_GLOBAL"], os.devnull) + self.assertEqual(execution_environment["ODOO_DEVKIT_GITHUB_TOKEN"], "secret-token") + + def test_clean_git_source_verifies_normalized_origin_and_commit(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repo_path = self._create_git_repo(Path(temporary_directory) / "source-repo") + + source = local_runtime.require_clean_git_source(repo_path=repo_path, label="source-repo") + + self.assertEqual(source.repository, "example/source-repo") + local_runtime.require_remote_source_commit.assert_called_once_with( + repository="example/source-repo", + commit=source.commit, + label="source-repo", + github_token=None, + ) + + def test_embedded_dependency_source_markers_are_reserved_for_devkit(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + addons_root = Path(temporary_directory) / "addons" + marker_path = addons_root / "shared" / "example" / local_runtime.DEPENDENCY_SOURCE_MARKER_FILE + marker_path.parent.mkdir(parents=True) + marker_path.write_text('{"repository":"spoofed/source","ref":"' + "a" * 40 + '"}\n', encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "reserved dependency source markers"): + local_runtime.require_no_embedded_dependency_source_markers(roots=((addons_root, "owned addons"),)) + + def test_clean_git_commit_rejects_assume_unchanged_inputs(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + repo_path = self._create_git_repo(Path(temporary_directory) / "source-repo") + subprocess.run( + ["git", "update-index", "--assume-unchanged", "README.md"], + cwd=repo_path, + check=True, + capture_output=True, + ) + (repo_path / "README.md").write_text("hidden edit\n", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "rejects assume-unchanged"): + local_runtime.require_clean_git_commit(repo_path=repo_path, label="source-repo") + + def test_staged_artifact_context_detects_changed_input_bytes(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + staged_root = Path(temporary_directory) + dockerfile = staged_root / "docker" / "Dockerfile" + dockerfile.parent.mkdir(parents=True) + dockerfile.write_text("FROM scratch\n", encoding="utf-8") + staged_context = local_runtime.StagedArtifactContext( + file_hashes=local_runtime.snapshot_staged_artifact_files(staged_root), + support_lock_sha256="a" * 64, + tenant_lock_sha256="b" * 64, + ) + dockerfile.write_text("FROM busybox\n", encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "staged inputs changed"): + local_runtime.require_staged_artifact_context_unchanged( + staged_context_root=staged_root, + staged_context=staged_context, + ) + def test_typed_odoo_instance_override_payload_from_legacy_setting_env(self) -> None: runtime_values = { "ENV_OVERRIDE_CONFIG_PARAM__WEB__BASE__URL": "https://opw-local.example.com", @@ -1179,6 +1518,7 @@ def test_native_runtime_publish_builds_release_context_and_emits_manifest(self) shared_addons_repo_path = self._create_git_repo(temp_root / "shared-addons-repo") (tenant_repo_path / "addons" / "opw_custom").mkdir(parents=True, exist_ok=True) (tenant_repo_path / "addons" / "opw_custom" / "__manifest__.py").write_text("{}\n", encoding="utf-8") + self._write_tenant_dependency_workspace(tenant_repo_path, addon_names=("opw_custom",)) (tenant_repo_path / "addons" / "shared").mkdir(parents=True, exist_ok=True) (tenant_repo_path / "addons" / "shared" / "tenant_shadow.txt").write_text("ignore me\n", encoding="utf-8") subprocess.run(["git", "add", "."], cwd=tenant_repo_path, check=True, capture_output=True) @@ -1195,12 +1535,12 @@ def test_native_runtime_publish_builds_release_context_and_emits_manifest(self) runtime_repo_path=runtime_repo_path, shared_addons_repo_path=shared_addons_repo_path, addons_paths=("sources/tenant/addons", "sources/shared-addons"), - instance_name="testing", + instance_name="local", ) subprocess.run(["git", "add", "workspace.toml"], cwd=tenant_repo_path, check=True, capture_output=True) subprocess.run(["git", "commit", "-m", "workspace manifest"], cwd=tenant_repo_path, check=True, capture_output=True) manifest = load_workspace_manifest(manifest_path) - self._configure_publish_runtime_payload() + self._configure_publish_runtime_payload(instance="local") captured_build_contexts: list[Path] = [] @@ -1213,8 +1553,9 @@ def fake_run_command( ) -> None: _ = environment_overrides, allowed_return_codes if command[:3] == ["docker", "buildx", "build"]: + self._write_artifact_build_outputs_for_command(command) + if "--metadata-file" in command: captured_build_contexts.append(runtime_repo_path) - self._write_buildx_metadata_for_command(command) self.assertIn("--platform", command) self.assertIn(",".join(local_runtime.DEFAULT_ARTIFACT_IMAGE_PLATFORMS), command) self.assertIn("--metadata-file", command) @@ -1228,9 +1569,9 @@ def fake_run_command( with mock.patch("odoo_devkit.local_runtime.ensure_registry_auth_for_image_push"): with mock.patch("odoo_devkit.local_runtime.run_command", side_effect=fake_run_command): with mock.patch( - "odoo_devkit.local_runtime.resolve_image_digest", - return_value="sha256:" + "2" * 64, - ) as resolve_digest_mock: + "odoo_devkit.local_runtime.resolve_base_image_provenance", + side_effect=self._resolve_base_image_provenance_fixture, + ) as resolve_provenance_mock: payload = run_native_runtime_publish( manifest=manifest, image_repository="ghcr.io/example/opw-runtime", @@ -1240,21 +1581,34 @@ def fake_run_command( ) self.assertTrue(captured_build_contexts) - resolve_digest_mock.assert_called_once_with("ghcr.io/example/runtime:19.0-runtime") + self.assertEqual( + [call.kwargs["role"] for call in resolve_provenance_mock.call_args_list], + ["runtime", "devtools"], + ) + self.assertEqual(payload["schema_version"], 2) self.assertEqual(payload["image"]["repository"], "ghcr.io/example/opw-runtime") self.assertEqual(payload["image"]["digest"], self.artifact_image_digest) self.assertEqual(payload["enterprise_base_digest"], "sha256:" + "2" * 64) + self.assertEqual( + payload["dependency_provenance"]["target_platforms"], + sorted(local_runtime.DEFAULT_ARTIFACT_IMAGE_PLATFORMS), + ) + self.assertEqual( + [base_image["role"] for base_image in payload["build_provenance"]["base_images"]], + ["runtime", "devtools"], + ) + self.assertEqual(payload["build_provenance"]["build_tools"][0]["name"], "odoo-devkit") self.assertEqual(payload["build_flags"]["values"]["build_target"], "production") self.assertEqual(payload["image"]["tags"], ["opw-20260416-abcdef"]) self.assertEqual( payload["odoo_install_modules"], - ["launchplane_settings", "disable_odoo_online", "opw_custom"], + ["opw_custom"], ) self.assertEqual(payload["output_file"], str(output_file.resolve())) written_payload = json.loads(output_file.read_text(encoding="utf-8")) self.assertEqual(written_payload["artifact_id"], payload["artifact_id"]) self.assertIn( - {"repository": "shared-addons-repo", "ref": written_payload["addon_sources"][0]["ref"]}, + {"repository": "example/shared-addons-repo", "ref": written_payload["addon_sources"][0]["ref"]}, written_payload["addon_sources"], ) @@ -1265,6 +1619,7 @@ def test_native_runtime_publish_resolves_addon_selectors_from_artifact_inputs_ma runtime_repo_path = self._create_git_repo(temp_root / "runtime-repo") (tenant_repo_path / "addons" / "opw_custom").mkdir(parents=True, exist_ok=True) (tenant_repo_path / "addons" / "opw_custom" / "__manifest__.py").write_text("{}\n", encoding="utf-8") + self._write_tenant_dependency_workspace(tenant_repo_path, addon_names=("opw_custom",)) subprocess.run(["git", "add", "."], cwd=tenant_repo_path, check=True, capture_output=True) subprocess.run(["git", "commit", "-m", "tenant addons"], cwd=tenant_repo_path, check=True, capture_output=True) self._write_runtime_repo(runtime_repo_path) @@ -1303,8 +1658,9 @@ def fake_run_command( ) -> None: _ = runtime_repo_path, environment_overrides, allowed_return_codes if command[:3] == ["docker", "buildx", "build"]: + self._write_artifact_build_outputs_for_command(command) + if "--metadata-file" in command: captured_build_args.extend(command) - self._write_buildx_metadata_for_command(command) resolved_ref = "411f6b8e85cac72dc7aa2e2dc5540001043c327d" @@ -1319,7 +1675,10 @@ def fake_run_command( "odoo_devkit.local_runtime.resolve_source_repository_ref_to_git_sha", return_value=resolved_ref, ) as resolve_ref_mock: - with mock.patch("odoo_devkit.local_runtime.resolve_image_digest", return_value="sha256:" + "2" * 64): + with mock.patch( + "odoo_devkit.local_runtime.resolve_base_image_provenance", + side_effect=self._resolve_base_image_provenance_fixture, + ): payload = run_native_runtime_publish( manifest=manifest, image_repository="ghcr.io/example/opw-runtime", @@ -1396,6 +1755,18 @@ def test_artifact_publish_runtime_values_require_odoo_version(self) -> None: ): local_runtime.validate_artifact_publish_runtime_values({}) + def test_artifact_publish_runtime_values_reject_skip_addons(self) -> None: + with self.assertRaisesRegex( + local_runtime.RuntimeCommandError, + "does not support ODOO_PYTHON_SYNC_SKIP_ADDONS", + ): + local_runtime.validate_artifact_publish_runtime_values( + { + "ODOO_VERSION": "19.0", + "ODOO_PYTHON_SYNC_SKIP_ADDONS": "legacy_addon", + } + ) + def test_native_runtime_publish_rejects_legacy_runtime_stack_selectors(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) @@ -1403,6 +1774,7 @@ def test_native_runtime_publish_rejects_legacy_runtime_stack_selectors(self) -> runtime_repo_path = self._create_git_repo(temp_root / "runtime-repo") (tenant_repo_path / "addons" / "opw_custom").mkdir(parents=True, exist_ok=True) (tenant_repo_path / "addons" / "opw_custom" / "__manifest__.py").write_text("{}\n", encoding="utf-8") + self._write_tenant_dependency_workspace(tenant_repo_path, addon_names=("opw_custom",)) subprocess.run(["git", "add", "."], cwd=tenant_repo_path, check=True, capture_output=True) subprocess.run(["git", "commit", "-m", "tenant addons"], cwd=tenant_repo_path, check=True, capture_output=True) self._write_runtime_repo(runtime_repo_path) @@ -1452,8 +1824,9 @@ def fake_run_command( ) -> None: _ = runtime_repo_path, environment_overrides, allowed_return_codes if command[:3] == ["docker", "buildx", "build"]: + self._write_artifact_build_outputs_for_command(command) + if "--metadata-file" in command: captured_build_args.extend(command) - self._write_buildx_metadata_for_command(command) with self.assertRaisesRegex(ValueError, "Legacy addon source keys are no longer supported"): run_native_runtime_publish( @@ -1471,6 +1844,7 @@ def test_native_runtime_publish_prefers_artifact_inputs_manifest_over_runtime_en runtime_repo_path = self._create_git_repo(temp_root / "runtime-repo") (tenant_repo_path / "addons" / "opw_custom").mkdir(parents=True, exist_ok=True) (tenant_repo_path / "addons" / "opw_custom" / "__manifest__.py").write_text("{}\n", encoding="utf-8") + self._write_tenant_dependency_workspace(tenant_repo_path, addon_names=("opw_custom",)) subprocess.run(["git", "add", "."], cwd=tenant_repo_path, check=True, capture_output=True) subprocess.run(["git", "commit", "-m", "tenant addons"], cwd=tenant_repo_path, check=True, capture_output=True) self._write_runtime_repo(runtime_repo_path) @@ -1511,8 +1885,9 @@ def fake_run_command( ) -> None: _ = runtime_repo_path, environment_overrides, allowed_return_codes if command[:3] == ["docker", "buildx", "build"]: + self._write_artifact_build_outputs_for_command(command) + if "--metadata-file" in command: captured_build_args.extend(command) - self._write_buildx_metadata_for_command(command) resolved_ref = "411f6b8e85cac72dc7aa2e2dc5540001043c327d" with mock.patch( @@ -1526,7 +1901,10 @@ def fake_run_command( "odoo_devkit.local_runtime.resolve_source_repository_ref_to_git_sha", return_value=resolved_ref, ) as resolve_ref_mock: - with mock.patch("odoo_devkit.local_runtime.resolve_image_digest", return_value="sha256:" + "2" * 64): + with mock.patch( + "odoo_devkit.local_runtime.resolve_base_image_provenance", + side_effect=self._resolve_base_image_provenance_fixture, + ): payload = run_native_runtime_publish( manifest=manifest, image_repository="ghcr.io/example/opw-runtime", @@ -1609,6 +1987,7 @@ def test_native_runtime_publish_rejects_invalid_artifact_inputs_manifest(self) - runtime_repo_path = self._create_git_repo(temp_root / "runtime-repo") (tenant_repo_path / "addons" / "opw_custom").mkdir(parents=True, exist_ok=True) (tenant_repo_path / "addons" / "opw_custom" / "__manifest__.py").write_text("{}\n", encoding="utf-8") + self._write_tenant_dependency_workspace(tenant_repo_path, addon_names=("opw_custom",)) subprocess.run(["git", "add", "."], cwd=tenant_repo_path, check=True, capture_output=True) subprocess.run(["git", "commit", "-m", "tenant addons"], cwd=tenant_repo_path, check=True, capture_output=True) self._write_runtime_repo(runtime_repo_path) @@ -1909,6 +2288,7 @@ def test_native_runtime_publish_prefers_exact_payload_refs_over_stack_defaults(s runtime_repo_path = self._create_git_repo(temp_root / "runtime-repo") (tenant_repo_path / "addons" / "cm_custom").mkdir(parents=True, exist_ok=True) (tenant_repo_path / "addons" / "cm_custom" / "__manifest__.py").write_text("{}\n", encoding="utf-8") + self._write_tenant_dependency_workspace(tenant_repo_path, addon_names=("cm_custom",)) subprocess.run(["git", "add", "."], cwd=tenant_repo_path, check=True, capture_output=True) subprocess.run(["git", "commit", "-m", "tenant addons"], cwd=tenant_repo_path, check=True, capture_output=True) self._write_runtime_repo(runtime_repo_path) @@ -1941,7 +2321,7 @@ def test_native_runtime_publish_prefers_exact_payload_refs_over_stack_defaults(s "OPENUPGRADELIB_INSTALL_SPEC": ( "git+https://github.com/OCA/openupgradelib.git@89e649728027a8ab656b3aa4be18f4bd364db417" ), - "ODOO_PYTHON_SYNC_SKIP_ADDONS": "skip_one,skip_two", + "ODOO_PYTHON_SYNC_SKIP_ADDONS": "", } ) @@ -1956,8 +2336,9 @@ def fake_run_command( ) -> None: _ = runtime_repo_path, environment_overrides, allowed_return_codes if command[:3] == ["docker", "buildx", "build"]: + self._write_artifact_build_outputs_for_command(command) + if "--metadata-file" in command: captured_build_args.extend(command) - self._write_buildx_metadata_for_command(command) exact_ref = "cbusillo/disable_odoo_online@411f6b8e85cac72dc7aa2e2dc5540001043c327d" with mock.patch( @@ -1967,7 +2348,10 @@ def fake_run_command( with mock.patch("odoo_devkit.local_runtime.ensure_registry_auth_for_base_images"): with mock.patch("odoo_devkit.local_runtime.ensure_registry_auth_for_image_push"): with mock.patch("odoo_devkit.local_runtime.run_command", side_effect=fake_run_command): - with mock.patch("odoo_devkit.local_runtime.resolve_image_digest", return_value="sha256:" + "2" * 64): + with mock.patch( + "odoo_devkit.local_runtime.resolve_base_image_provenance", + side_effect=self._resolve_base_image_provenance_fixture, + ): payload = run_native_runtime_publish( manifest=manifest, image_repository="ghcr.io/example/cm-runtime", @@ -1980,13 +2364,13 @@ def fake_run_command( self.assertEqual(addon_build_arg, f"ODOO_ADDON_REPOSITORIES={exact_ref}") expected_build_args = { "ODOO_VERSION=20.0", - "ODOO_BASE_RUNTIME_IMAGE=ghcr.io/example/runtime:20.0-runtime", - "ODOO_BASE_DEVTOOLS_IMAGE=ghcr.io/example/devtools:20.0-devtools", + "ODOO_BASE_RUNTIME_IMAGE=ghcr.io/example/runtime@sha256:" + "2" * 64, + "ODOO_BASE_DEVTOOLS_IMAGE=ghcr.io/example/devtools@sha256:" + "3" * 64, "OPENUPGRADE_ADDON_REPOSITORY=OCA/OpenUpgrade@411f6b8e85cac72dc7aa2e2dc5540001043c327d", - "OPENUPGRADELIB_INSTALL_SPEC=git+https://github.com/OCA/openupgradelib.git@89e649728027a8ab656b3aa4be18f4bd364db417", - "ODOO_PYTHON_SYNC_SKIP_ADDONS=skip_one,skip_two", } self.assertTrue(expected_build_args.issubset(set(captured_build_args))) + self.assertFalse(any(argument.startswith("OPENUPGRADELIB_INSTALL_SPEC=") for argument in captured_build_args)) + self.assertFalse(any(argument.startswith("ODOO_PYTHON_SYNC_SKIP_ADDONS=") for argument in captured_build_args)) self.assertIn( {"repository": "cbusillo/disable_odoo_online", "ref": exact_ref.rsplit("@", 1)[1]}, payload["addon_sources"], @@ -1996,7 +2380,7 @@ def fake_run_command( ["launchplane_settings", "disable_odoo_online", "opw_custom"], ) self.assertEqual(payload["build_flags"]["values"]["odoo_version"], "20.0") - self.assertEqual(payload["build_flags"]["addon_skip_flags"], ["skip_one", "skip_two"]) + self.assertEqual(payload["build_flags"]["addon_skip_flags"], []) def test_native_runtime_publish_requires_explicit_payload_for_non_local_instance(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: @@ -2073,6 +2457,7 @@ def test_native_runtime_publish_synthesizes_context_from_explicit_runtime_payloa runtime_repo_path = self._create_git_repo(temp_root / "runtime-repo") (tenant_repo_path / "addons" / "cm_website").mkdir(parents=True, exist_ok=True) (tenant_repo_path / "addons" / "cm_website" / "__manifest__.py").write_text("{}\n", encoding="utf-8") + self._write_tenant_dependency_workspace(tenant_repo_path, addon_names=("cm_website",)) (tenant_repo_path / "website-bootstrap.toml").write_text( """ schema_version = 1 @@ -2130,8 +2515,9 @@ def fake_run_command( ) -> None: _ = runtime_repo_path, environment_overrides, allowed_return_codes if command[:3] == ["docker", "buildx", "build"]: + self._write_artifact_build_outputs_for_command(command) + if "--metadata-file" in command: captured_build_args.extend(command) - self._write_buildx_metadata_for_command(command) explicit_payload = { "context": "cm_website", @@ -2157,7 +2543,10 @@ def fake_run_command( "odoo_devkit.local_runtime.resolve_source_repository_ref_to_git_sha", return_value="411f6b8e85cac72dc7aa2e2dc5540001043c327d", ): - with mock.patch("odoo_devkit.local_runtime.resolve_image_digest", return_value="sha256:" + "2" * 64): + with mock.patch( + "odoo_devkit.local_runtime.resolve_base_image_provenance", + side_effect=self._resolve_base_image_provenance_fixture, + ): payload = run_native_runtime_publish( manifest=manifest, image_repository="ghcr.io/example/cm-website-runtime", @@ -3146,11 +3535,31 @@ def _write_manifest( def _write_runtime_repo(runtime_repo_path: Path) -> None: (runtime_repo_path / "platform" / "compose").mkdir(parents=True, exist_ok=True) (runtime_repo_path / "platform" / "config").mkdir(parents=True, exist_ok=True) - (runtime_repo_path / "docker").mkdir(parents=True, exist_ok=True) + (runtime_repo_path / "docker" / "runtime-python").mkdir(parents=True, exist_ok=True) (runtime_repo_path / "addons" / "shared").mkdir(parents=True, exist_ok=True) (runtime_repo_path / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8") (runtime_repo_path / "platform" / "compose" / "base.yaml").write_text("services: {}\n", encoding="utf-8") (runtime_repo_path / "docker" / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + (runtime_repo_path / "docker" / "artifact.Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + (runtime_repo_path / "docker" / "dependency-evidence.Dockerfile").write_text( + "ARG ARTIFACT_IMAGE\nFROM ${ARTIFACT_IMAGE} AS artifact\nFROM scratch\n", + encoding="utf-8", + ) + (runtime_repo_path / "docker" / "runtime-python" / "pyproject.toml").write_text( + "[project]\n" + 'name = "runtime-support"\n' + 'version = "0.0.0"\n' + 'requires-python = ">=3.13"\n' + 'dependencies = ["hatchling==1.27.0"]\n\n' + "[tool.uv]\n" + "package = false\n", + encoding="utf-8", + ) + subprocess.run( + ["uv", "lock", "--project", str(runtime_repo_path / "docker" / "runtime-python")], + check=True, + capture_output=True, + ) (runtime_repo_path / "platform" / "config" / "odoo.conf").write_text("[options]\n", encoding="utf-8") (runtime_repo_path / "pyproject.toml").write_text("[project]\nname='runtime-repo'\nversion='0.1.0'\n", encoding="utf-8") (runtime_repo_path / "uv.lock").write_text("version = 1\n", encoding="utf-8") @@ -3177,12 +3586,49 @@ def _write_runtime_repo(runtime_repo_path: Path) -> None: encoding="utf-8", ) + @staticmethod + def _write_tenant_dependency_workspace(tenant_repo_path: Path, *, addon_names: tuple[str, ...]) -> None: + for addon_name in addon_names: + addon_root = tenant_repo_path / "addons" / addon_name + addon_root.mkdir(parents=True, exist_ok=True) + (addon_root / "pyproject.toml").write_text( + "[build-system]\n" + 'requires = ["hatchling==1.27.0"]\n' + 'build-backend = "hatchling.build"\n\n' + "[project]\n" + f'name = "{addon_name}"\n' + 'version = "0.0.0"\n' + "dependencies = []\n\n" + "[tool.uv]\n" + "package = false\n", + encoding="utf-8", + ) + members = ", ".join(json.dumps(f"addons/{addon_name}") for addon_name in addon_names) + (tenant_repo_path / "pyproject.toml").write_text( + "[project]\n" + 'name = "tenant-dependencies"\n' + 'version = "0.0.0"\n' + "dependencies = []\n\n" + "[tool.uv]\n" + "package = false\n\n" + "[tool.uv.workspace]\n" + f"members = [{members}]\n", + encoding="utf-8", + ) + subprocess.run(["uv", "lock", "--project", str(tenant_repo_path)], check=True, capture_output=True) + @staticmethod def _initialize_git_repository(repo_path: Path) -> Path: subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True) subprocess.run(["git", "branch", "-m", "main"], cwd=repo_path, check=True, capture_output=True) subprocess.run(["git", "config", "user.name", "Code"], cwd=repo_path, check=True, capture_output=True) subprocess.run(["git", "config", "user.email", "code@example.com"], cwd=repo_path, check=True, capture_output=True) + subprocess.run( + ["git", "remote", "add", "origin", f"git@github.com:example/{repo_path.name}.git"], + cwd=repo_path, + check=True, + capture_output=True, + ) subprocess.run(["git", "add", "."], cwd=repo_path, check=True, capture_output=True) subprocess.run(["git", "commit", "-m", "initial"], cwd=repo_path, check=True, capture_output=True) return repo_path diff --git a/tests/test_workspace.py b/tests/test_workspace.py index fc1db27..a9420ef 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -641,6 +641,8 @@ def test_workspace_surface_prefers_tenant_root_scripts_when_present(self) -> Non self.assertIn("workspace.local.md", workspace_agents_contents) self.assertIn("Disposable local runtime state", workspace_agents_contents) self.assertIn("ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON", workspace_agents_contents) + self.assertIn("platform dependencies check", workspace_agents_contents) + self.assertIn("schema-v2 provenance", workspace_agents_contents) self.assertIn("sources/tenant/scripts/workspace-sync", workspace_docs_contents) self.assertIn("sources/tenant/scripts/workspace-status", workspace_docs_contents) self.assertIn("ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON", workspace_docs_contents)