diff --git a/README.md b/README.md index c391f6a..2b931da 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,41 @@ uv run platform runtime odoo-shell --manifest /path/to/workspace.toml \ If `--manifest` is omitted, the CLI looks for `workspace.toml` in the current directory. +## Local Runtime Input + +All `platform runtime` commands consume runtime values through one typed input: +`ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON`. The JSON object must contain the exact +selected `context`, the exact selected `instance`, and a non-empty +`environment` object whose keys and values are strings: + +```json +{ + "context": "replace-me", + "instance": "local", + "environment": { + "ODOO_MASTER_PASSWORD": "", + "ODOO_DB_USER": "odoo", + "ODOO_DB_PASSWORD": "" + } +} +``` + +Inject the payload from an operator-owned shell, password manager, or a +mode-`0600` file outside the repository before running `runtime select`, +`inspect`, `up`, or local workflows. For example: + +```bash +export ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON="$(cat ~/.config/odoo-devkit/runtime-environment.json)" +uv run platform runtime inspect --manifest /path/to/workspace.toml --instance local +``` + +Do not put the payload or its values in `workspace.toml`, generated workspace +docs, checked-in config, `.env`, `platform/.env`, or +`platform/secrets.toml`. Missing input, context/instance mismatches, non-string +values, and legacy devkit-local env/secrets files fail closed. Non-local +Launchplane artifact workflows may inject the same typed payload boundary; +non-local mutation remains Launchplane-owned. + ## Scope This repo is intentionally small. It owns the manifest/runtime contract for diff --git a/docker-compose.yml b/docker-compose.yml index 0530f69..d1e4ab6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,6 @@ x-odoo-base: &odoo-base image: ${DOCKER_IMAGE_REFERENCE:-${DOCKER_IMAGE:-odoo-devkit}:${DOCKER_IMAGE_TAG:-latest}} pull_policy: ${DOCKER_PULL_POLICY:-never} restart: unless-stopped - env_file: - - path: .env - required: false x-odoo-env: &odoo-env ODOO_STACK_NAME: ${ODOO_STACK_NAME:-} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 00c3dc2..797b49e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -60,7 +60,8 @@ When - Legacy or disposable local runtime output under `state/`, when an older workspace still has it. - Optional local `AGENTS.override.md` for non-secret implementation facts that - should stay out of generated docs. Secrets still belong in `.env`. + should stay out of generated docs. Local runtime secrets are injected through + `ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON`, not stored in workspace files. ### Control plane diff --git a/docs/tooling/command-patterns.md b/docs/tooling/command-patterns.md index 885e70e..befd93f 100644 --- a/docs/tooling/command-patterns.md +++ b/docs/tooling/command-patterns.md @@ -14,6 +14,10 @@ When These examples are workspace, local runtime, and artifact-handoff patterns. Remote release and non-local data actions such as ship, promote, restore, bootstrap, update, and Launchplane preview lifecycle belong in `launchplane`. +Before a local `platform runtime` command, inject the context/instance-scoped +`ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON` payload from operator-local secret +storage. Never add that payload to the tenant manifest or generated workspace +files. - Sync the current tenant workspace: @@ -27,6 +31,13 @@ uv run platform workspace sync --manifest /path/to/workspace.toml uv run platform workspace status --manifest /path/to/workspace.toml ``` +- Safely inspect the selected local runtime after operator setup: + +```bash +export ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON="$(cat ~/.config/odoo-devkit/runtime-environment.json)" +uv run platform runtime inspect --manifest /path/to/workspace.toml --instance local +``` + - Run a command from the workspace root: ```bash @@ -71,6 +82,7 @@ uv run platform workspace scaffold-tenant-overlay \ - Do not hand-edit generated workspace-root cockpit files. - If the workspace surface is wrong, fix `odoo-devkit` and re-sync. - Keep implementation-specific, non-secret local facts in an untracked - `AGENTS.override.md`; keep credentials in `.env`. + `AGENTS.override.md`; keep credentials in operator-local secret storage + outside the repo. - Keep tenant repo docs thin; use the generated workspace docs index for shared guidance. diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index df214a2..b0801f9 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -152,6 +152,21 @@ Purpose workflows natively against `odoo-devkit`, while leaving non-local mutation to Launchplane service routes and reusable workflows. +Local runtime input + +- Before `select`, `inspect`, `build`, `up`, `down`, or a local workflow, set + `ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON` from an operator-owned shell, password + manager, or mode-`0600` file outside the repository. +- The payload must be a JSON object with the exact selected `context`, the exact + selected `instance`, and a non-empty `environment` object containing only + string keys and values. The checked-in stack declares which environment keys + are required for the selected command. +- This is the only supported runtime-environment input path. Do not put runtime + values in `workspace.toml`, generated workspace docs, checked-in config, + `.env`, `platform/.env`, or `platform/secrets.toml`. +- `runtime inspect` reports selected runtime metadata and generated config paths; + it does not print the payload or environment values. + Notes - The tenant repo remains path-based and user-owned. `workspace sync` does not @@ -212,13 +227,10 @@ Notes typed `odoo_overrides` instead. Unrelated devkit control keys such as `ENV_OVERRIDE_DISABLE_CRON` remain available until they get their own typed local contract. -- When `ODOO_CONTROL_PLANE_ROOT` points at a valid `launchplane` - checkout, local runtime env resolution comes from the control-plane-owned - environment contract. Devkit-local `.env` / `platform/secrets.toml` runtime - authority is unsupported. Leftover devkit-local env/secrets files are - treated as a hard conflict so environment authority stays single-source, and - build/restore requirements are expected to live in `launchplane`'s - `config/runtime-environments.toml` surface. +- Local runtime environment input comes only from + `ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON`. Leftover devkit-local `.env`, + `platform/.env`, or `platform/secrets.toml` files are a hard conflict so the + runtime boundary stays single-source and fail-closed. - Non-local `restore`, `workflow bootstrap`, and `workflow update` now fail closed with Launchplane handoff guidance. Devkit should not grow arbitrary checkout remote mutation flows; add or use a Launchplane service route first. diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index dd82ca2..c9a97ba 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -24,14 +24,15 @@ ) from .ide_support import write_pycharm_odoo_conf from .manifest import WorkspaceManifest +from .runtime_environment import RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR, sanitized_subprocess_environment ScalarValue = str | int | float | bool ScalarMap = dict[str, ScalarValue] DEFAULT_ARTIFACT_IMAGE_PLATFORMS = ("linux/amd64", "linux/arm64") GIT_SHA_PATTERN = re.compile(r"[0-9a-fA-F]{7,40}") +ENVIRONMENT_VARIABLE_NAME_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") 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", @@ -199,7 +200,6 @@ PLACEHOLDER_REGISTRY_HOST = "registry.invalid" DEFAULT_ODOO_BASE_RUNTIME_IMAGE = "registry.invalid/private-enterprise-runtime:19.0-runtime" DEFAULT_ODOO_BASE_DEVTOOLS_IMAGE = "registry.invalid/private-enterprise-devtools:19.0-devtools" -CONTROL_PLANE_ROOT_ENV_VAR = "ODOO_CONTROL_PLANE_ROOT" LAUNCHPLANE_MANAGED_INSTANCE_NAMES = {"dev", "testing", "prod"} LAUNCHPLANE_REQUIRED_ODOO_MODULES = ("launchplane_settings", "disable_odoo_online") @@ -486,6 +486,11 @@ def publish_runtime_artifact( runtime_values, artifact_source_selectors = resolve_artifact_runtime_source_repository_refs( runtime_values=runtime_values, ) + ensure_required_environment_mapping( + required_keys=runtime_context.stack.stack_definition.required_env_keys, + environment_values=runtime_values, + source_description="Resolved publish runtime environment", + ) ensure_registry_auth_for_base_images(runtime_values) ensure_registry_auth_for_image_push( environment_values=runtime_values, @@ -798,6 +803,10 @@ def load_runtime_context( context_name=manifest.runtime.context, instance_name=manifest.runtime.instance, ) + ensure_required_environment_values( + stack_definition=loaded_stack.stack_definition, + loaded_environment=loaded_environment, + ) try: artifact_inputs_definition = load_artifact_inputs_definition(manifest=manifest) except ArtifactInputsError as error: @@ -805,7 +814,11 @@ 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(), + allow_runtime_payload_context=( + not require_local_instance + and manifest.runtime.instance.strip().lower() != "local" + and explicit_runtime_environment_payload_is_configured() + ), ) website_bootstrap = load_website_bootstrap_definition(manifest=manifest) runtime_selection = resolve_runtime_selection( @@ -816,6 +829,11 @@ def load_runtime_context( repo_root=runtime_repo_path, website_bootstrap=website_bootstrap, ) + ensure_required_runtime_selection_values( + stack_definition=effective_stack_definition, + loaded_environment=loaded_environment, + runtime_selection=runtime_selection, + ) runtime_env_file = runtime_env_file_for_scope( repo_root=runtime_repo_path, context_name=manifest.runtime.context, @@ -1287,31 +1305,16 @@ 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 + ensure_legacy_local_environment_files_are_absent(repo_root) 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) - if legacy_file_display is not None: - raise RuntimeCommandError( - "Legacy devkit-local env/secrets files are no longer supported for runtime environment authority: " - f"{legacy_file_display}. Remove them, set {CONTROL_PLANE_ROOT_ENV_VAR}, and migrate runtime values into " - "harbor `config/runtime-environments.toml`." - ) + if not explicit_environment: raise RuntimeCommandError( - "Runtime environment resolution now requires the control-plane contract. " - f"Set {CONTROL_PLANE_ROOT_ENV_VAR} to a valid harbor checkout and configure runtime values in " - "`config/runtime-environments.toml`." + f"Runtime environment input is not configured. Set {RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} to a typed JSON " + "object whose context and instance match the selected runtime and whose environment object contains the " + "required string values." ) - ensure_legacy_local_environment_files_are_absent(repo_root) - return load_environment_from_control_plane( - control_plane_root=control_plane_root, + return load_environment_from_explicit_payload( + raw_payload=explicit_environment, context_name=context_name, instance_name=instance_name, ) @@ -1332,9 +1335,11 @@ def load_environment_from_explicit_payload( 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.") + raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} context and instance must be non-empty strings.") payload_context = clean_optional_value(raw_payload_context) payload_instance = clean_optional_value(raw_payload_instance) + if payload_context is None or payload_instance is None: + raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} context and instance must be non-empty strings.") 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. " @@ -1348,6 +1353,17 @@ def load_environment_from_explicit_payload( for environment_key, environment_value in raw_environment.items() ): raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment keys and values must be strings.") + if any(ENVIRONMENT_VARIABLE_NAME_PATTERN.fullmatch(environment_key) is None for environment_key in raw_environment): + raise RuntimeCommandError( + f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment keys must be valid environment variable names." + ) + if any( + "\x00" in environment_value or (bool(environment_value) and environment_value.splitlines() != [environment_value]) + for environment_value in raw_environment.values() + ): + raise RuntimeCommandError( + f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment values must not contain line separators or NUL bytes." + ) resolved_environment = dict(raw_environment) if not resolved_environment: raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment object must not be empty.") @@ -1359,25 +1375,51 @@ def load_environment_from_explicit_payload( ) -def resolve_control_plane_root() -> Path | None: - configured_root = os.environ.get(CONTROL_PLANE_ROOT_ENV_VAR, "").strip() - if not configured_root: - return None - return Path(configured_root).expanduser().resolve() +def runtime_environment_configuration_guidance(*, noun: str = "these") -> str: + return f"Provide {noun} in the {RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment object for the selected context and instance." -def runtime_environment_configuration_guidance(*, noun: str = "these") -> str: - if resolve_control_plane_root() is None: - return ( - f"Set {CONTROL_PLANE_ROOT_ENV_VAR} to a valid harbor checkout and configure {noun} in " - "`config/runtime-environments.toml`." - ) - return ( - f"Configure {noun} in the control-plane runtime environments file resolved through " - f"{CONTROL_PLANE_ROOT_ENV_VAR} (`config/runtime-environments.toml` by default)." +def ensure_required_environment_values( + *, + stack_definition: StackDefinition, + loaded_environment: LoadedEnvironment, +) -> None: + ensure_required_environment_mapping( + required_keys=stack_definition.required_env_keys, + environment_values=loaded_environment.merged_values, + source_description=f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment", ) +def ensure_required_runtime_selection_values( + *, + stack_definition: StackDefinition, + loaded_environment: LoadedEnvironment, + runtime_selection: RuntimeSelection, +) -> None: + effective_environment = dict(loaded_environment.merged_values) + effective_environment.update( + {runtime_key: str(runtime_value) for runtime_key, runtime_value in runtime_selection.effective_runtime_env.items()} + ) + ensure_required_environment_mapping( + required_keys=stack_definition.required_env_keys, + environment_values=effective_environment, + source_description="Resolved runtime environment", + ) + + +def ensure_required_environment_mapping( + *, + required_keys: tuple[str, ...], + environment_values: dict[str, str], + source_description: str, +) -> None: + missing_keys = [required_key for required_key in required_keys if not environment_values.get(required_key, "").strip()] + if not missing_keys: + return + raise RuntimeCommandError(f"{source_description} is missing required non-empty values: {', '.join(missing_keys)}.") + + def legacy_local_environment_files(repo_root: Path) -> list[Path]: legacy_files = [ repo_root / ".env", @@ -1399,59 +1441,9 @@ def ensure_legacy_local_environment_files_are_absent(repo_root: Path) -> None: if legacy_file_display is None: return raise RuntimeCommandError( - "Local runtime environment authority is configured to come from the control plane via " - f"{CONTROL_PLANE_ROOT_ENV_VAR}, but legacy devkit-local env/secrets files still exist: {legacy_file_display}. " - "Remove or migrate those files before continuing so environment authority stays single-source and fail-closed." - ) - - -def load_environment_from_control_plane( - *, - control_plane_root: Path, - context_name: str, - instance_name: str, -) -> LoadedEnvironment: - control_plane_command = [ - "uv", - "--directory", - str(control_plane_root), - "run", - "launchplane", - "environments", - "resolve", - "--context", - context_name, - "--instance", - instance_name, - "--json-output", - ] - result = subprocess.run(control_plane_command, capture_output=True, text=True, env=command_execution_env()) - if result.returncode != 0: - details = clean_optional_value(result.stderr) or clean_optional_value(result.stdout) - raise RuntimeCommandError( - "Unable to resolve runtime environment from control plane. " - f"Ensure {CONTROL_PLANE_ROOT_ENV_VAR} points at a valid harbor checkout." - + (f"\nControl plane reported: {details}" if details else "") - ) - try: - payload = json.loads(result.stdout) - except json.JSONDecodeError as error: - raise RuntimeCommandError( - "Control plane returned invalid runtime environment payload while resolving local runtime config." - ) from error - raw_environment = payload.get("environment") - if not isinstance(raw_environment, dict): - raise RuntimeCommandError("Control plane runtime environment payload did not 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) - } - synthetic_env_file = control_plane_root / ".generated" / "runtime-env" / f"{context_name}.{instance_name}.env" - return LoadedEnvironment( - env_file_path=synthetic_env_file, - merged_values=resolved_environment, - collisions=(), + "Legacy devkit-local env/secrets files are no longer supported for runtime environment input: " + f"{legacy_file_display}. Remove them and provide the selected context/instance through " + f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} so runtime input stays explicit and fail-closed." ) @@ -1830,7 +1822,7 @@ 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(): + if explicit_runtime_environment_payload_is_configured() and runtime_selection.instance_name != "local": for runtime_key in ARTIFACT_PUBLISH_RUNTIME_ENV_KEYS: runtime_values[runtime_key] = source_environment.get(runtime_key, "") apply_typed_odoo_instance_override_payload( @@ -1840,6 +1832,11 @@ def build_runtime_env_values( odoo_overrides=runtime_selection.effective_odoo_overrides, website_bootstrap=runtime_selection.website_bootstrap, ) + ensure_required_environment_mapping( + required_keys=stack_definition.required_env_keys, + environment_values=runtime_values, + source_description="Resolved runtime environment", + ) return runtime_values @@ -3043,7 +3040,7 @@ def assert_active_admin_password_is_not_default( def command_execution_env() -> dict[str, str]: - execution_env = dict(os.environ) + execution_env = sanitized_subprocess_environment() for runtime_key in PLATFORM_RUNTIME_ENV_KEYS: execution_env.pop(runtime_key, None) for passthrough_key in PLATFORM_RUNTIME_PASSTHROUGH_KEYS: diff --git a/odoo_devkit/runtime_environment.py b/odoo_devkit/runtime_environment.py new file mode 100644 index 0000000..13405c8 --- /dev/null +++ b/odoo_devkit/runtime_environment.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import os + +RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR = "ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON" + + +def sanitized_subprocess_environment() -> dict[str, str]: + environment = dict(os.environ) + environment.pop(RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR, None) + return environment diff --git a/odoo_devkit/workspace.py b/odoo_devkit/workspace.py index 0249c05..1e1fa0b 100644 --- a/odoo_devkit/workspace.py +++ b/odoo_devkit/workspace.py @@ -12,6 +12,7 @@ from .manifest import RepoDefinition, WorkspaceManifest from .pycharm import write_pycharm_support_files from .runtime import resolve_runtime_repo_path +from .runtime_environment import sanitized_subprocess_environment from .workspace_surface import write_workspace_surface_files @@ -313,7 +314,13 @@ def _assert_managed_repo_origin(repo_path: Path, *, repo_definition: RepoDefinit def _run_git_command(repo_path: Path | None, *arguments: str) -> None: - completed_process = subprocess.run(["git", *arguments], cwd=repo_path, capture_output=True, text=True) + completed_process = subprocess.run( + ["git", *arguments], + cwd=repo_path, + capture_output=True, + text=True, + env=sanitized_subprocess_environment(), + ) if completed_process.returncode != 0: stderr = completed_process.stderr.strip() stdout = completed_process.stdout.strip() @@ -464,7 +471,13 @@ def _describe_repo_state(role: str, repo_definition: RepoDefinition, repo_path: def _git_output(repo_path: Path, *arguments: str) -> str | None: - completed_process = subprocess.run(["git", *arguments], cwd=repo_path, capture_output=True, text=True) + completed_process = subprocess.run( + ["git", *arguments], + cwd=repo_path, + capture_output=True, + text=True, + env=sanitized_subprocess_environment(), + ) if completed_process.returncode != 0: return None output = completed_process.stdout.strip() @@ -472,14 +485,26 @@ def _git_output(repo_path: Path, *arguments: str) -> str | None: def _git_is_work_tree(repo_path: Path) -> bool: - completed_process = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], cwd=repo_path, capture_output=True, text=True) + completed_process = subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=repo_path, + capture_output=True, + text=True, + env=sanitized_subprocess_environment(), + ) if completed_process.returncode != 0: return False return completed_process.stdout.strip() == "true" def _git_dirty(repo_path: Path) -> bool: - completed_process = subprocess.run(["git", "status", "--short"], cwd=repo_path, capture_output=True, text=True) + completed_process = subprocess.run( + ["git", "status", "--short"], + cwd=repo_path, + capture_output=True, + text=True, + env=sanitized_subprocess_environment(), + ) if completed_process.returncode != 0: return False return bool(completed_process.stdout.strip()) diff --git a/odoo_devkit/workspace_surface.py b/odoo_devkit/workspace_surface.py index c77d2ab..e89de16 100644 --- a/odoo_devkit/workspace_surface.py +++ b/odoo_devkit/workspace_surface.py @@ -103,12 +103,13 @@ def _render_workspace_agents( "This file is generated by `uv run platform workspace sync`. Treat the workspace root as the shared Every Code cockpit for this tenant while keeping hand-edited source-of-truth changes in the underlying repos.\n\n" "## Start Here\n\n" "- Open `docs/README.md` in this workspace root first. It routes to the shared devkit docs and the tenant-specific docs.\n" - "- If present, open `AGENTS.override.md` for local, non-secret operator details before touching infra, SSH, tunnels, or remote service configuration. Keep secrets in `.env`.\n" + "- If present, open `AGENTS.override.md` for local, non-secret operator details before touching infra, SSH, tunnels, or remote service configuration. Keep secrets in operator-local secret storage outside the workspace.\n" "- For the deeper shared operating guide, open `sources/devkit/AGENTS.md`.\n" "- For workspace command details, open `sources/devkit/docs/tooling/workspace-cli.md`.\n" "- Use `sources/tenant/` for the active tenant source tree. PyCharm should still open that tenant repo directly by default.\n" "- Use `sources/devkit/` for shared DX/runtime/tooling ownership. That repo is the canonical owner of the shared operating guide and docs used to generate this workspace surface.\n" "- Treat `platform runtime` as the home for local runtime work and artifact handoff. Stable remote lanes are `testing` and `prod`; Launchplane PR previews replace a durable `dev` lane; remote restore/bootstrap/update and release actions such as ship/promote/gate stay in `launchplane`.\n" + "- Before local runtime commands, inject the context/instance-scoped `ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON` payload from operator-local secret storage. Never put it in the manifest, generated workspace files, or checked-in env/secrets files.\n" f"{shared_addons_pointer}\n" "- Treat `.generated/` as managed output only. Treat `state/`, when present, as legacy or disposable local runtime output; it should not become a long-term home for hand-edited code or secrets.\n\n" "## Source Of Truth Rules\n\n" @@ -241,6 +242,7 @@ def _render_workspace_docs_index( f"- Preferred tenant-root sync command: `{tenant_workspace_sync_command}`.\n" f"- Preferred tenant-root status command: `{tenant_workspace_status_command}`.\n" "- Treat `platform runtime` as the local-runtime and artifact-handoff surface. Stable remote lanes are `testing` and `prod`; Launchplane PR previews replace a durable `dev` lane; remote restore/bootstrap/update and release actions belong in `launchplane`.\n" + "- Local runtime commands require `ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON` from operator-local secret storage; generated workspace files never store that payload.\n" "- When in doubt about ownership, fix the source repo under `sources/` instead of editing generated files in the workspace root.\n" ) @@ -273,6 +275,7 @@ def _render_workspace_session_prompt( "- Keep tenant code in sources/tenant.\n" "- Keep shared DX/runtime/workspace behavior in odoo-devkit.\n" "- Use platform runtime for local runtime and artifact handoff.\n" + "- Inject ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON from operator-local secret storage before local runtime commands; never write it into generated workspace files.\n" "- Use launchplane for remote release and non-local data actions.\n" "- Stable remote lanes are testing and prod.\n" "- Launchplane PR previews replace any durable shared dev lane.\n" diff --git a/platform/compose/base.yaml b/platform/compose/base.yaml index 4225db2..232e836 100644 --- a/platform/compose/base.yaml +++ b/platform/compose/base.yaml @@ -6,19 +6,13 @@ services: command: >- ${ODOO_WEB_COMMAND:-/odoo/odoo-bin} env_file: - - path: .env - required: false - platform/config/base.env - - ${PLATFORM_RUNTIME_ENV_FILE:-.env} + - ${PLATFORM_RUNTIME_ENV_FILE:?missing} script-runner: env_file: - - path: .env - required: false - platform/config/base.env - - ${PLATFORM_RUNTIME_ENV_FILE:-.env} + - ${PLATFORM_RUNTIME_ENV_FILE:?missing} database: env_file: - - path: .env - required: false - platform/config/base.env - - ${PLATFORM_RUNTIME_ENV_FILE:-.env} + - ${PLATFORM_RUNTIME_ENV_FILE:?missing} diff --git a/platform/config/README.md b/platform/config/README.md index 305b644..56841c2 100644 --- a/platform/config/README.md +++ b/platform/config/README.md @@ -1,39 +1,37 @@ # Platform Config Layering This directory holds fallback defaults for devkit-owned local runtime compose -commands. Canonical tenant environment values come from `harbor`, -not from devkit-local secrets files. +commands. Required tenant runtime values come from the typed +`ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON` operator input, not from checked-in or +devkit-local secrets files. ## Platform Runtime Generation `platform runtime ...` resolves configuration in this order: ```text -harbor config/runtime-environments.toml --> selected generated runtime env file --> platform/config/base.env fallback defaults +platform/config/base.env safe compose fallbacks ++ ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON ++ selected stack local configuration += selected generated runtime env file ``` -`ODOO_CONTROL_PLANE_ROOT` must point at the control-plane checkout when runtime -commands need tenant environment truth. +The payload context and instance must exactly match the manifest-selected +runtime. Missing or mismatched input fails closed. -## Raw Compose Loading +## Raw Compose -Raw `docker compose` commands that bypass `platform runtime ...` still use the -compose `env_file` entries directly: - -```text -.env (optional) --> platform/config/base.env -``` - -Prefer `platform runtime ...` for tenant work so environment authority remains -single-source. +Raw `docker compose` bypasses the typed runtime resolver and is not a supported +tenant runtime input path. Use `platform runtime ...`; the compose fragments now +require its generated `PLATFORM_RUNTIME_ENV_FILE` instead of falling back to a +repo-local `.env` file. ## Rules -- Keep tenant secrets and canonical stack values in control-plane environment - contracts. +- Keep local tenant secrets in operator-local secret storage and inject them + through the typed runtime payload. +- Keep shared/testing/prod values in Launchplane-managed runtime records and + secrets. - Keep `platform/config/base.env` limited to shared fallback defaults. - If a canonical value conflicts with `base.env`, tooling fails closed and asks for the duplicate to be aligned or removed. diff --git a/platform/config/base.env b/platform/config/base.env index 1a68b5a..59bd803 100644 --- a/platform/config/base.env +++ b/platform/config/base.env @@ -1,6 +1,6 @@ # Shared environment defaults for all stacks (checked in, safe defaults only). -# Put runtime-environment overrides in the control-plane runtime environments -# file pointed to by `ODOO_CONTROL_PLANE_ROOT`. +# Inject runtime-environment values through +# `ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON`. LOCAL_ADDONS_DIRS= ODOO_WITHOUT_DEMO=true diff --git a/tests/test_compose_contract.py b/tests/test_compose_contract.py index dcaeccf..33155f9 100644 --- a/tests/test_compose_contract.py +++ b/tests/test_compose_contract.py @@ -11,8 +11,17 @@ def test_base_compose_file_is_pull_only_for_web(self) -> None: self.assertNotIn("x-odoo-build", base_compose_text) self.assertNotIn("<<: [*odoo-base, *odoo-build]", base_compose_text) + self.assertNotIn("path: .env", base_compose_text) self.assertIn(" web:\n <<: *odoo-base\n", base_compose_text) + def test_shared_compose_requires_generated_runtime_env(self) -> None: + repo_root = Path(__file__).resolve().parent.parent + shared_compose_text = (repo_root / "platform" / "compose" / "base.yaml").read_text(encoding="utf-8") + + self.assertNotIn("path: .env", shared_compose_text) + self.assertNotIn("PLATFORM_RUNTIME_ENV_FILE:-.env", shared_compose_text) + self.assertEqual(shared_compose_text.count("${PLATFORM_RUNTIME_ENV_FILE:?missing}"), 3) + def test_override_compose_file_owns_local_web_build(self) -> None: repo_root = Path(__file__).resolve().parent.parent override_compose_path = repo_root / "docker-compose.override.yml" diff --git a/tests/test_control_plane_cli_contract.py b/tests/test_control_plane_cli_contract.py deleted file mode 100644 index 4463561..0000000 --- a/tests/test_control_plane_cli_contract.py +++ /dev/null @@ -1,99 +0,0 @@ -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_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_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, - 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() diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 21a505c..f55102e 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -47,23 +47,18 @@ class RuntimeCommandTests(unittest.TestCase): def setUp(self) -> None: super().setUp() - self.control_plane_root_temporary_directory = tempfile.TemporaryDirectory() - self.control_plane_root = Path(self.control_plane_root_temporary_directory.name) / "harbor" - self.control_plane_root.mkdir(parents=True, exist_ok=True) - self.environment_patch = mock.patch.dict( - os.environ, {local_runtime.CONTROL_PLANE_ROOT_ENV_VAR: str(self.control_plane_root)} - ) + self.explicit_payload_loader = local_runtime.load_environment_from_explicit_payload + self.environment_patch = mock.patch.dict(os.environ, {local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: "{}"}) self.environment_patch.start() self.load_environment_patch = mock.patch( - "odoo_devkit.local_runtime.load_environment_from_control_plane", - side_effect=self._load_environment_from_control_plane, + "odoo_devkit.local_runtime.load_environment_from_explicit_payload", + side_effect=self._load_environment_from_explicit_payload, ) self.load_environment_patch.start() def tearDown(self) -> None: self.load_environment_patch.stop() self.environment_patch.stop() - self.control_plane_root_temporary_directory.cleanup() super().tearDown() def _write_buildx_metadata_for_command(self, command: list[str]) -> None: @@ -79,9 +74,9 @@ def _configure_publish_runtime_payload( environment: dict[str, str] | None = None, ) -> None: payload_environment = { - "ODOO_MASTER_PASSWORD": "control-plane-master", + "ODOO_MASTER_PASSWORD": "runtime-payload-master", "ODOO_DB_USER": "odoo", - "ODOO_DB_PASSWORD": "control-plane-secret", + "ODOO_DB_PASSWORD": "runtime-payload-database", "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", @@ -408,11 +403,12 @@ def test_resolve_runtime_repo_path_requires_workspace_sync_for_repo_addressable_ with self.assertRaisesRegex(ValueError, "must be materialized by `platform workspace sync`"): resolve_runtime_repo_path(manifest) - def test_resolve_runtime_repo_path_uses_materialized_repo_addressable_runtime(self) -> None: + def test_synced_repo_addressable_runtime_supports_local_inspect(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) tenant_repo_path = temp_root / "tenant-repo" tenant_repo_path.mkdir(parents=True, exist_ok=True) + (tenant_repo_path / "addons" / "opw_custom").mkdir(parents=True, exist_ok=True) devkit_repo_path = self._create_git_repo(temp_root / "devkit-repo") runtime_repo_path = temp_root / "runtime-repo" self._write_runtime_repo(runtime_repo_path) @@ -479,6 +475,36 @@ def test_resolve_runtime_repo_path_uses_materialized_repo_addressable_runtime(se ), ) + payload = json.dumps( + { + "context": "opw", + "instance": "local", + "environment": { + "ODOO_MASTER_PASSWORD": "test-master-value", + "ODOO_DB_USER": "odoo", + "ODOO_DB_PASSWORD": "test-database-value", + }, + } + ) + inspect_output = io.StringIO() + with mock.patch( + "odoo_devkit.local_runtime.load_environment_from_explicit_payload", + side_effect=self.explicit_payload_loader, + ): + with mock.patch.dict( + os.environ, + {local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: payload}, + clear=True, + ): + with contextlib.redirect_stdout(inspect_output): + exit_code = run_native_runtime_inspect(manifest=manifest) + + self.assertEqual(exit_code, 0) + self.assertIn("context=opw", inspect_output.getvalue()) + self.assertIn("instance=local", inspect_output.getvalue()) + self.assertNotIn("test-master-value", inspect_output.getvalue()) + self.assertNotIn("test-database-value", inspect_output.getvalue()) + def test_resolve_runtime_repo_path_defaults_to_devkit_repo_for_local_instance(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) @@ -799,79 +825,198 @@ def test_native_runtime_inspect_emits_key_value_payload(self) -> None: self.assertIn("project_addons_host_path=", inspect_output) self.assertIn('"opw_custom"', inspect_output) - def test_load_environment_prefers_control_plane_contract_when_configured(self) -> None: + def test_native_runtime_inspect_rejects_missing_required_payload_values(self) -> None: + for environment in ( + {"ODOO_MASTER_PASSWORD": "test-master-value"}, + { + "ODOO_MASTER_PASSWORD": "test-master-value", + "ODOO_DB_USER": "odoo", + "ODOO_DB_PASSWORD": " ", + }, + ): + with self.subTest(environment=environment): + 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(parents=True, exist_ok=True) + self._write_runtime_repo(runtime_repo_path) + manifest_path = self._write_manifest( + tenant_repo_path=tenant_repo_path, + runtime_repo_path=runtime_repo_path, + ) + manifest = load_workspace_manifest(manifest_path) + payload = json.dumps( + { + "context": "opw", + "instance": "local", + "environment": environment, + } + ) + + with mock.patch( + "odoo_devkit.local_runtime.load_environment_from_explicit_payload", + side_effect=self.explicit_payload_loader, + ): + with mock.patch.dict( + os.environ, + {local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: payload}, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "missing required non-empty values"): + run_native_runtime_inspect(manifest=manifest) + + self.assertFalse((runtime_repo_path / ".platform").exists()) + + def test_native_runtime_inspect_rejects_stack_override_of_required_payload_value(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(parents=True, exist_ok=True) self._write_runtime_repo(runtime_repo_path) + stack_path = runtime_repo_path / "platform" / "stack.toml" + stack_path.write_text( + stack_path.read_text(encoding="utf-8").replace( + "[contexts.opw.instances.local]\n\n[contexts.opw.instances.dev]", + """[contexts.opw.instances.local] - loaded_environment = local_runtime.load_environment( - repo_root=runtime_repo_path, - context_name="opw", - instance_name="local", +[contexts.opw.instances.local.runtime_env] +ODOO_DB_PASSWORD = "" + +[contexts.opw.instances.dev]""", + ), + encoding="utf-8", + ) + manifest_path = self._write_manifest( + tenant_repo_path=tenant_repo_path, + runtime_repo_path=runtime_repo_path, ) + manifest = load_workspace_manifest(manifest_path) + payload = json.dumps( + { + "context": "opw", + "instance": "local", + "environment": { + "ODOO_MASTER_PASSWORD": "test-master-value", + "ODOO_DB_USER": "odoo", + "ODOO_DB_PASSWORD": "test-database-value", + }, + } + ) + + with mock.patch( + "odoo_devkit.local_runtime.load_environment_from_explicit_payload", + side_effect=self.explicit_payload_loader, + ): + with mock.patch.dict( + os.environ, + {local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: payload}, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "Resolved runtime environment is missing required"): + run_native_runtime_inspect(manifest=manifest) + + self.assertFalse((runtime_repo_path / ".platform").exists()) + + def test_load_environment_uses_explicit_runtime_payload(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + temp_root = Path(temporary_directory) + runtime_repo_path = temp_root / "runtime-repo" + self._write_runtime_repo(runtime_repo_path) + payload = json.dumps( + { + "context": "opw", + "instance": "local", + "environment": { + "ODOO_MASTER_PASSWORD": "test-master-value", + "ODOO_DB_USER": "odoo", + "ODOO_DB_PASSWORD": "test-database-value", + }, + } + ) + + with mock.patch( + "odoo_devkit.local_runtime.load_environment_from_explicit_payload", + side_effect=self.explicit_payload_loader, + ): + with mock.patch.dict( + os.environ, + {local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: payload}, + clear=True, + ): + loaded_environment = local_runtime.load_environment( + repo_root=runtime_repo_path, + context_name="opw", + instance_name="local", + ) - self.assertEqual(loaded_environment.merged_values["ODOO_MASTER_PASSWORD"], "control-plane-master") - self.assertEqual(loaded_environment.merged_values["ODOO_DB_PASSWORD"], "control-plane-secret") + self.assertEqual(loaded_environment.merged_values["ODOO_MASTER_PASSWORD"], "test-master-value") + self.assertEqual(loaded_environment.merged_values["ODOO_DB_PASSWORD"], "test-database-value") - def test_load_environment_fails_closed_when_control_plane_and_legacy_files_coexist(self) -> None: + def test_load_environment_fails_closed_when_payload_and_legacy_files_coexist(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) runtime_repo_path = temp_root / "runtime-repo" self._write_runtime_repo(runtime_repo_path) (runtime_repo_path / ".env").write_text("ODOO_MASTER_PASSWORD=legacy\n", encoding="utf-8") - with self.assertRaisesRegex(ValueError, "legacy devkit-local env/secrets files still exist"): + with self.assertRaisesRegex(ValueError, "Legacy devkit-local env/secrets files are no longer supported"): local_runtime.load_environment( repo_root=runtime_repo_path, context_name="opw", instance_name="local", ) - def test_load_environment_requires_control_plane_root(self) -> None: + def test_load_environment_requires_explicit_runtime_payload(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) runtime_repo_path = temp_root / "runtime-repo" self._write_runtime_repo(runtime_repo_path) with mock.patch.dict(os.environ, {}, clear=True): - with self.assertRaisesRegex(ValueError, local_runtime.CONTROL_PLANE_ROOT_ENV_VAR): + with self.assertRaisesRegex(ValueError, local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR): local_runtime.load_environment( repo_root=runtime_repo_path, context_name="opw", instance_name="local", ) - def test_load_environment_rejects_legacy_local_env_files_without_control_plane_root(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - temp_root = Path(temporary_directory) - runtime_repo_path = temp_root / "runtime-repo" - self._write_runtime_repo(runtime_repo_path) - (runtime_repo_path / ".env").write_text("ODOO_MASTER_PASSWORD=legacy\n", encoding="utf-8") - - with mock.patch.dict(os.environ, {}, clear=True): - with self.assertRaisesRegex(ValueError, "no longer supported"): - local_runtime.load_environment( - repo_root=runtime_repo_path, - context_name="opw", - instance_name="local", - ) + def test_load_environment_rejects_legacy_local_env_files_without_payload(self) -> None: + for relative_path in (Path(".env"), Path("platform/.env"), Path("platform/secrets.toml")): + with self.subTest(relative_path=relative_path): + with tempfile.TemporaryDirectory() as temporary_directory: + temp_root = Path(temporary_directory) + runtime_repo_path = temp_root / "runtime-repo" + self._write_runtime_repo(runtime_repo_path) + legacy_path = runtime_repo_path / relative_path + legacy_path.parent.mkdir(parents=True, exist_ok=True) + legacy_path.write_text("ODOO_MASTER_PASSWORD=legacy\n", encoding="utf-8") + + with mock.patch.dict(os.environ, {}, clear=True): + with self.assertRaisesRegex(ValueError, "no longer supported"): + local_runtime.load_environment( + repo_root=runtime_repo_path, + context_name="opw", + instance_name="local", + ) - def test_runtime_environment_configuration_guidance_requires_control_plane_when_unset(self) -> None: - with mock.patch.dict(os.environ, {}, clear=True): - guidance = local_runtime.runtime_environment_configuration_guidance() + def test_runtime_environment_configuration_guidance_uses_explicit_payload(self) -> None: + guidance = local_runtime.runtime_environment_configuration_guidance(noun="it") - self.assertIn(local_runtime.CONTROL_PLANE_ROOT_ENV_VAR, guidance) - self.assertIn("config/runtime-environments.toml", guidance) + self.assertIn(local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR, guidance) + self.assertIn("selected context and instance", guidance) + self.assertNotIn("runtime-environments.toml", guidance) + self.assertNotIn("harbor", guidance) - def test_runtime_environment_configuration_guidance_mentions_control_plane_when_configured(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - temp_root = Path(temporary_directory) - with mock.patch.dict(os.environ, {local_runtime.CONTROL_PLANE_ROOT_ENV_VAR: str(temp_root / "harbor")}): - guidance = local_runtime.runtime_environment_configuration_guidance(noun="it") + def test_command_execution_environment_excludes_runtime_payload(self) -> None: + with mock.patch.dict( + os.environ, + {local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: '{"environment":{"SECRET":"test-only"}}'}, + ): + execution_environment = local_runtime.command_execution_env() - self.assertIn(local_runtime.CONTROL_PLANE_ROOT_ENV_VAR, guidance) - self.assertIn("config/runtime-environments.toml", guidance) + self.assertNotIn(local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR, execution_environment) def test_native_runtime_select_prefers_manifest_mounts_over_runtime_repo_defaults(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: @@ -1164,19 +1309,8 @@ def fake_run_command( resolved_ref = "411f6b8e85cac72dc7aa2e2dc5540001043c327d" with mock.patch( - "odoo_devkit.local_runtime.load_environment_from_control_plane", - return_value=local_runtime.LoadedEnvironment( - env_file_path=self.control_plane_root / ".generated" / "runtime-env" / "opw.testing.env", - merged_values={ - "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", - }, - collisions=(), - ), + "odoo_devkit.local_runtime.load_environment_from_explicit_payload", + side_effect=self.explicit_payload_loader, ): 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"): @@ -1382,20 +1516,8 @@ def fake_run_command( resolved_ref = "411f6b8e85cac72dc7aa2e2dc5540001043c327d" with mock.patch( - "odoo_devkit.local_runtime.load_environment_from_control_plane", - return_value=local_runtime.LoadedEnvironment( - env_file_path=self.control_plane_root / ".generated" / "runtime-env" / "opw.testing.env", - merged_values={ - "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", - "ODOO_ADDON_REPOSITORIES": "cbusillo/disable_odoo_online@ffffffffffffffffffffffffffffffffffffffff", - }, - collisions=(), - ), + "odoo_devkit.local_runtime.load_environment_from_explicit_payload", + side_effect=self.explicit_payload_loader, ): 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"): @@ -1438,6 +1560,48 @@ def fake_run_command( ["launchplane_settings", "disable_odoo_online", "opw_custom"], ) + def test_native_runtime_publish_revalidates_required_values_after_artifact_inputs(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(parents=True, exist_ok=True) + self._write_runtime_repo(runtime_repo_path) + stack_path = runtime_repo_path / "platform" / "stack.toml" + stack_path.write_text( + stack_path.read_text(encoding="utf-8").replace( + 'required_env_keys = ["ODOO_MASTER_PASSWORD", "ODOO_DB_USER", "ODOO_DB_PASSWORD"]', + 'required_env_keys = ["ODOO_MASTER_PASSWORD", "ODOO_DB_USER", "ODOO_DB_PASSWORD", "ODOO_ADDON_REPOSITORIES"]', + ), + encoding="utf-8", + ) + (tenant_repo_path / "artifact-inputs.toml").write_text( + "schema_version = 1\nsources = []\n", + encoding="utf-8", + ) + manifest_path = self._write_manifest( + tenant_repo_path=tenant_repo_path, + runtime_repo_path=runtime_repo_path, + instance_name="testing", + artifact_inputs_file="artifact-inputs.toml", + ) + manifest = load_workspace_manifest(manifest_path) + self._configure_publish_runtime_payload( + environment={"ODOO_ADDON_REPOSITORIES": "example/addon@411f6b8e85cac72dc7aa2e2dc5540001043c327d"} + ) + + with self.assertRaisesRegex( + ValueError, + "Resolved publish runtime environment is missing required non-empty values: ODOO_ADDON_REPOSITORIES", + ): + run_native_runtime_publish( + manifest=manifest, + image_repository="ghcr.io/example/opw-runtime", + image_tag="opw-20260416-abcdef", + output_file=None, + no_cache=False, + ) + def test_native_runtime_publish_rejects_invalid_artifact_inputs_manifest(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) @@ -1738,7 +1902,7 @@ def test_runtime_payload_synthesizes_missing_instance_in_existing_context(self) 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: + def test_native_runtime_publish_prefers_exact_payload_refs_over_stack_defaults(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) tenant_repo_path = self._create_git_repo(temp_root / "tenant-repo") @@ -1797,20 +1961,8 @@ def fake_run_command( exact_ref = "cbusillo/disable_odoo_online@411f6b8e85cac72dc7aa2e2dc5540001043c327d" with mock.patch( - "odoo_devkit.local_runtime.load_environment_from_control_plane", - return_value=local_runtime.LoadedEnvironment( - env_file_path=self.control_plane_root / ".generated" / "runtime-env" / "cm.testing.env", - merged_values={ - "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", - "ODOO_ADDON_REPOSITORIES": exact_ref, - }, - collisions=(), - ), + "odoo_devkit.local_runtime.load_environment_from_explicit_payload", + side_effect=self.explicit_payload_loader, ): 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"): @@ -1986,9 +2138,9 @@ def fake_run_command( "instance": "testing", "environment": { "ODOO_VERSION": "19.0", - "ODOO_MASTER_PASSWORD": "control-plane-master", + "ODOO_MASTER_PASSWORD": "runtime-payload-master", "ODOO_DB_USER": "odoo", - "ODOO_DB_PASSWORD": "control-plane-secret", + "ODOO_DB_PASSWORD": "runtime-payload-database", "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", @@ -2894,20 +3046,25 @@ def run_side_effect(*args: object, **kwargs: object) -> mock.Mock: return run_side_effect - def _load_environment_from_control_plane( + def _load_environment_from_explicit_payload( self, *, - control_plane_root: Path, + raw_payload: str, context_name: str, instance_name: str, ) -> local_runtime.LoadedEnvironment: - _ = control_plane_root, context_name, instance_name + if raw_payload != "{}": + return self.explicit_payload_loader( + raw_payload=raw_payload, + context_name=context_name, + instance_name=instance_name, + ) return local_runtime.LoadedEnvironment( - env_file_path=self.control_plane_root / ".generated" / "runtime-env" / f"{context_name}.{instance_name}.env", + env_file_path=Path(".generated") / "runtime-env" / f"{context_name}.{instance_name}.env", merged_values={ - "ODOO_MASTER_PASSWORD": "control-plane-master", + "ODOO_MASTER_PASSWORD": "runtime-payload-master", "ODOO_DB_USER": "odoo", - "ODOO_DB_PASSWORD": "control-plane-secret", + "ODOO_DB_PASSWORD": "runtime-payload-database", "ODOO_UPSTREAM_HOST": "example.internal", "ODOO_UPSTREAM_USER": "odoo", "ODOO_UPSTREAM_DB_NAME": "opw-source", diff --git a/tests/test_runtime_environment_contract.py b/tests/test_runtime_environment_contract.py new file mode 100644 index 0000000..7ca9fdf --- /dev/null +++ b/tests/test_runtime_environment_contract.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +import os +import unittest +from unittest import mock + +from odoo_devkit import local_runtime +from odoo_devkit.runtime_environment import sanitized_subprocess_environment + + +class RuntimeEnvironmentPayloadContractTests(unittest.TestCase): + def test_environment_resolution_accepts_explicit_runtime_payload(self) -> None: + loaded_environment = local_runtime.load_environment_from_explicit_payload( + raw_payload=json.dumps( + { + "context": "cm", + "instance": "testing", + "environment": { + "ODOO_MASTER_PASSWORD": "test-master-value", + }, + } + ), + context_name="cm", + instance_name="testing", + ) + + self.assertEqual( + loaded_environment.merged_values["ODOO_MASTER_PASSWORD"], + "test-master-value", + ) + + def test_environment_resolution_rejects_mismatched_explicit_payload(self) -> None: + for payload_context, payload_instance in (("opw", "testing"), ("cm", "prod")): + with self.subTest(context=payload_context, instance=payload_instance): + with self.assertRaisesRegex(local_runtime.RuntimeCommandError, "does not match the selected runtime"): + local_runtime.load_environment_from_explicit_payload( + raw_payload=json.dumps( + { + "context": payload_context, + "instance": payload_instance, + "environment": {"ODOO_MASTER_PASSWORD": "test-only"}, + } + ), + context_name="cm", + 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_rejects_empty_context_or_instance(self) -> None: + for context_name, instance_name in (("", "testing"), ("cm", "")): + with self.subTest(context=context_name, instance=instance_name): + with self.assertRaisesRegex( + local_runtime.RuntimeCommandError, + "context and instance must be non-empty strings", + ): + local_runtime.load_environment_from_explicit_payload( + raw_payload=json.dumps( + { + "context": context_name, + "instance": instance_name, + "environment": {"ODOO_MASTER_PASSWORD": "test-only"}, + } + ), + context_name="cm", + instance_name="testing", + ) + + def test_environment_resolution_rejects_empty_environment(self) -> None: + with self.assertRaisesRegex(local_runtime.RuntimeCommandError, "environment object must not be empty"): + local_runtime.load_environment_from_explicit_payload( + raw_payload=json.dumps( + { + "context": "cm", + "instance": "testing", + "environment": {}, + } + ), + context_name="cm", + instance_name="testing", + ) + + def test_environment_resolution_rejects_unsafe_environment_keys(self) -> None: + for environment_key in ("", "INVALID-NAME", "INJECTED=value", "LINE\nBREAK"): + with self.subTest(environment_key=environment_key): + with self.assertRaisesRegex(local_runtime.RuntimeCommandError, "valid environment variable names"): + local_runtime.load_environment_from_explicit_payload( + raw_payload=json.dumps( + { + "context": "cm", + "instance": "testing", + "environment": {environment_key: "test-only"}, + } + ), + context_name="cm", + instance_name="testing", + ) + + def test_environment_resolution_rejects_unsafe_environment_values(self) -> None: + for environment_value in ( + "line-one\nINJECTED=value", + "line-one\rline-two", + "line-one\x85INJECTED=value", + "line-one\u2028INJECTED=value", + "line-one\u2029INJECTED=value", + "value\x00suffix", + ): + with self.subTest(environment_value=repr(environment_value)): + with self.assertRaisesRegex(local_runtime.RuntimeCommandError, "must not contain"): + local_runtime.load_environment_from_explicit_payload( + raw_payload=json.dumps( + { + "context": "cm", + "instance": "testing", + "environment": {"ODOO_MASTER_PASSWORD": environment_value}, + } + ), + context_name="cm", + instance_name="testing", + ) + + def test_sanitized_subprocess_environment_excludes_runtime_payload(self) -> None: + with mock.patch.dict( + os.environ, + {local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: '{"environment":{"SECRET":"test-only"}}'}, + ): + child_environment = sanitized_subprocess_environment() + + self.assertNotIn(local_runtime.RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR, child_environment) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 6421f32..d002228 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -1,17 +1,30 @@ from __future__ import annotations import json +import os import subprocess import tempfile import unittest from pathlib import Path +from unittest import mock +from odoo_devkit import workspace from odoo_devkit.cli import build_parser from odoo_devkit.manifest import load_workspace_manifest +from odoo_devkit.runtime_environment import RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR from odoo_devkit.workspace import clean_workspace, resolve_workspace_path, sync_workspace, workspace_status class WorkspaceSyncTestCase(unittest.TestCase): + def test_internal_git_commands_exclude_runtime_payload(self) -> None: + completed_process = mock.Mock(returncode=0, stdout="main\n", stderr="") + with mock.patch.dict(os.environ, {RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR: "test-payload"}): + with mock.patch("odoo_devkit.workspace.subprocess.run", return_value=completed_process) as run_mock: + result = workspace._git_output(Path("."), "rev-parse", "--abbrev-ref", "HEAD") + + self.assertEqual(result, "main") + self.assertNotIn(RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR, run_mock.call_args.kwargs["env"]) + def test_sync_creates_workspace_lock_and_pycharm_outputs(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) @@ -578,12 +591,15 @@ def test_workspace_surface_prefers_tenant_root_scripts_when_present(self) -> Non self.assertIn("sources/tenant/scripts/workspace-status", workspace_agents_contents) self.assertIn("AGENTS.override.md", workspace_agents_contents) self.assertIn("Legacy or disposable local runtime output", workspace_agents_contents) + self.assertIn("ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON", workspace_agents_contents) self.assertIn("sources/tenant/scripts/workspace-sync", workspace_docs_contents) self.assertIn("sources/tenant/scripts/workspace-status", workspace_docs_contents) + self.assertIn("ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON", workspace_docs_contents) self.assertIn( "launchplane for remote release and non-local data actions", workspace_session_prompt_contents, ) + self.assertIn("ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON", workspace_session_prompt_contents) def test_cli_parser_accepts_workspace_run_remainder(self) -> None: parser = build_parser()