From cd38d3dd1b14d350fa550d0d5f6eae81014dad16 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sun, 26 Apr 2026 18:49:41 -0400 Subject: [PATCH 1/4] 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/4] 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, From 83f07dafb32a71e7e7a0d0a4bdfc35c6f6803dcc Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sun, 26 Apr 2026 20:05:18 -0400 Subject: [PATCH 3/4] Authenticate artifact source ref resolution --- odoo_devkit/local_runtime.py | 18 ++++++++++-- tests/test_runtime.py | 55 ++++++++++++++++++++++++++++++------ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index f5da826..cbc333b 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import json import os import re @@ -1748,6 +1749,7 @@ def resolve_artifact_runtime_source_repository_refs( runtime_values: dict[str, str], ) -> tuple[dict[str, str], tuple[dict[str, str], ...]]: resolved_values = dict(runtime_values) + github_token = clean_optional_value(runtime_values.get("GITHUB_TOKEN")) selector_metadata: list[dict[str, str]] = [] for env_key in ARTIFACT_SOURCE_ENV_KEYS: raw_value = runtime_values.get(env_key, "") @@ -1761,6 +1763,7 @@ def resolve_artifact_runtime_source_repository_refs( resolved_ref = resolve_source_repository_ref_to_git_sha( repository=repository, ref=ref, + github_token=github_token, ) selector_metadata.append( { @@ -1776,17 +1779,28 @@ def resolve_artifact_runtime_source_repository_refs( return resolved_values, tuple(selector_metadata) -def resolve_source_repository_ref_to_git_sha(*, repository: str, ref: str) -> str: +def resolve_source_repository_ref_to_git_sha(*, repository: str, ref: str, github_token: str | None = None) -> str: normalized_repository = repository.strip() normalized_ref = ref.strip() if GIT_SHA_PATTERN.fullmatch(normalized_ref): return normalized_ref remote_url = resolve_source_repository_remote_url(normalized_repository) + execution_env = command_execution_env() + normalized_token = clean_optional_value(github_token) + if normalized_token and remote_url.startswith("https://github.com/"): + encoded_auth = base64.b64encode(f"x-access-token:{normalized_token}".encode()).decode("ascii") + execution_env.update( + { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.https://github.com/.extraheader", + "GIT_CONFIG_VALUE_0": f"AUTHORIZATION: basic {encoded_auth}", + } + ) ls_remote_result = subprocess.run( ["git", "ls-remote", "--refs", remote_url, normalized_ref], capture_output=True, text=True, - env=command_execution_env(), + env=execution_env, ) if ls_remote_result.returncode != 0: details = clean_optional_value(ls_remote_result.stderr) or clean_optional_value(ls_remote_result.stdout) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 2184740..d005fd8 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -11,8 +11,7 @@ from pathlib import Path from unittest import mock -from odoo_devkit import artifact_inputs -from odoo_devkit import local_runtime +from odoo_devkit import artifact_inputs, local_runtime from odoo_devkit.cli import ( _handle_runtime_build, _handle_runtime_down, @@ -753,7 +752,13 @@ def test_native_runtime_publish_builds_release_context_and_emits_manifest(self) captured_build_contexts: list[Path] = [] - def fake_run_command(*, runtime_repo_path: Path, command: list[str], environment_overrides=None, allowed_return_codes=None): + def fake_run_command( + *, + runtime_repo_path: Path, + command: list[str], + environment_overrides: object | None = None, + allowed_return_codes: object | None = None, + ) -> None: _ = environment_overrides, allowed_return_codes if command[:3] == ["docker", "buildx", "build"]: captured_build_contexts.append(runtime_repo_path) @@ -827,7 +832,13 @@ def test_native_runtime_publish_resolves_addon_selectors_from_artifact_inputs_ma captured_build_args: list[str] = [] - def fake_run_command(*, runtime_repo_path: Path, command: list[str], environment_overrides=None, allowed_return_codes=None): + 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) @@ -868,7 +879,11 @@ def fake_run_command(*, runtime_repo_path: Path, command: list[str], environment no_cache=False, ) - resolve_ref_mock.assert_called_once_with(repository="cbusillo/disable_odoo_online", ref="main") + resolve_ref_mock.assert_called_once_with( + repository="cbusillo/disable_odoo_online", + ref="main", + github_token="gh-token", + ) addon_build_arg = next( argument for argument in captured_build_args @@ -940,7 +955,13 @@ def test_native_runtime_publish_rejects_legacy_runtime_stack_selectors(self) -> captured_build_args: list[str] = [] - def fake_run_command(*, runtime_repo_path: Path, command: list[str], environment_overrides=None, allowed_return_codes=None): + 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) @@ -987,7 +1008,13 @@ def test_native_runtime_publish_prefers_artifact_inputs_manifest_over_runtime_en captured_build_args: list[str] = [] - def fake_run_command(*, runtime_repo_path: Path, command: list[str], environment_overrides=None, allowed_return_codes=None): + 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) @@ -1028,7 +1055,11 @@ def fake_run_command(*, runtime_repo_path: Path, command: list[str], environment no_cache=False, ) - resolve_ref_mock.assert_called_once_with(repository="cbusillo/disable_odoo_online", ref="release-19") + resolve_ref_mock.assert_called_once_with( + repository="cbusillo/disable_odoo_online", + ref="release-19", + github_token="gh-token", + ) addon_build_arg = next(argument for argument in captured_build_args if argument.startswith("ODOO_ADDON_REPOSITORIES=")) self.assertEqual( addon_build_arg, @@ -1226,7 +1257,13 @@ def test_native_runtime_publish_prefers_exact_control_plane_refs_over_stack_defa captured_build_args: list[str] = [] - def fake_run_command(*, runtime_repo_path: Path, command: list[str], environment_overrides=None, allowed_return_codes=None): + 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) From d9e16e724691cb5ed899bb1712a576215ad49bb2 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Sun, 26 Apr 2026 20:10:37 -0400 Subject: [PATCH 4/4] Allow single-platform artifact publish --- odoo_devkit/cli.py | 11 +++++++++-- odoo_devkit/local_runtime.py | 6 +++++- odoo_devkit/runtime.py | 9 ++++++--- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/odoo_devkit/cli.py b/odoo_devkit/cli.py index 7fcd5bc..43fb252 100644 --- a/odoo_devkit/cli.py +++ b/odoo_devkit/cli.py @@ -10,10 +10,10 @@ from .runtime import ( run_native_runtime_build, run_native_runtime_down, + run_native_runtime_inspect, run_native_runtime_logs, run_native_runtime_odoo_shell, run_native_runtime_psql, - run_native_runtime_inspect, run_native_runtime_publish, run_native_runtime_restore, run_native_runtime_select, @@ -22,8 +22,8 @@ run_runtime_platform_command, ) from .scaffold import scaffold_tenant_overlay, scaffold_workspace_cockpit -from .workspace_cockpit import load_workspace_cockpit_manifest, sync_workspace_cockpit, workspace_cockpit_status from .workspace import clean_workspace, run_in_workspace, sync_workspace, workspace_status +from .workspace_cockpit import load_workspace_cockpit_manifest, sync_workspace_cockpit, workspace_cockpit_status def main() -> None: @@ -117,6 +117,12 @@ def build_parser() -> argparse.ArgumentParser: runtime_publish_parser.add_argument("--image-tag", required=True) runtime_publish_parser.add_argument("--output-file", type=Path, default=None) runtime_publish_parser.add_argument("--no-cache", action="store_true") + runtime_publish_parser.add_argument( + "--platform", + action="append", + default=[], + help="Target platform for artifact image builds. May be provided more than once; defaults to linux/amd64 and linux/arm64.", + ) runtime_publish_parser.set_defaults(handler=_handle_runtime_publish) runtime_down_parser = _add_manifest_argument( @@ -351,6 +357,7 @@ def _handle_runtime_publish(arguments: argparse.Namespace) -> None: image_tag=arguments.image_tag, output_file=arguments.output_file, no_cache=arguments.no_cache, + platforms=tuple(arguments.platform or ()), ) ) print(json.dumps(payload, indent=2, sort_keys=True)) diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index cbc333b..3e6a8a5 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -386,6 +386,7 @@ def publish_runtime_artifact( image_tag: str, output_file: Path | None, no_cache: bool, + platforms: tuple[str, ...] = DEFAULT_ARTIFACT_IMAGE_PLATFORMS, ) -> RuntimeArtifactPublishResult: normalized_image_repository = image_repository.strip() normalized_image_tag = image_tag.strip() @@ -393,6 +394,9 @@ def publish_runtime_artifact( raise RuntimeCommandError("Artifact publish requires a non-empty image repository.") if not normalized_image_tag: raise RuntimeCommandError("Artifact publish requires a non-empty image tag.") + normalized_platforms = tuple(platform.strip() for platform in platforms if platform.strip()) + if not normalized_platforms: + raise RuntimeCommandError("Artifact publish requires at least one target platform.") runtime_context = load_runtime_context( manifest=manifest, @@ -440,7 +444,7 @@ def publish_runtime_artifact( "--target", "production", "--platform", - ",".join(DEFAULT_ARTIFACT_IMAGE_PLATFORMS), + ",".join(normalized_platforms), "--tag", f"{normalized_image_repository}:{normalized_image_tag}", "--push", diff --git a/odoo_devkit/runtime.py b/odoo_devkit/runtime.py index ede2be1..fcc2ff5 100644 --- a/odoo_devkit/runtime.py +++ b/odoo_devkit/runtime.py @@ -5,21 +5,22 @@ from pathlib import Path from .local_runtime import ( + DEFAULT_ARTIFACT_IMAGE_PLATFORMS, RuntimeCommandError, build_runtime, down_runtime, emit_key_value_payload, inspect_runtime, publish_runtime_artifact, - run_psql_command, - run_odoo_shell_command, run_bootstrap_workflow, run_init_workflow, - stream_runtime_logs, + run_odoo_shell_command, run_openupgrade_workflow, + run_psql_command, run_restore_workflow, run_update_workflow, select_runtime, + stream_runtime_logs, up_runtime, ) from .manifest import WorkspaceManifest @@ -245,6 +246,7 @@ def run_native_runtime_publish( image_tag: str, output_file: Path | None, no_cache: bool, + platforms: tuple[str, ...] = (), ) -> dict[str, object]: runtime_repo_path = resolve_runtime_repo_path(manifest) try: @@ -255,6 +257,7 @@ def run_native_runtime_publish( image_tag=image_tag, output_file=output_file, no_cache=no_cache, + platforms=platforms or DEFAULT_ARTIFACT_IMAGE_PLATFORMS, ) except RuntimeCommandError as error: raise ValueError(str(error)) from error