Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions odoo_devkit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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))
Expand Down
78 changes: 75 additions & 3 deletions odoo_devkit/local_runtime.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import base64
import json
import os
import re
Expand Down Expand Up @@ -29,6 +30,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",
Expand Down Expand Up @@ -384,13 +386,17 @@ 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()
if not normalized_image_repository:
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,
Expand Down Expand Up @@ -438,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",
Expand Down Expand Up @@ -1040,6 +1046,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)
Expand All @@ -1062,6 +1076,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:
Expand Down Expand Up @@ -1694,6 +1753,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, "")
Expand All @@ -1707,6 +1767,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(
{
Expand All @@ -1722,17 +1783,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)
Expand Down
9 changes: 6 additions & 3 deletions odoo_devkit/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions tests/test_control_plane_cli_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
55 changes: 46 additions & 9 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down