From cd38d3dd1b14d350fa550d0d5f6eae81014dad16 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sun, 26 Apr 2026 18:49:41 -0400 Subject: [PATCH 1/2] Use Launchplane CLI for environment resolution --- odoo_devkit/local_runtime.py | 4 +- tests/test_control_plane_cli_contract.py | 48 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 tests/test_control_plane_cli_contract.py diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index 68dda1c..54517d0 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -16,8 +16,8 @@ from typing import TextIO from .artifact_inputs import ( - ArtifactInputsError, ArtifactInputsDefinition, + ArtifactInputsError, effective_artifact_input_sources, load_artifact_inputs_definition, ) @@ -1119,7 +1119,7 @@ def load_environment_from_control_plane( "--directory", str(control_plane_root), "run", - "control-plane", + "launchplane", "environments", "resolve", "--context", diff --git a/tests/test_control_plane_cli_contract.py b/tests/test_control_plane_cli_contract.py new file mode 100644 index 0000000..0e620ff --- /dev/null +++ b/tests/test_control_plane_cli_contract.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path +from unittest import mock + +from odoo_devkit import local_runtime + + +class ControlPlaneCliContractTests(unittest.TestCase): + def test_environment_resolution_uses_launchplane_cli(self) -> None: + completed_process = mock.Mock( + returncode=0, + stdout=json.dumps( + { + "environment": { + "ODOO_MASTER_PASSWORD": "control-plane-master", + } + } + ), + stderr="", + ) + + with mock.patch( + "odoo_devkit.local_runtime.subprocess.run", + return_value=completed_process, + ) as run_mock: + loaded_environment = local_runtime.load_environment_from_control_plane( + control_plane_root=Path("/opt/launchplane"), + context_name="cm", + instance_name="testing", + ) + + command = run_mock.call_args.args[0] + self.assertEqual( + command[:5], + ["uv", "--directory", "/opt/launchplane", "run", "launchplane"], + ) + self.assertIn("environments", command) + self.assertEqual( + loaded_environment.merged_values["ODOO_MASTER_PASSWORD"], + "control-plane-master", + ) + + +if __name__ == "__main__": + unittest.main() From 2fcf211c948d317c4739bff0be8bc3861ac60f15 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sun, 26 Apr 2026 19:43:08 -0400 Subject: [PATCH 2/2] Accept Launchplane runtime payloads for publish --- odoo_devkit/local_runtime.py | 54 ++++++++++++++++++++++++ tests/test_control_plane_cli_contract.py | 34 +++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index 54517d0..f5da826 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -29,6 +29,7 @@ DEFAULT_ARTIFACT_IMAGE_PLATFORMS = ("linux/amd64", "linux/arm64") GIT_SHA_PATTERN = re.compile(r"[0-9a-fA-F]{7,40}") ARTIFACT_SOURCE_ENV_KEYS = ("ODOO_ADDON_REPOSITORIES", "OPENUPGRADE_ADDON_REPOSITORY") +RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR = "ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON" PLATFORM_RUNTIME_ENV_KEYS = ( "PLATFORM_CONTEXT", @@ -1040,6 +1041,14 @@ def discover_project_addon_group_paths(repo_root: Path) -> tuple[str, ...]: def load_environment(*, repo_root: Path, context_name: str, instance_name: str, collision_mode: str = "warn") -> LoadedEnvironment: _ = collision_mode + explicit_environment = os.environ.get(RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR, "").strip() + if explicit_environment: + ensure_legacy_local_environment_files_are_absent(repo_root) + return load_environment_from_explicit_payload( + raw_payload=explicit_environment, + context_name=context_name, + instance_name=instance_name, + ) control_plane_root = resolve_control_plane_root() if control_plane_root is None: legacy_file_display = legacy_local_environment_file_display(repo_root) @@ -1062,6 +1071,51 @@ def load_environment(*, repo_root: Path, context_name: str, instance_name: str, ) +def load_environment_from_explicit_payload( + *, + raw_payload: str, + context_name: str, + instance_name: str, +) -> LoadedEnvironment: + try: + payload = json.loads(raw_payload) + except json.JSONDecodeError as error: + raise RuntimeCommandError( + f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} must contain a JSON object." + ) from error + if not isinstance(payload, dict): + raise RuntimeCommandError( + f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} must contain a JSON object." + ) + payload_context = clean_optional_value(str(payload.get("context", ""))) + payload_instance = clean_optional_value(str(payload.get("instance", ""))) + if payload_context != context_name or payload_instance != instance_name: + raise RuntimeCommandError( + f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} context/instance does not match the selected runtime. " + f"Payload={payload_context}/{payload_instance} selected={context_name}/{instance_name}." + ) + raw_environment = payload.get("environment") + if not isinstance(raw_environment, dict): + raise RuntimeCommandError( + f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} must include an environment object." + ) + resolved_environment = { + environment_key: str(environment_value) + for environment_key, environment_value in raw_environment.items() + if isinstance(environment_key, str) + } + if not resolved_environment: + raise RuntimeCommandError( + f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment object must not be empty." + ) + synthetic_env_file = Path(".generated") / "runtime-env" / f"{context_name}.{instance_name}.env" + return LoadedEnvironment( + env_file_path=synthetic_env_file, + merged_values=resolved_environment, + collisions=(), + ) + + def resolve_control_plane_root() -> Path | None: configured_root = os.environ.get(CONTROL_PLANE_ROOT_ENV_VAR, "").strip() if not configured_root: diff --git a/tests/test_control_plane_cli_contract.py b/tests/test_control_plane_cli_contract.py index 0e620ff..bc15f6f 100644 --- a/tests/test_control_plane_cli_contract.py +++ b/tests/test_control_plane_cli_contract.py @@ -9,6 +9,40 @@ class ControlPlaneCliContractTests(unittest.TestCase): + def test_environment_resolution_accepts_explicit_launchplane_payload(self) -> None: + loaded_environment = local_runtime.load_environment_from_explicit_payload( + raw_payload=json.dumps( + { + "context": "cm", + "instance": "testing", + "environment": { + "ODOO_MASTER_PASSWORD": "control-plane-master", + }, + } + ), + context_name="cm", + instance_name="testing", + ) + + self.assertEqual( + loaded_environment.merged_values["ODOO_MASTER_PASSWORD"], + "control-plane-master", + ) + + def test_environment_resolution_rejects_mismatched_explicit_payload(self) -> None: + with self.assertRaises(local_runtime.RuntimeCommandError): + local_runtime.load_environment_from_explicit_payload( + raw_payload=json.dumps( + { + "context": "cm", + "instance": "prod", + "environment": {"ODOO_MASTER_PASSWORD": "secret"}, + } + ), + context_name="cm", + instance_name="testing", + ) + def test_environment_resolution_uses_launchplane_cli(self) -> None: completed_process = mock.Mock( returncode=0,