From 2421e466131fdf9088a540e1bd121f2c891e9636 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Thu, 9 Jul 2026 20:02:17 -0400 Subject: [PATCH] Remove hosted runtime authority from devkit stack --- README.md | 20 +-- docs/ARCHITECTURE.md | 5 +- docs/tooling/artifact-inputs.md | 4 +- docs/tooling/workspace-cli.md | 26 +-- odoo_devkit/local_runtime.py | 87 ++++++--- platform/stack.toml | 72 +------- tests/test_control_plane_cli_contract.py | 17 ++ tests/test_runtime.py | 220 ++++++++++++++++++++--- 8 files changed, 302 insertions(+), 149 deletions(-) diff --git a/README.md b/README.md index d3e82b3..c391f6a 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,6 @@ uv run platform workspace clean --manifest /path/to/workspace.toml uv run platform workspace run --manifest /path/to/workspace.toml -- pwd uv run platform runtime select --manifest /path/to/workspace.toml uv run platform runtime build --manifest /path/to/workspace.toml --no-cache -uv run platform runtime publish --manifest /path/to/workspace.toml \ - --instance testing \ - --image-repository ghcr.io/example/odoo-opw \ - --image-tag opw-20260416-deadbeef \ - --output-file /tmp/opw-artifact.json uv run platform runtime up --manifest /path/to/workspace.toml --build uv run platform runtime down --manifest /path/to/workspace.toml --volumes uv run platform runtime workflow --manifest /path/to/workspace.toml --workflow update @@ -139,23 +134,18 @@ uv run python -m unittest discover -s tests For tenant repos that keep `instance = "local"` in the tracked manifest, `--instance testing` or `--instance prod` is not a shortcut for remote mutation. Release and non-local data actions should run through `launchplane`. -Local and artifact-handoff examples: +Local workflow example: ```bash uv --directory ../odoo-devkit run platform runtime workflow \ --manifest ./workspace.toml \ --workflow bootstrap -uv --directory ../odoo-devkit run platform runtime publish \ - --manifest ./workspace.toml \ - --instance testing \ - --image-repository ghcr.io/example/odoo-opw \ - --image-tag opw-20260416-deadbeef \ - --output-file /tmp/opw-artifact.json -uv --directory ../launchplane run launchplane artifacts write \ - --state-dir ./state \ - --input-file /tmp/opw-artifact.json ``` +Non-local artifact publishing runs through Launchplane's reusable workflow, +which supplies the authoritative runtime-environment payload and records the +resulting artifact. + `platform runtime publish` stages the tenant addons plus shared addons into a real build context, requires clean git worktrees for the repos it captures, pushes the resulting image, resolves the pushed digest, and emits a diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 67a8803..00c3dc2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -17,7 +17,7 @@ When - PyCharm opens the tenant repo directly. - Every Code starts from the materialized workspace root. -- `odoo-devkit` owns the shared DX/runtime/bootstrap contract. +- `odoo-devkit` owns the shared local DX/runtime/bootstrap contract. - The control plane owns canonical deploy/build tuples and release-sensitive behavior. - Remote release flow remains artifact-backed and control-plane-owned rather @@ -44,6 +44,9 @@ When - Generated PyCharm metadata and run configurations for the tenant repo. - Devkit-owned local runtime bundle (`docker-compose.yml`, `platform/stack.toml`, Dockerfile, and local runtime scripts). +- Checked-in stack runtime values and Odoo overrides are local-development + defaults only; Launchplane runtime-environment records own non-local artifact + publish values and hosted lane authority. - Shared tenant runtime contracts that must stay safe across stable-lane redeploys, including conservative database image/version changes for long-lived tenant volumes. diff --git a/docs/tooling/artifact-inputs.md b/docs/tooling/artifact-inputs.md index be387d9..e671f5b 100644 --- a/docs/tooling/artifact-inputs.md +++ b/docs/tooling/artifact-inputs.md @@ -13,7 +13,9 @@ When - Keep source input intent versioned in the repo that owns the build. - Keep `workspace.toml` focused on workspace assembly and runtime targeting. -- Keep runtime stack config focused on runtime selection state. +- Keep runtime stack config focused on local runtime selection state. +- Keep hosted runtime values in Launchplane records. `artifact-inputs.toml` + may override source repository selection, not hosted runtime authority. - Keep artifact, release, and deploy truth pinned to resolved SHAs and image digests after publish. diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index a8cb847..df214a2 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -35,11 +35,6 @@ uv run platform workspace clean --manifest /path/to/workspace.toml uv run platform workspace run --manifest /path/to/workspace.toml -- pwd uv run platform runtime select --manifest /path/to/workspace.toml uv run platform runtime build --manifest /path/to/workspace.toml --no-cache -uv run platform runtime publish --manifest /path/to/workspace.toml \ - --instance testing \ - --image-repository ghcr.io/example/odoo-opw \ - --image-tag opw-20260416-deadbeef \ - --output-file /tmp/opw-artifact.json uv run platform runtime up --manifest /path/to/workspace.toml --build uv run platform runtime down --manifest /path/to/workspace.toml --volumes uv run platform runtime workflow --manifest /path/to/workspace.toml --workflow update @@ -54,6 +49,9 @@ uv run platform runtime odoo-shell --manifest /path/to/workspace.toml \ If `--manifest` is omitted, the command looks for `workspace.toml` in the current directory. +Non-local `platform runtime publish` is invoked by Launchplane's reusable +artifact workflow, which supplies the required runtime-environment payload. + ## `workspace sync` Purpose @@ -180,6 +178,9 @@ Notes `launchplane_settings`. `config_parameters` tables write Odoo `ir.config_parameter` keys, while `addon_settings.` tables write supported addon settings such as `authentik_sso` values. +- Checked-in stack `runtime_env` and `odoo_overrides` values are local-only. + Active dev, testing, and production values or domains belong to Launchplane + runtime-environment records. - Non-local Launchplane-managed instances (`dev`, `testing`, and `prod`) always prepend `launchplane_settings` and `disable_odoo_online` to the resolved Odoo install module list. Artifact inputs or base images make addon files @@ -252,13 +253,14 @@ 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. Synthesized contexts do not inherit stack-level install-module - lists; their artifact install intent comes from the managed-instance required - modules plus any repo-owned `website-bootstrap.toml` modules. Unknown contexts - still fail closed without the explicit payload. +- Non-local publish requires Launchplane to supply + `ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON`. The payload is authoritative for + artifact build runtime keys and can synthesize a missing context or instance + instead of requiring hosted lanes in the shared devkit stack. Synthesized + contexts do not inherit stack-level install-module lists; their artifact + install intent comes from managed-instance required modules plus any + repo-owned `website-bootstrap.toml` modules. Unknown contexts and non-local + instances 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 e80652d..dd82ca2 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -32,6 +32,15 @@ ARTIFACT_SOURCE_ENV_KEYS = ("ODOO_ADDON_REPOSITORIES", "OPENUPGRADE_ADDON_REPOSITORY") SOURCE_GITHUB_TOKEN_ENV_KEYS = ("ODOO_DEVKIT_SOURCE_GITHUB_TOKEN", "ODOO_SOURCE_GITHUB_TOKEN") RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR = "ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON" +ARTIFACT_PUBLISH_RUNTIME_ENV_KEYS = ( + "ODOO_VERSION", + "ODOO_BASE_RUNTIME_IMAGE", + "ODOO_BASE_DEVTOOLS_IMAGE", + "ODOO_ADDON_REPOSITORIES", + "OPENUPGRADE_ADDON_REPOSITORY", + "OPENUPGRADELIB_INSTALL_SPEC", + "ODOO_PYTHON_SYNC_SKIP_ADDONS", +) ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY = "ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64" LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY = "LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED" LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY = "LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED" @@ -452,6 +461,10 @@ def publish_runtime_artifact( 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.") + if manifest.runtime.instance.strip().lower() != "local" and not explicit_runtime_environment_payload_is_configured(): + raise RuntimeCommandError( + f"Non-local artifact publish requires Launchplane runtime environment payload via {RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR}." + ) runtime_context = load_runtime_context( manifest=manifest, @@ -469,6 +482,7 @@ def publish_runtime_artifact( runtime_context=runtime_context, runtime_values=runtime_values, ) + validate_artifact_publish_runtime_values(runtime_values) runtime_values, artifact_source_selectors = resolve_artifact_runtime_source_repository_refs( runtime_values=runtime_values, ) @@ -512,15 +526,7 @@ def publish_runtime_artifact( build_command.extend(["--secret", "id=github_token,env=GITHUB_TOKEN"]) if no_cache: build_command.append("--no-cache") - for build_argument_name in ( - "ODOO_VERSION", - "ODOO_BASE_RUNTIME_IMAGE", - "ODOO_BASE_DEVTOOLS_IMAGE", - "ODOO_ADDON_REPOSITORIES", - "OPENUPGRADE_ADDON_REPOSITORY", - "OPENUPGRADELIB_INSTALL_SPEC", - "ODOO_PYTHON_SYNC_SKIP_ADDONS", - ): + for build_argument_name in ARTIFACT_PUBLISH_RUNTIME_ENV_KEYS: build_command.extend(["--build-arg", f"{build_argument_name}={runtime_values.get(build_argument_name, '')}"]) build_command.append(str(staged_context_root)) run_command( @@ -576,6 +582,13 @@ def publish_runtime_artifact( ) +def validate_artifact_publish_runtime_values(runtime_values: dict[str, str]) -> None: + if not runtime_values.get("ODOO_VERSION", "").strip(): + raise RuntimeCommandError( + f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment must include ODOO_VERSION for artifact publish." + ) + + def down_runtime(*, manifest: WorkspaceManifest, runtime_repo_path: Path, volumes: bool) -> None: load_runtime_context(manifest=manifest, runtime_repo_path=runtime_repo_path) runtime_env_file = ensure_runtime_env_file( @@ -872,23 +885,33 @@ def explicit_runtime_environment_payload_is_configured() -> bool: 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( + context_definition = contexts.get(manifest.runtime.context) + if context_definition is None: + context_definition = ContextDefinition( + database=manifest.runtime.database, + install_modules=(), + runtime_env={}, + odoo_overrides=empty_odoo_override_definition(), + update_modules="AUTO", + instances={}, + ) + if manifest.runtime.instance in context_definition.instances: + return stack_definition + instances = dict(context_definition.instances) + instances[manifest.runtime.instance] = InstanceDefinition( database=manifest.runtime.database, - install_modules=(), + install_modules_add=(), 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(), - ) - }, + ) + contexts[manifest.runtime.context] = ContextDefinition( + database=context_definition.database or manifest.runtime.database, + install_modules=context_definition.install_modules, + runtime_env=context_definition.runtime_env, + odoo_overrides=context_definition.odoo_overrides, + update_modules=context_definition.update_modules, + instances=instances, ) return StackDefinition( schema_version=stack_definition.schema_version, @@ -1306,8 +1329,12 @@ def load_environment_from_explicit_payload( 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", ""))) + raw_payload_context = payload.get("context") + raw_payload_instance = payload.get("instance") + if not isinstance(raw_payload_context, str) or not isinstance(raw_payload_instance, str): + raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} context and instance must be strings.") + payload_context = clean_optional_value(raw_payload_context) + payload_instance = clean_optional_value(raw_payload_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. " @@ -1316,11 +1343,12 @@ def load_environment_from_explicit_payload( 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) + if any( + not isinstance(environment_key, str) or not isinstance(environment_value, str) for environment_key, environment_value in raw_environment.items() - if isinstance(environment_key, str) - } + ): + raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment keys and values must be strings.") + resolved_environment = dict(raw_environment) 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" @@ -1802,6 +1830,9 @@ def build_runtime_env_values( runtime_values[environment_key] = source_environment[environment_key] for runtime_key, runtime_value in runtime_selection.effective_runtime_env.items(): runtime_values[runtime_key] = runtime_value + if explicit_runtime_environment_payload_is_configured(): + for runtime_key in ARTIFACT_PUBLISH_RUNTIME_ENV_KEYS: + runtime_values[runtime_key] = source_environment.get(runtime_key, "") apply_typed_odoo_instance_override_payload( runtime_values=runtime_values, context_name=runtime_selection.context_name, diff --git a/platform/stack.toml b/platform/stack.toml index 88b9933..b2486eb 100644 --- a/platform/stack.toml +++ b/platform/stack.toml @@ -43,14 +43,6 @@ install_modules = [ "cm_custom", ] -[contexts.cm.runtime_env] -ODOO_DEV_MODE = "" - -[contexts.cm.odoo_overrides.addon_settings.authentik_sso] -base_url = "https://authentik.cellmechanic.com" -disable_providers = "Odoo.com" -group_claim = "groups" - [contexts.cm.instances.local] install_modules_add = [ "fishbowl_import", @@ -67,29 +59,10 @@ COMPOSE_BUILD_TARGET = "development" [contexts.cm.instances.local.odoo_overrides.config_parameters] "web.base.url" = "https://cm-local.shinycomputers.com" -[contexts.cm.instances.dev.runtime_env] -ODOO_WORKERS = 4 -COMPOSE_BUILD_TARGET = "production" - -[contexts.cm.instances.dev.odoo_overrides.config_parameters] -"web.base.url" = "https://cm-dev.shinycomputers.com" - -[contexts.cm.instances.testing.runtime_env] -ODOO_WORKERS = 4 -COMPOSE_BUILD_TARGET = "production" - -[contexts.cm.instances.testing.odoo_overrides.config_parameters] -"web.base.url" = "https://cm-testing.shinycomputers.com" - -[contexts.cm.instances.prod.runtime_env] -ODOO_WORKERS = 6 -POSTGRES_SHARED_BUFFERS = "2GB" -POSTGRES_EFFECTIVE_CACHE_SIZE = "6GB" -POSTGRES_WORK_MEM = "64MB" -COMPOSE_BUILD_TARGET = "production" - -[contexts.cm.instances.prod.odoo_overrides.config_parameters] -"web.base.url" = "https://odoo.cellmechanic.com" +[contexts.cm.instances.local.odoo_overrides.addon_settings.authentik_sso] +base_url = "https://authentik.cellmechanic.com" +disable_providers = "Odoo.com" +group_claim = "groups" [contexts.cm.instances.dev] install_modules_add = [ @@ -109,12 +82,6 @@ install_modules = [ "opw_custom", ] -[contexts.opw.runtime_env] -ODOO_DEV_MODE = "" -OPENUPGRADE_ENABLED = true -OPENUPGRADE_ADDON_REPOSITORY = "OCA/OpenUpgrade@89e649728027a8ab656b3aa4be18f4bd364db417" -OPENUPGRADELIB_INSTALL_SPEC = "git+https://github.com/OCA/openupgradelib.git@46d66ff5ed6a99481f84d3c79fc6e50835da7286" - [contexts.opw.instances.local] [contexts.opw.instances.local.runtime_env] @@ -123,34 +90,9 @@ ODOO_WORKERS = 2 ODOO_LIMIT_MEMORY_SOFT = 2147483648 ODOO_LIMIT_MEMORY_HARD = 3221225472 COMPOSE_BUILD_TARGET = "development" +OPENUPGRADE_ENABLED = true +OPENUPGRADE_ADDON_REPOSITORY = "OCA/OpenUpgrade@89e649728027a8ab656b3aa4be18f4bd364db417" +OPENUPGRADELIB_INSTALL_SPEC = "git+https://github.com/OCA/openupgradelib.git@46d66ff5ed6a99481f84d3c79fc6e50835da7286" [contexts.opw.instances.local.odoo_overrides.config_parameters] "web.base.url" = "https://opw-local.shinycomputers.com" - -[contexts.opw.instances.dev] - -[contexts.opw.instances.dev.runtime_env] -ODOO_WORKERS = 4 -COMPOSE_BUILD_TARGET = "production" - -[contexts.opw.instances.dev.odoo_overrides.config_parameters] -"web.base.url" = "https://opw-dev.shinycomputers.com" - -[contexts.opw.instances.testing] - -[contexts.opw.instances.testing.runtime_env] -ODOO_WORKERS = 4 -COMPOSE_BUILD_TARGET = "production" - -[contexts.opw.instances.testing.odoo_overrides.config_parameters] -"web.base.url" = "https://opw-testing.shinycomputers.com" - -[contexts.opw.instances.prod.runtime_env] -ODOO_WORKERS = 6 -POSTGRES_SHARED_BUFFERS = "2GB" -POSTGRES_EFFECTIVE_CACHE_SIZE = "6GB" -POSTGRES_WORK_MEM = "64MB" -COMPOSE_BUILD_TARGET = "production" - -[contexts.opw.instances.prod.odoo_overrides.config_parameters] -"web.base.url" = "https://opw-prod.shinycomputers.com" diff --git a/tests/test_control_plane_cli_contract.py b/tests/test_control_plane_cli_contract.py index bc15f6f..4463561 100644 --- a/tests/test_control_plane_cli_contract.py +++ b/tests/test_control_plane_cli_contract.py @@ -43,6 +43,23 @@ def test_environment_resolution_rejects_mismatched_explicit_payload(self) -> Non instance_name="testing", ) + def test_environment_resolution_rejects_non_string_values(self) -> None: + with self.assertRaisesRegex( + local_runtime.RuntimeCommandError, + "environment keys and values must be strings", + ): + local_runtime.load_environment_from_explicit_payload( + raw_payload=json.dumps( + { + "context": "cm", + "instance": "testing", + "environment": {"ODOO_VERSION": None}, + } + ), + context_name="cm", + instance_name="testing", + ) + def test_environment_resolution_uses_launchplane_cli(self) -> None: completed_process = mock.Mock( returncode=0, diff --git a/tests/test_runtime.py b/tests/test_runtime.py index d048341..21a505c 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -70,6 +70,40 @@ def _write_buildx_metadata_for_command(self, command: list[str]) -> None: metadata_file = Path(command[command.index("--metadata-file") + 1]) metadata_file.write_text(json.dumps({"containerimage.digest": self.artifact_image_digest}) + "\n", encoding="utf-8") + def _configure_publish_runtime_payload( + self, + *, + context: str = "opw", + instance: str = "testing", + odoo_version: str | None = "19.0", + environment: dict[str, str] | None = None, + ) -> None: + payload_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", + } + if odoo_version is not None: + payload_environment["ODOO_VERSION"] = odoo_version + payload_environment.update(environment or {}) + environment_patch = mock.patch.dict( + os.environ, + { + local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: json.dumps( + { + "context": context, + "instance": instance, + "environment": payload_environment, + } + ) + }, + ) + environment_patch.start() + self.addCleanup(environment_patch.stop) + def test_resolve_runtime_repo_path_prefers_explicit_repo(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) @@ -732,12 +766,6 @@ def test_native_runtime_select_rejects_legacy_stack_addon_source_keys(self) -> N install_modules = ["opw_custom"] [contexts.opw.instances.local] - -[contexts.opw.instances.dev] - -[contexts.opw.instances.testing] - -[contexts.opw.instances.prod] """.strip() + "\n", encoding="utf-8", @@ -1027,6 +1055,7 @@ def test_native_runtime_publish_builds_release_context_and_emits_manifest(self) 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) + self._configure_publish_runtime_payload() captured_build_contexts: list[Path] = [] @@ -1116,6 +1145,7 @@ def test_native_runtime_publish_resolves_addon_selectors_from_artifact_inputs_ma ) subprocess.run(["git", "commit", "-m", "workspace manifest"], cwd=tenant_repo_path, check=True, capture_output=True) manifest = load_workspace_manifest(manifest_path) + self._configure_publish_runtime_payload() captured_build_args: list[str] = [] @@ -1225,6 +1255,13 @@ def fake_subprocess_run(*args: object, **kwargs: object) -> mock.Mock: self.assertEqual(login_inputs, ["read-token", "push-token"]) + def test_artifact_publish_runtime_values_require_odoo_version(self) -> None: + with self.assertRaisesRegex( + local_runtime.RuntimeCommandError, + "environment must include ODOO_VERSION", + ): + local_runtime.validate_artifact_publish_runtime_values({}) + def test_native_runtime_publish_rejects_legacy_runtime_stack_selectors(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) @@ -1268,6 +1305,7 @@ def test_native_runtime_publish_rejects_legacy_runtime_stack_selectors(self) -> 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) + self._configure_publish_runtime_payload() captured_build_args: list[str] = [] @@ -1324,6 +1362,9 @@ def test_native_runtime_publish_prefers_artifact_inputs_manifest_over_runtime_en ) subprocess.run(["git", "commit", "-m", "workspace manifest"], cwd=tenant_repo_path, check=True, capture_output=True) manifest = load_workspace_manifest(manifest_path) + self._configure_publish_runtime_payload( + environment={"ODOO_ADDON_REPOSITORIES": ("cbusillo/disable_odoo_online@ffffffffffffffffffffffffffffffffffffffff")} + ) captured_build_args: list[str] = [] @@ -1431,6 +1472,7 @@ def test_native_runtime_publish_rejects_invalid_artifact_inputs_manifest(self) - ) subprocess.run(["git", "commit", "-m", "workspace manifest"], cwd=tenant_repo_path, check=True, capture_output=True) manifest = load_workspace_manifest(manifest_path) + self._configure_publish_runtime_payload() with self.assertRaisesRegex(ValueError, "exactly one of exact_ref or selector"): run_native_runtime_publish( @@ -1627,6 +1669,75 @@ def test_resolve_runtime_selection_orders_managed_and_bootstrap_modules(self) -> ("launchplane_settings", "disable_odoo_online", "opw_custom", "website_sale"), ) + def test_checked_in_stack_keeps_hosted_runtime_authority_out_of_devkit(self) -> None: + repo_root = Path(__file__).resolve().parents[1] + stack = local_runtime.load_stack(repo_root / "platform" / "stack.toml").stack_definition + + cm_context = stack.contexts["cm"] + self.assertEqual(set(cm_context.instances), {"local", "dev", "testing"}) + self.assertEqual(cm_context.runtime_env, {}) + self.assertEqual(cm_context.odoo_overrides, local_runtime.empty_odoo_override_definition()) + for instance_name in ("dev", "testing"): + instance = cm_context.instances[instance_name] + self.assertEqual(instance.runtime_env, {}) + self.assertEqual( + instance.odoo_overrides, + local_runtime.empty_odoo_override_definition(), + ) + self.assertNotIn("prod", cm_context.instances) + self.assertEqual( + cm_context.instances["local"].odoo_overrides.addon_settings["authentik_sso"]["base_url"], + "https://authentik.cellmechanic.com", + ) + + opw_context = stack.contexts["opw"] + self.assertEqual(set(opw_context.instances), {"local"}) + self.assertEqual(opw_context.runtime_env, {}) + self.assertEqual(opw_context.odoo_overrides, local_runtime.empty_odoo_override_definition()) + self.assertEqual( + opw_context.instances["local"].runtime_env["OPENUPGRADE_ENABLED"], + True, + ) + + def test_runtime_payload_synthesizes_missing_instance_in_existing_context(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + temp_root = Path(temporary_directory) + tenant_repo_path = temp_root / "tenant-repo" + runtime_repo_path = temp_root / "runtime-repo" + tenant_repo_path.mkdir() + runtime_repo_path.mkdir() + manifest = load_workspace_manifest( + self._write_manifest( + tenant_repo_path=tenant_repo_path, + runtime_repo_path=runtime_repo_path, + instance_name="testing", + ) + ) + stack = local_runtime.parse_stack_definition( + { + "schema_version": 1, + "odoo_version": "19.0", + "addons_path": ["/odoo/addons", "/opt/project/addons"], + "contexts": { + "opw": { + "database": "opw", + "install_modules": ["opw_custom"], + "instances": {"local": {}}, + } + }, + }, + stack_file_path=runtime_repo_path / "platform" / "stack.toml", + ) + + synthesized = local_runtime.synthesize_runtime_payload_context( + manifest=manifest, + stack_definition=stack, + ) + + self.assertEqual(set(synthesized.contexts["opw"].instances), {"local", "testing"}) + self.assertEqual(synthesized.contexts["opw"].install_modules, ("opw_custom",)) + self.assertEqual(synthesized.contexts["opw"].instances["testing"].database, "opw") + def test_native_runtime_publish_prefers_exact_control_plane_refs_over_stack_defaults(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) @@ -1637,6 +1748,15 @@ def test_native_runtime_publish_prefers_exact_control_plane_refs_over_stack_defa subprocess.run(["git", "add", "."], cwd=tenant_repo_path, check=True, capture_output=True) subprocess.run(["git", "commit", "-m", "tenant addons"], cwd=tenant_repo_path, check=True, capture_output=True) self._write_runtime_repo(runtime_repo_path) + stack_file = runtime_repo_path / "platform" / "stack.toml" + stack_file.write_text( + stack_file.read_text(encoding="utf-8").replace( + 'install_modules = ["opw_custom"]', + """install_modules = ["opw_custom"] +runtime_env = { ODOO_VERSION = "18.0", ODOO_BASE_RUNTIME_IMAGE = "ghcr.io/example/runtime:18.0-runtime", ODOO_BASE_DEVTOOLS_IMAGE = "ghcr.io/example/devtools:18.0-devtools", ODOO_ADDON_REPOSITORIES = "example/stale-addon@main", OPENUPGRADE_ADDON_REPOSITORY = "example/stale-openupgrade@main", OPENUPGRADELIB_INSTALL_SPEC = "example-stale-spec", ODOO_PYTHON_SYNC_SKIP_ADDONS = "stale_skip" }""", + ), + encoding="utf-8", + ) 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( @@ -1647,6 +1767,19 @@ def test_native_runtime_publish_prefers_exact_control_plane_refs_over_stack_defa 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) + self._configure_publish_runtime_payload( + environment={ + "ODOO_VERSION": "20.0", + "ODOO_BASE_RUNTIME_IMAGE": "ghcr.io/example/runtime:20.0-runtime", + "ODOO_BASE_DEVTOOLS_IMAGE": "ghcr.io/example/devtools:20.0-devtools", + "ODOO_ADDON_REPOSITORIES": ("cbusillo/disable_odoo_online@411f6b8e85cac72dc7aa2e2dc5540001043c327d"), + "OPENUPGRADE_ADDON_REPOSITORY": ("OCA/OpenUpgrade@411f6b8e85cac72dc7aa2e2dc5540001043c327d"), + "OPENUPGRADELIB_INSTALL_SPEC": ( + "git+https://github.com/OCA/openupgradelib.git@89e649728027a8ab656b3aa4be18f4bd364db417" + ), + "ODOO_PYTHON_SYNC_SKIP_ADDONS": "skip_one,skip_two", + } + ) captured_build_args: list[str] = [] @@ -1693,6 +1826,15 @@ def fake_run_command( addon_build_arg = next(argument for argument in captured_build_args if argument.startswith("ODOO_ADDON_REPOSITORIES=")) self.assertEqual(addon_build_arg, f"ODOO_ADDON_REPOSITORIES={exact_ref}") + expected_build_args = { + "ODOO_VERSION=20.0", + "ODOO_BASE_RUNTIME_IMAGE=ghcr.io/example/runtime:20.0-runtime", + "ODOO_BASE_DEVTOOLS_IMAGE=ghcr.io/example/devtools:20.0-devtools", + "OPENUPGRADE_ADDON_REPOSITORY=OCA/OpenUpgrade@411f6b8e85cac72dc7aa2e2dc5540001043c327d", + "OPENUPGRADELIB_INSTALL_SPEC=git+https://github.com/OCA/openupgradelib.git@89e649728027a8ab656b3aa4be18f4bd364db417", + "ODOO_PYTHON_SYNC_SKIP_ADDONS=skip_one,skip_two", + } + self.assertTrue(expected_build_args.issubset(set(captured_build_args))) self.assertIn( {"repository": "cbusillo/disable_odoo_online", "ref": exact_ref.rsplit("@", 1)[1]}, payload["addon_sources"], @@ -1701,8 +1843,10 @@ def fake_run_command( payload["odoo_install_modules"], ["launchplane_settings", "disable_odoo_online", "opw_custom"], ) + self.assertEqual(payload["build_flags"]["values"]["odoo_version"], "20.0") + self.assertEqual(payload["build_flags"]["addon_skip_flags"], ["skip_one", "skip_two"]) - def test_native_runtime_publish_rejects_unknown_context_without_explicit_runtime_payload(self) -> None: + def test_native_runtime_publish_requires_explicit_payload_for_non_local_instance(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) tenant_repo_path = self._create_git_repo(temp_root / "tenant-repo") @@ -1727,27 +1871,48 @@ def test_native_runtime_publish_rejects_unknown_context_without_explicit_runtime 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, + "Non-local artifact publish requires Launchplane runtime environment payload", ): - 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",), - ) + 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_rejects_payload_without_odoo_version_before_build(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") + 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, + instance_name="testing", + ) + subprocess.run(["git", "add", "."], 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) + self._configure_publish_runtime_payload(odoo_version=None) + + with mock.patch("odoo_devkit.local_runtime.run_command") as run_command_mock: + with self.assertRaisesRegex(ValueError, "environment must include ODOO_VERSION"): + run_native_runtime_publish( + manifest=manifest, + image_repository="ghcr.io/example/opw-runtime", + image_tag="opw-20260416-abcdef", + output_file=None, + no_cache=False, + ) + + run_command_mock.assert_not_called() def test_native_runtime_publish_synthesizes_context_from_explicit_runtime_payload(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: @@ -1820,6 +1985,7 @@ def fake_run_command( "context": "cm_website", "instance": "testing", "environment": { + "ODOO_VERSION": "19.0", "ODOO_MASTER_PASSWORD": "control-plane-master", "ODOO_DB_USER": "odoo", "ODOO_DB_PASSWORD": "control-plane-secret",