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
4 changes: 4 additions & 0 deletions docs/tooling/workspace-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 55 additions & 1 deletion odoo_devkit/local_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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] = []
Expand Down
173 changes: 163 additions & 10 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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]
Expand All @@ -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}

Expand Down