From b60e2289624ede9d4f5cea077c8cce6578559dc3 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sun, 7 Jun 2026 09:19:46 -0400 Subject: [PATCH] Allow LP payloads to define publish contexts --- docs/tooling/workspace-cli.md | 4 + odoo_devkit/local_runtime.py | 56 ++++++++++- tests/test_runtime.py | 173 ++++++++++++++++++++++++++++++++-- 3 files changed, 222 insertions(+), 11 deletions(-) diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index 92be504..3ad5d4e 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -234,6 +234,10 @@ Notes 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. +- When Launchplane supplies `ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON`, publish can + synthesize the selected manifest context from that explicit payload instead of + requiring every Launchplane-owned product context to be listed in the shared + devkit stack. Unknown contexts still fail closed without the explicit payload. - Publish-time GHCR credentials can be split by purpose. Private base image reads prefer `GHCR_READ_TOKEN`, artifact image pushes prefer `GHCR_TOKEN`, and private source checkout secrets still belong in the transient runtime diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index 9b5adf9..c6ea068 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -783,6 +783,7 @@ def load_runtime_context( effective_stack_definition = resolve_manifest_runtime_stack_definition( manifest=manifest, stack_definition=loaded_stack.stack_definition, + allow_runtime_payload_context=explicit_runtime_environment_payload_is_configured(), ) website_bootstrap = load_website_bootstrap_definition(manifest=manifest) runtime_selection = resolve_runtime_selection( @@ -809,7 +810,25 @@ def load_runtime_context( ) -def resolve_manifest_runtime_stack_definition(*, manifest: WorkspaceManifest, stack_definition: StackDefinition) -> StackDefinition: +def resolve_manifest_runtime_stack_definition( + *, + manifest: WorkspaceManifest, + stack_definition: StackDefinition, + allow_runtime_payload_context: bool = False, +) -> StackDefinition: + effective_stack_definition = resolve_manifest_runtime_addons_paths( + manifest=manifest, + stack_definition=stack_definition, + ) + if allow_runtime_payload_context: + return synthesize_runtime_payload_context( + manifest=manifest, + stack_definition=effective_stack_definition, + ) + return effective_stack_definition + + +def resolve_manifest_runtime_addons_paths(*, manifest: WorkspaceManifest, stack_definition: StackDefinition) -> StackDefinition: project_addons_paths = resolve_manifest_container_addons_paths(manifest=manifest) if not project_addons_paths: return stack_definition @@ -839,6 +858,41 @@ def resolve_manifest_runtime_stack_definition(*, manifest: WorkspaceManifest, st ) +def explicit_runtime_environment_payload_is_configured() -> bool: + return bool(os.environ.get(RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR, "").strip()) + + +def synthesize_runtime_payload_context(*, manifest: WorkspaceManifest, stack_definition: StackDefinition) -> StackDefinition: + if manifest.runtime.context in stack_definition.contexts: + return stack_definition + contexts = dict(stack_definition.contexts) + contexts[manifest.runtime.context] = ContextDefinition( + database=manifest.runtime.database, + install_modules=(), + runtime_env={}, + odoo_overrides=empty_odoo_override_definition(), + update_modules="AUTO", + instances={ + manifest.runtime.instance: InstanceDefinition( + database=manifest.runtime.database, + install_modules_add=(), + runtime_env={}, + odoo_overrides=empty_odoo_override_definition(), + ) + }, + ) + return StackDefinition( + schema_version=stack_definition.schema_version, + odoo_version=stack_definition.odoo_version, + state_root=stack_definition.state_root, + addons_path=stack_definition.addons_path, + runtime_env=stack_definition.runtime_env, + odoo_overrides=stack_definition.odoo_overrides, + required_env_keys=stack_definition.required_env_keys, + contexts=contexts, + ) + + def load_website_bootstrap_definition(*, manifest: WorkspaceManifest) -> WebsiteBootstrapDefinition | None: tenant_repo_path = manifest.tenant_repo.resolve_path(manifest_directory=manifest.manifest_directory) candidate_paths: list[Path] = [] diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 487a786..276c239 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -699,9 +699,7 @@ def test_native_runtime_select_writes_runtime_env_and_pycharm_conf(self) -> None self.assertIn("DOCKER_IMAGE=odoo-opw-local", runtime_env_text) self.assertIn("ODOO_ADDON_REPOSITORIES=cbusillo/disable_odoo_online@main", runtime_env_text) self.assertIn(f"ODOO_PROJECT_ADDONS_HOST_PATH={(tenant_repo_path / 'addons').resolve()}", runtime_env_text) - addons_path_line = next( - line for line in runtime_env_text.splitlines() if line.startswith("ODOO_ADDONS_PATH=") - ) + addons_path_line = next(line for line in runtime_env_text.splitlines() if line.startswith("ODOO_ADDONS_PATH=")) self.assertIn("/opt/project/addons", addons_path_line) self.assertIn("/opt/project/addons/shared", addons_path_line) self.assertIn("/opt/launchplane/addons", addons_path_line) @@ -871,9 +869,7 @@ def test_native_runtime_select_prefers_manifest_mounts_over_runtime_repo_default self.assertIn("DOCKER_IMAGE=odoo-opw-local", runtime_env_text) self.assertIn(f"ODOO_PROJECT_ADDONS_HOST_PATH={(tenant_repo_path / 'addons').resolve()}", runtime_env_text) self.assertIn(f"ODOO_SHARED_ADDONS_HOST_PATH={shared_addons_repo_path.resolve()}", runtime_env_text) - addons_path_line = next( - line for line in runtime_env_text.splitlines() if line.startswith("ODOO_ADDONS_PATH=") - ) + addons_path_line = next(line for line in runtime_env_text.splitlines() if line.startswith("ODOO_ADDONS_PATH=")) self.assertIn("/opt/project/addons", addons_path_line) self.assertIn("/opt/project/addons/shared", addons_path_line) self.assertIn("/opt/launchplane/addons", addons_path_line) @@ -1606,6 +1602,161 @@ def fake_run_command( payload["addon_sources"], ) + def test_native_runtime_publish_rejects_unknown_context_without_explicit_runtime_payload(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + temp_root = Path(temporary_directory) + tenant_repo_path = self._create_git_repo(temp_root / "tenant-repo") + 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") + subprocess.run(["git", "add", "."], cwd=tenant_repo_path, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "tenant website"], cwd=tenant_repo_path, check=True, capture_output=True) + self._write_runtime_repo(runtime_repo_path) + subprocess.run(["git", "add", "."], cwd=runtime_repo_path, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "runtime files"], cwd=runtime_repo_path, check=True, capture_output=True) + manifest_path = self._write_manifest( + tenant_repo_path=tenant_repo_path, + runtime_repo_path=runtime_repo_path, + addons_paths=("addons",), + context_name="cm_website", + database_name="cm_website_testing", + instance_name="testing", + ) + 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) + + with mock.patch.dict(os.environ, {local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: ""}): + with mock.patch( + "odoo_devkit.local_runtime.load_environment", + return_value=local_runtime.LoadedEnvironment( + env_file_path=self.control_plane_root / ".generated" / "runtime-env" / "cm_website.testing.env", + merged_values={ + "ODOO_MASTER_PASSWORD": "control-plane-master", + "ODOO_DB_USER": "odoo", + "ODOO_DB_PASSWORD": "control-plane-secret", + }, + collisions=(), + ), + ): + with self.assertRaisesRegex(ValueError, "Unknown context 'cm_website'"): + run_native_runtime_publish( + manifest=manifest, + image_repository="ghcr.io/example/cm-website-runtime", + image_tag="cm_website-20260606-abcdef", + output_file=None, + no_cache=False, + platforms=("linux/amd64",), + ) + + def test_native_runtime_publish_synthesizes_context_from_explicit_runtime_payload(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + temp_root = Path(temporary_directory) + tenant_repo_path = self._create_git_repo(temp_root / "tenant-repo") + 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") + (tenant_repo_path / "website-bootstrap.toml").write_text( + """ +schema_version = 1 +tenant = "cm_website" + +[odoo] +install_modules = ["cm_website"] + +[website] +name = "Cell Mechanic" +""".strip() + + "\n", + encoding="utf-8", + ) + subprocess.run(["git", "add", "."], cwd=tenant_repo_path, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "tenant website"], cwd=tenant_repo_path, check=True, capture_output=True) + self._write_runtime_repo(runtime_repo_path) + subprocess.run(["git", "add", "."], cwd=runtime_repo_path, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "runtime files"], cwd=runtime_repo_path, check=True, capture_output=True) + manifest_path = self._write_manifest( + tenant_repo_path=tenant_repo_path, + runtime_repo_path=runtime_repo_path, + addons_paths=("addons",), + context_name="cm_website", + database_name="cm_website_testing", + instance_name="testing", + ) + (tenant_repo_path / "artifact-inputs.toml").write_text( + """ +schema_version = 1 +sources = [ + { repository = "cbusillo/disable_odoo_online", selector = "main" }, +] +""".strip() + + "\n", + encoding="utf-8", + ) + subprocess.run( + ["git", "add", "workspace.toml", "artifact-inputs.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) + + captured_build_args: list[str] = [] + + def fake_run_command( + *, + runtime_repo_path: Path, + command: list[str], + environment_overrides: object | None = None, + allowed_return_codes: object | None = None, + ) -> None: + _ = runtime_repo_path, environment_overrides, allowed_return_codes + if command[:3] == ["docker", "buildx", "build"]: + captured_build_args.extend(command) + self._write_buildx_metadata_for_command(command) + + explicit_payload = { + "context": "cm_website", + "instance": "testing", + "environment": { + "ODOO_MASTER_PASSWORD": "control-plane-master", + "ODOO_DB_USER": "odoo", + "ODOO_DB_PASSWORD": "control-plane-secret", + "GITHUB_TOKEN": "gh-token", + "ODOO_BASE_RUNTIME_IMAGE": "ghcr.io/example/runtime:19.0-runtime", + "ODOO_BASE_DEVTOOLS_IMAGE": "ghcr.io/example/devtools:19.0-devtools", + }, + } + with mock.patch.dict( + os.environ, + {local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: json.dumps(explicit_payload)}, + ): + 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_source_repository_ref_to_git_sha", + return_value="411f6b8e85cac72dc7aa2e2dc5540001043c327d", + ): + with mock.patch("odoo_devkit.local_runtime.resolve_image_digest", return_value="sha256:" + "2" * 64): + payload = run_native_runtime_publish( + manifest=manifest, + image_repository="ghcr.io/example/cm-website-runtime", + image_tag="cm_website-20260606-abcdef", + output_file=None, + no_cache=False, + platforms=("linux/amd64",), + ) + + self.assertTrue(payload["artifact_id"].startswith("artifact-cm_website-")) + self.assertEqual(payload["image"]["repository"], "ghcr.io/example/cm-website-runtime") + self.assertEqual(payload["image"]["tags"], ["cm_website-20260606-abcdef"]) + self.assertIn( + "ODOO_ADDON_REPOSITORIES=cbusillo/disable_odoo_online@411f6b8e85cac72dc7aa2e2dc5540001043c327d", + captured_build_args, + ) + def test_resolve_source_repository_ref_to_git_sha_rejects_missing_remote_ref(self) -> None: with mock.patch( "odoo_devkit.local_runtime.subprocess.run", @@ -2506,6 +2657,8 @@ def _write_manifest( runtime_repo_path: Path, shared_addons_repo_path: Path | None = None, addons_paths: tuple[str, ...] = ("sources/tenant/addons",), + context_name: str = "opw", + database_name: str = "opw", instance_name: str = "local", artifact_inputs_file: str | None = None, ) -> Path: @@ -2530,10 +2683,10 @@ def _write_manifest( manifest_path.write_text( f""" schema_version = 1 -tenant = "opw" +tenant = "{context_name}" [workspace] -name = "opw" +name = "{context_name}" python = "3.13" [repos.tenant] @@ -2546,9 +2699,9 @@ def _write_manifest( {shared_addons_block} [runtime] -context = "opw" +context = "{context_name}" instance = "{instance_name}" -database = "opw" +database = "{database_name}" addons_paths = [{rendered_addons_paths}] {artifacts_block}