diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2c7cc74..85bbdd9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -36,6 +36,23 @@ updates: patterns: - "*" + - package-ecosystem: uv + directory: "/docker/runtime-python" + open-pull-requests-limit: 2 + schedule: + interval: weekly + labels: + - dependencies + - python:uv + groups: + all-version-updates: + patterns: + - "*" + all-security-updates: + applies-to: security-updates + patterns: + - "*" + - package-ecosystem: docker directory: "/" open-pull-requests-limit: 2 diff --git a/.github/github.json b/.github/github.json index fc95ec8..41dbeeb 100644 --- a/.github/github.json +++ b/.github/github.json @@ -8,6 +8,7 @@ "roles": "docs/roles.md", "workspaceCli": "docs/tooling/workspace-cli.md", "artifactInputs": "docs/tooling/artifact-inputs.md", + "buildToolUpgrades": "docs/tooling/build-tool-upgrades.md", "commandPatterns": "docs/tooling/command-patterns.md", "tenantOverlay": "docs/tooling/tenant-overlay.md" }, @@ -16,7 +17,8 @@ "default": "uv sync --locked" }, "test": { - "default": "uv run python -m unittest discover -s tests" + "default": "uv run python -m unittest discover -s tests", + "runtimePythonLock": "uv lock --check --offline --no-config --project docker/runtime-python" }, "format": { "default": "uv run ruff format --check ." @@ -59,7 +61,7 @@ "Dependency Graph", "Dependabot Updates" ], - "requiredStatusChecks": ["test"], + "requiredStatusChecks": ["test", "runtime-python-lock"], "codeScanningMergeProtection": { "ruleset": "Require code scanning results", "tool": "CodeQL", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a79c4c6..7a96451 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,23 @@ permissions: contents: read jobs: + runtime-python-lock: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.13" + + - name: Verify runtime Python lock + run: uv lock --check --offline --no-config --project docker/runtime-python + test: runs-on: ubuntu-latest steps: diff --git a/docs/README.md b/docs/README.md index df41a7b..7d42fb7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,8 @@ lanes live in `launchplane`. command surface and generated-output contract. - [tooling/artifact-inputs.md](tooling/artifact-inputs.md) for the repo-owned runtime and publish source-input contract. +- [tooling/build-tool-upgrades.md](tooling/build-tool-upgrades.md) for central + build-tool ownership and atomic tenant pin synchronization. - [tooling/command-patterns.md](tooling/command-patterns.md) for concrete workspace command examples. - [tooling/tenant-overlay.md](tooling/tenant-overlay.md) for the thin tenant diff --git a/docs/tooling/build-tool-upgrades.md b/docs/tooling/build-tool-upgrades.md new file mode 100644 index 0000000..5a2ab5c --- /dev/null +++ b/docs/tooling/build-tool-upgrades.md @@ -0,0 +1,61 @@ +# Build-Tool Upgrades + +## Ownership + +`docker/runtime-python/pyproject.toml` is the devkit-owned catalog for Python +build tools that must be available at exact versions during tenant artifact +assembly. Its independent `uv.lock` records the approved artifacts. Tenant and +shared-addon projects retain exact `[build-system].requires` declarations so +the publish provenance check can prove that every requested backend is present +in either the support/runtime catalog or the tenant catalog. + +Dependabot monitors `docker/runtime-python` separately from the devkit root +project. The `runtime-python-lock` CI job rejects catalog changes whose lock is +not current. + +## Tenant Synchronization + +Plan a tenant update from a devkit checkout and an exact candidate commit: + +```bash +uv run python -m odoo_devkit.build_tool_sync \ + --tenant-root ../odoo-tenant-opw \ + --devkit-root . \ + --devkit-ref <40-character-devkit-commit> +``` + +Add `--check` to exit nonzero when any synchronized value differs, +`--check-build-tools` to ignore unrelated devkit-ref movement and fail only on +centrally managed addon pin drift, or `--apply` to update the tenant's +`workspace.toml` devkit/runtime refs and matching addon build-tool pins as one +transaction. Apply mode reparses every changed TOML file, verifies the exact +catalog contract, and runs the tenant's offline uv lock check. Any failure +restores every touched file. + +The command owns deterministic file transformation and validation only. +Launchplane owns repository inventory, credentials, branch creation, pull +requests, merge ordering, retries, and audited rollout state. + +## Rollout Order + +1. Pin each tenant to its current known-good devkit commit. +2. Open and validate the devkit catalog/lock update. +3. Run the synchronization command against the devkit candidate commit to + preview and validate the downstream changes. +4. Merge the devkit update and capture the resulting commit on `main`. +5. Rerun synchronization with that final commit, then merge the green tenant + updates. Do not permanently pin a pull-request head that may be replaced by + squash or rebase merge. + +Tenant Dependabot may ignore centrally owned build tools only after the +runtime-python Dependabot lane, lock validation, and synchronization path are +operational. Security updates then originate from the central catalog and use +the same downstream rollout. Tenants should also run a scheduled sync check +against devkit `main` so a centrally available update cannot remain silent. + +## Recovery + +A failed tenant rollout leaves its tracked files unchanged. A merged tenant can +roll back by restoring its prior devkit commit and addon build-tool pins through +the same synchronization command. Never weaken the exact catalog check or use +a floating branch ref as a transition mechanism. diff --git a/odoo_devkit/build_tool_sync.py b/odoo_devkit/build_tool_sync.py new file mode 100644 index 0000000..d2567cf --- /dev/null +++ b/odoo_devkit/build_tool_sync.py @@ -0,0 +1,468 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import tempfile +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .dependency_workspace import DependencyWorkspaceError, require_staged_build_requirements_supplied + +_EXACT_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.!+_-]*)$" +) +_FULL_GIT_SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$") +_SECTION_PATTERN_TEMPLATE = r"(?ms)^\[{section}\]\s*$.*?(?=^\[|\Z)" +_REF_PATTERN = re.compile(r"(?m)^(?P\s*ref\s*=\s*)(?P[\"'])(?P[^\"']+)(?P=quote)(?P\s*(?:#.*)?)$") +_IGNORED_ADDON_PATH_PARTS = frozenset({".git", ".venv", "build", "dist", "__pycache__"}) + + +class BuildToolSyncError(ValueError): + pass + + +@dataclass(frozen=True) +class BuildToolChange: + path: str + kind: str + before: str + after: str + + def to_dict(self) -> dict[str, str]: + return { + "path": self.path, + "kind": self.kind, + "before": self.before, + "after": self.after, + } + + +@dataclass(frozen=True) +class BuildToolSyncPlan: + tenant_root: Path + devkit_root: Path + devkit_ref: str + catalog: dict[str, str] + rendered_files: dict[Path, str] + changes: tuple[BuildToolChange, ...] + + @property + def changed(self) -> bool: + return bool(self.changes) + + @property + def build_tool_changed(self) -> bool: + return any(change.kind.startswith("build-system.requires:") for change in self.changes) + + def to_dict(self) -> dict[str, object]: + return { + "schema_version": 1, + "changed": self.changed, + "build_tool_changed": self.build_tool_changed, + "devkit_ref": self.devkit_ref, + "catalog": dict(sorted(self.catalog.items())), + "changes": [change.to_dict() for change in self.changes], + } + + +def plan_build_tool_sync(*, tenant_root: Path, devkit_root: Path, devkit_ref: str) -> BuildToolSyncPlan: + tenant_root = _resolve_regular_directory(tenant_root, label="Tenant root") + devkit_root = _resolve_regular_directory(devkit_root, label="Devkit root") + if _FULL_GIT_SHA_PATTERN.fullmatch(devkit_ref) is None: + raise BuildToolSyncError("Devkit ref must be an exact lowercase 40-character Git commit.") + _require_devkit_checkout(devkit_root=devkit_root, devkit_ref=devkit_ref) + + catalog = _load_exact_catalog(devkit_root / "docker" / "runtime-python" / "pyproject.toml") + rendered_files: dict[Path, str] = {} + changes: list[BuildToolChange] = [] + + workspace_path = tenant_root / "workspace.toml" + workspace_text = _read_regular_text(workspace_path) + rendered_workspace, workspace_changes = _render_workspace_refs( + text=workspace_text, + path=workspace_path, + tenant_root=tenant_root, + devkit_ref=devkit_ref, + ) + if workspace_changes: + rendered_files[workspace_path] = rendered_workspace + changes.extend(workspace_changes) + + addons_root = _resolve_regular_directory(tenant_root / "addons", label="Tenant addons root") + for pyproject_path in sorted(addons_root.rglob("pyproject.toml")): + if _IGNORED_ADDON_PATH_PARTS.intersection(pyproject_path.relative_to(addons_root).parts): + continue + original_text = _read_regular_text(pyproject_path) + rendered_text, file_changes = _render_addon_requirements( + text=original_text, + path=pyproject_path, + tenant_root=tenant_root, + catalog=catalog, + ) + if file_changes: + rendered_files[pyproject_path] = rendered_text + changes.extend(file_changes) + + return BuildToolSyncPlan( + tenant_root=tenant_root, + devkit_root=devkit_root, + devkit_ref=devkit_ref, + catalog=catalog, + rendered_files=rendered_files, + changes=tuple(changes), + ) + + +def apply_build_tool_sync(plan: BuildToolSyncPlan) -> None: + originals = {path: path.read_bytes() for path in plan.rendered_files} + try: + for path, rendered_text in plan.rendered_files.items(): + _atomic_write_text(path=path, content=rendered_text) + _validate_applied_plan(plan) + except Exception as error: + rollback_failures: list[str] = [] + for path, original_bytes in originals.items(): + try: + _atomic_write_bytes(path=path, content=original_bytes) + except OSError as rollback_error: + rollback_failures.append(f"{path}: {rollback_error}") + if rollback_failures: + raise BuildToolSyncError( + f"Build-tool synchronization failed and rollback was incomplete: {'; '.join(rollback_failures)}" + ) from error + raise + + +def _load_exact_catalog(path: Path) -> dict[str, str]: + payload = _load_toml(path) + project = payload.get("project") + dependencies = project.get("dependencies", []) if isinstance(project, dict) else [] + if not isinstance(dependencies, list) or not all(isinstance(value, str) for value in dependencies): + raise BuildToolSyncError(f"{path} project.dependencies must be a string array.") + catalog: dict[str, str] = {} + for dependency in dependencies: + match = _EXACT_REQUIREMENT_PATTERN.fullmatch(dependency.strip()) + if match is None: + continue + raw_name = match.group("name") + version = match.group("version") + if raw_name is None or version is None: + raise BuildToolSyncError(f"Unable to parse exact build-tool requirement: {dependency}") + name = _normalize_requirement_name(raw_name) + previous = catalog.get(name) + if previous is None: + catalog[name] = version + elif previous != version: + raise BuildToolSyncError(f"Build-tool catalog supplies conflicting versions for {name}: {previous}, {version}.") + if not catalog: + raise BuildToolSyncError(f"{path} does not contain any exact build-tool requirements.") + return catalog + + +def _render_workspace_refs( + *, + text: str, + path: Path, + tenant_root: Path, + devkit_ref: str, +) -> tuple[str, tuple[BuildToolChange, ...]]: + _parse_toml_text(text=text, path=path) + rendered = text + changes: list[BuildToolChange] = [] + for section in ("repos.devkit", "repos.runtime"): + rendered, previous = _replace_section_ref(text=rendered, section=section, devkit_ref=devkit_ref, path=path) + if previous != devkit_ref: + changes.append( + BuildToolChange( + path=path.relative_to(tenant_root).as_posix(), + kind=f"{section}.ref", + before=previous, + after=devkit_ref, + ) + ) + _parse_toml_text(text=rendered, path=path) + return rendered, tuple(changes) + + +def _replace_section_ref(*, text: str, section: str, devkit_ref: str, path: Path) -> tuple[str, str]: + section_pattern = re.compile(_SECTION_PATTERN_TEMPLATE.format(section=re.escape(section))) + section_match = section_pattern.search(text) + if section_match is None: + raise BuildToolSyncError(f"{path} is missing [{section}].") + section_text = section_match.group() + ref_match = _REF_PATTERN.search(section_text) + if ref_match is None: + raise BuildToolSyncError(f"{path} [{section}] is missing ref.") + previous = ref_match.group("value") + replacement = ( + f"{ref_match.group('prefix')}{ref_match.group('quote')}{devkit_ref}{ref_match.group('quote')}{ref_match.group('suffix')}" + ) + rendered_section = section_text[: ref_match.start()] + replacement + section_text[ref_match.end() :] + return text[: section_match.start()] + rendered_section + text[section_match.end() :], previous + + +def _render_addon_requirements( + *, + text: str, + path: Path, + tenant_root: Path, + catalog: dict[str, str], +) -> tuple[str, tuple[BuildToolChange, ...]]: + payload = _parse_toml_text(text=text, path=path) + build_system = payload.get("build-system") + requirements = build_system.get("requires", []) if isinstance(build_system, dict) else [] + if not isinstance(requirements, list) or not all(isinstance(value, str) for value in requirements): + raise BuildToolSyncError(f"{path} build-system.requires must be a string array.") + + rendered = text + changes: list[BuildToolChange] = [] + for requirement in requirements: + match = _EXACT_REQUIREMENT_PATTERN.fullmatch(requirement.strip()) + requirement_name = _requirement_name(requirement) + if requirement_name not in catalog: + continue + if match is None: + raise BuildToolSyncError(f"{path} centrally managed build requirement must use an exact version: {requirement}") + next_requirement = f"{match.group('name')}=={catalog[requirement_name]}" + if requirement == next_requirement: + continue + rendered = _replace_build_system_requirement( + text=rendered, + path=path, + previous=requirement, + replacement=next_requirement, + ) + changes.append( + BuildToolChange( + path=path.relative_to(tenant_root).as_posix(), + kind=f"build-system.requires:{requirement_name}", + before=requirement, + after=next_requirement, + ) + ) + _parse_toml_text(text=rendered, path=path) + return rendered, tuple(changes) + + +def _replace_build_system_requirement(*, text: str, path: Path, previous: str, replacement: str) -> str: + section_pattern = re.compile(_SECTION_PATTERN_TEMPLATE.format(section=re.escape("build-system"))) + section_match = section_pattern.search(text) + if section_match is None: + raise BuildToolSyncError(f"{path} is missing [build-system].") + section_text = section_match.group() + pattern = re.compile(rf"(?P[\"']){re.escape(previous)}(?P=quote)") + matches = tuple(pattern.finditer(section_text)) + if len(matches) != 1: + raise BuildToolSyncError(f"Expected exactly one {previous!r} string in {path} [build-system], found {len(matches)}.") + match = matches[0] + quote = match.group("quote") + rendered_section = section_text[: match.start()] + f"{quote}{replacement}{quote}" + section_text[match.end() :] + return text[: section_match.start()] + rendered_section + text[section_match.end() :] + + +def _requirement_name(requirement: str) -> str: + match = re.match(r"^\s*([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)", requirement) + return _normalize_requirement_name(match.group(1)) if match is not None else "" + + +def _normalize_requirement_name(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def _validate_applied_plan(plan: BuildToolSyncPlan) -> None: + for path in plan.rendered_files: + _load_toml(path) + try: + require_staged_build_requirements_supplied( + support_root=plan.devkit_root / "docker" / "runtime-python", + tenant_root=plan.tenant_root, + ) + except DependencyWorkspaceError as error: + raise BuildToolSyncError(str(error)) from error + try: + result = subprocess.run( + ["uv", "lock", "--check", "--offline", "--no-config"], + cwd=plan.tenant_root, + capture_output=True, + text=True, + env=_sanitized_command_environment(), + timeout=120, + ) + except FileNotFoundError as error: + raise BuildToolSyncError("uv is required for tenant lock validation.") from error + except subprocess.TimeoutExpired as error: + raise BuildToolSyncError("Tenant lock validation timed out after 120 seconds.") from error + if result.returncode != 0: + message = result.stderr.strip() or result.stdout.strip() or "uv lock check failed" + raise BuildToolSyncError(f"Tenant lock validation failed: {message}") + + +def _read_regular_text(path: Path) -> str: + if path.is_symlink() or not path.is_file(): + raise BuildToolSyncError(f"Expected a tracked-style regular file: {path}") + return path.read_text() + + +def _load_toml(path: Path) -> dict[str, Any]: + return _parse_toml_text(text=_read_regular_text(path), path=path) + + +def _parse_toml_text(*, text: str, path: Path) -> dict[str, Any]: + try: + return tomllib.loads(text) + except tomllib.TOMLDecodeError as error: + raise BuildToolSyncError(f"Invalid TOML in {path}: {error}") from error + + +def _resolve_regular_directory(path: Path, *, label: str) -> Path: + if path.is_symlink() or not path.is_dir(): + raise BuildToolSyncError(f"{label} must be a regular directory: {path}") + return path.resolve() + + +def _require_devkit_checkout(*, devkit_root: Path, devkit_ref: str) -> None: + repository_root = _git_output(devkit_root, "rev-parse", "--show-toplevel") + if Path(repository_root).resolve() != devkit_root: + raise BuildToolSyncError(f"Devkit root must be the Git worktree root: {devkit_root}") + head_ref = _git_output(devkit_root, "rev-parse", "HEAD") + if head_ref != devkit_ref: + raise BuildToolSyncError(f"Devkit checkout HEAD {head_ref} does not match requested ref {devkit_ref}.") + catalog_paths = ("docker/runtime-python/pyproject.toml", "docker/runtime-python/uv.lock") + for catalog_path in catalog_paths: + result = _run_git(devkit_root, "ls-files", "--error-unmatch", "--", catalog_path) + if result.returncode != 0: + raise BuildToolSyncError(f"Devkit catalog input must be tracked at {devkit_ref}: {catalog_path}") + for diff_arguments in (("diff", "--quiet", "--", *catalog_paths), ("diff", "--cached", "--quiet", "--", *catalog_paths)): + result = _run_git(devkit_root, *diff_arguments) + if result.returncode == 1: + raise BuildToolSyncError("Devkit runtime-python catalog or lock has uncommitted changes.") + if result.returncode != 0: + message = result.stderr.strip() or result.stdout.strip() or "git diff failed" + raise BuildToolSyncError(f"Unable to validate devkit catalog state: {message}") + + +def _git_output(root: Path, *arguments: str) -> str: + result = _run_git(root, *arguments) + if result.returncode != 0: + message = result.stderr.strip() or result.stdout.strip() or "git command failed" + raise BuildToolSyncError(f"Unable to validate devkit checkout: {message}") + return result.stdout.strip() + + +def _run_git(root: Path, *arguments: str) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + ["git", *arguments], + cwd=root, + capture_output=True, + text=True, + env=_git_command_environment(), + timeout=30, + ) + except FileNotFoundError as error: + raise BuildToolSyncError("git is required for devkit checkout validation.") from error + except subprocess.TimeoutExpired as error: + raise BuildToolSyncError("Devkit Git validation timed out after 30 seconds.") from error + + +def _sanitized_command_environment() -> dict[str, str]: + 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" + return environment + + +def _git_command_environment() -> 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 _atomic_write_text(*, path: Path, content: str) -> None: + _atomic_write_bytes(path=path, content=content.encode()) + + +def _atomic_write_bytes(*, path: Path, content: bytes) -> None: + file_mode = path.stat().st_mode + with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", delete=False) as temporary_file: + temporary_path = Path(temporary_file.name) + temporary_file.write(content) + try: + os.chmod(temporary_path, file_mode) + os.replace(temporary_path, path) + finally: + temporary_path.unlink(missing_ok=True) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Synchronize tenant build-tool pins from the devkit runtime catalog.") + parser.add_argument("--tenant-root", type=Path, required=True) + parser.add_argument("--devkit-root", type=Path, required=True) + parser.add_argument("--devkit-ref", required=True) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--check", action="store_true", help="Exit nonzero when synchronization changes are required.") + mode.add_argument( + "--check-build-tools", + action="store_true", + help="Exit nonzero only when centrally managed addon build-tool pins differ from the catalog.", + ) + mode.add_argument("--apply", action="store_true", help="Apply the planned changes and validate the result atomically.") + return parser + + +def main() -> None: + arguments = build_parser().parse_args() + try: + plan = plan_build_tool_sync( + tenant_root=arguments.tenant_root, + devkit_root=arguments.devkit_root, + devkit_ref=arguments.devkit_ref, + ) + if arguments.apply and plan.changed: + apply_build_tool_sync(plan) + print(json.dumps(plan.to_dict(), indent=2, sort_keys=True)) + except BuildToolSyncError as error: + raise SystemExit(str(error)) from error + if arguments.check and plan.changed: + raise SystemExit(1) + if arguments.check_build_tools and plan.build_tool_changed: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_build_tool_sync.py b/tests/test_build_tool_sync.py new file mode 100644 index 0000000..2e95231 --- /dev/null +++ b/tests/test_build_tool_sync.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import odoo_devkit.build_tool_sync as build_tool_sync_module +from odoo_devkit.build_tool_sync import BuildToolSyncError, apply_build_tool_sync, plan_build_tool_sync + + +class BuildToolSyncTest(unittest.TestCase): + def test_plan_updates_workspace_refs_and_managed_addon_requirements(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + tenant_root, devkit_root, devkit_ref = self._write_fixture(Path(temporary_directory)) + + plan = plan_build_tool_sync( + tenant_root=tenant_root, + devkit_root=devkit_root, + devkit_ref=devkit_ref, + ) + + self.assertTrue(plan.changed) + self.assertEqual(3, len(plan.changes)) + self.assertEqual("1.32.0", plan.catalog["hatchling"]) + rendered_addon = plan.rendered_files[plan.tenant_root / "addons" / "example" / "pyproject.toml"] + self.assertIn('requires = ["hatchling==1.32.0", "tenant-builder==2.0.0"]', rendered_addon) + self.assertIn("# preserved", rendered_addon) + rendered_workspace = plan.rendered_files[plan.tenant_root / "workspace.toml"] + self.assertEqual(2, rendered_workspace.count(f'ref = "{devkit_ref}"')) + + def test_apply_is_atomic_when_validation_fails(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + tenant_root, devkit_root, devkit_ref = self._write_fixture(Path(temporary_directory)) + plan = plan_build_tool_sync( + tenant_root=tenant_root, + devkit_root=devkit_root, + devkit_ref=devkit_ref, + ) + originals = {path: path.read_bytes() for path in plan.rendered_files} + + with ( + mock.patch("odoo_devkit.build_tool_sync._validate_applied_plan", side_effect=BuildToolSyncError("boom")), + self.assertRaisesRegex(BuildToolSyncError, "boom"), + ): + apply_build_tool_sync(plan) + + self.assertEqual(originals, {path: path.read_bytes() for path in plan.rendered_files}) + + def test_apply_validates_and_writes_all_changes(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + tenant_root, devkit_root, devkit_ref = self._write_fixture(Path(temporary_directory)) + plan = plan_build_tool_sync( + tenant_root=tenant_root, + devkit_root=devkit_root, + devkit_ref=devkit_ref, + ) + + apply_build_tool_sync(plan) + + self.assertIn("hatchling==1.32.0", (tenant_root / "addons" / "example" / "pyproject.toml").read_text()) + self.assertEqual(2, (tenant_root / "workspace.toml").read_text().count(f'ref = "{devkit_ref}"')) + + def test_apply_reports_incomplete_rollback(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + tenant_root, devkit_root, devkit_ref = self._write_fixture(Path(temporary_directory)) + plan = plan_build_tool_sync(tenant_root=tenant_root, devkit_root=devkit_root, devkit_ref=devkit_ref) + real_atomic_write = build_tool_sync_module._atomic_write_bytes + call_count = 0 + + def fail_first_rollback(*, path: Path, content: bytes) -> None: + nonlocal call_count + call_count += 1 + if call_count == len(plan.rendered_files) + 1: + raise OSError("restore failed") + real_atomic_write(path=path, content=content) + + with ( + mock.patch("odoo_devkit.build_tool_sync._validate_applied_plan", side_effect=BuildToolSyncError("boom")), + mock.patch("odoo_devkit.build_tool_sync._atomic_write_bytes", side_effect=fail_first_rollback), + self.assertRaisesRegex(BuildToolSyncError, "rollback was incomplete"), + ): + apply_build_tool_sync(plan) + + def test_plan_rejects_non_exact_managed_requirement(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + tenant_root, devkit_root, devkit_ref = self._write_fixture(Path(temporary_directory)) + addon_path = tenant_root / "addons" / "example" / "pyproject.toml" + addon_path.write_text(addon_path.read_text().replace("hatchling==1.31.0", "hatchling>=1.31.0")) + + with self.assertRaisesRegex(BuildToolSyncError, "must use an exact version"): + plan_build_tool_sync( + tenant_root=tenant_root, + devkit_root=devkit_root, + devkit_ref=devkit_ref, + ) + + def test_plan_rejects_non_commit_devkit_ref(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + tenant_root, devkit_root, _devkit_ref = self._write_fixture(Path(temporary_directory)) + + with self.assertRaisesRegex(BuildToolSyncError, "40-character Git commit"): + plan_build_tool_sync(tenant_root=tenant_root, devkit_root=devkit_root, devkit_ref="main") + + def test_plan_rejects_mismatched_devkit_checkout(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + tenant_root, devkit_root, _devkit_ref = self._write_fixture(Path(temporary_directory)) + + with self.assertRaisesRegex(BuildToolSyncError, "does not match requested ref"): + plan_build_tool_sync(tenant_root=tenant_root, devkit_root=devkit_root, devkit_ref="d" * 40) + + def test_plan_rejects_dirty_devkit_catalog(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + tenant_root, devkit_root, devkit_ref = self._write_fixture(Path(temporary_directory)) + catalog_path = devkit_root / "docker" / "runtime-python" / "pyproject.toml" + catalog_path.write_text(catalog_path.read_text().replace("1.32.0", "1.33.0")) + + with self.assertRaisesRegex(BuildToolSyncError, "uncommitted changes"): + plan_build_tool_sync(tenant_root=tenant_root, devkit_root=devkit_root, devkit_ref=devkit_ref) + + def test_plan_rejects_untracked_devkit_catalog_input(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + tenant_root, devkit_root, devkit_ref = self._write_fixture(Path(temporary_directory)) + subprocess.run( + ["git", "rm", "--cached", "--quiet", "docker/runtime-python/uv.lock"], + cwd=devkit_root, + check=True, + ) + + with self.assertRaisesRegex(BuildToolSyncError, "must be tracked"): + plan_build_tool_sync(tenant_root=tenant_root, devkit_root=devkit_root, devkit_ref=devkit_ref) + + def test_plan_ignores_ambient_git_repository_context(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + tenant_root, devkit_root, devkit_ref = self._write_fixture(root) + + with mock.patch.dict(os.environ, {"GIT_DIR": str(root / "wrong-repository")}, clear=False): + plan = plan_build_tool_sync(tenant_root=tenant_root, devkit_root=devkit_root, devkit_ref=devkit_ref) + + self.assertTrue(plan.build_tool_changed) + + def test_ref_only_plan_is_not_build_tool_drift(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + tenant_root, devkit_root, devkit_ref = self._write_fixture(Path(temporary_directory)) + addon_path = tenant_root / "addons" / "example" / "pyproject.toml" + addon_path.write_text(addon_path.read_text().replace("hatchling==1.31.0", "hatchling==1.32.0")) + + plan = plan_build_tool_sync(tenant_root=tenant_root, devkit_root=devkit_root, devkit_ref=devkit_ref) + + self.assertTrue(plan.changed) + self.assertFalse(plan.build_tool_changed) + + @staticmethod + def _write_fixture(root: Path) -> tuple[Path, Path, str]: + tenant_root = root / "tenant" + devkit_root = root / "devkit" + (tenant_root / "addons" / "example").mkdir(parents=True) + (devkit_root / "docker" / "runtime-python").mkdir(parents=True) + (tenant_root / "workspace.toml").write_text( + '[repos.devkit]\nname = "odoo-devkit"\npath = "../odoo-devkit"\nref = "main"\n\n' + '[repos.runtime]\nname = "odoo-devkit"\npath = "../odoo-devkit"\nref = "main"\n' + ) + (tenant_root / "pyproject.toml").write_text('[project]\nname = "tenant"\nversion = "0.0.0"\ndependencies = []\n') + (tenant_root / "addons" / "example" / "pyproject.toml").write_text( + '[build-system]\nrequires = ["hatchling==1.31.0", "tenant-builder==2.0.0"]\n' + 'build-backend = "hatchling.build"\n\n[project]\nname = "example"\nversion = "0.0.0"\n' + "dependencies = [] # preserved\n" + ) + (devkit_root / "docker" / "runtime-python" / "pyproject.toml").write_text( + '[project]\nname = "runtime"\nversion = "0.0.0"\n' + 'dependencies = ["hatchling==1.32.0", "tenant-builder==2.0.0", "passlib>=1.7.4"]\n' + ) + (devkit_root / "docker" / "runtime-python" / "uv.lock").write_text("version = 1\n") + subprocess.run( + ["uv", "lock", "--offline", "--no-config"], + cwd=tenant_root, + check=True, + capture_output=True, + text=True, + ) + subprocess.run(["git", "init", "--quiet"], cwd=devkit_root, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=devkit_root, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=devkit_root, check=True) + subprocess.run(["git", "add", "docker/runtime-python"], cwd=devkit_root, check=True) + subprocess.run(["git", "commit", "--quiet", "-m", "fixture"], cwd=devkit_root, check=True) + devkit_ref = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=devkit_root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + return tenant_root, devkit_root, devkit_ref + + +if __name__ == "__main__": + unittest.main()