From 8a532cf01a8123a88607df7e51f5fa47c1f88565 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Fri, 29 May 2026 00:55:59 -0400 Subject: [PATCH] Localize devkit runtime mutations --- README.md | 34 +- docs/README.md | 5 +- docs/tooling/command-patterns.md | 6 +- docs/tooling/tenant-overlay.md | 6 +- docs/tooling/workspace-cli.md | 42 +-- odoo_devkit/cli.py | 14 +- odoo_devkit/dokploy_api.py | 406 ---------------------- odoo_devkit/dokploy_config.py | 430 ----------------------- odoo_devkit/local_runtime.py | 7 +- odoo_devkit/remote_runtime.py | 568 ------------------------------- odoo_devkit/runtime.py | 45 ++- odoo_devkit/workspace_cockpit.py | 4 +- odoo_devkit/workspace_surface.py | 8 +- tests/test_remote_runtime.py | 355 ------------------- tests/test_runtime.py | 82 +++-- tests/test_workspace.py | 10 +- 16 files changed, 148 insertions(+), 1874 deletions(-) delete mode 100644 odoo_devkit/dokploy_api.py delete mode 100644 odoo_devkit/dokploy_config.py delete mode 100644 odoo_devkit/remote_runtime.py delete mode 100644 tests/test_remote_runtime.py diff --git a/README.md b/README.md index b1e04f0..452d552 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ For remote environments, the stable lane model is `testing` plus `prod`. Launchplane-managed PR previews are a separate control-plane concern rather than a third durable runtime lane exposed through `platform runtime`. `odoo-devkit` can publish artifact images for handoff, but remote ship, -promote, gate, and preview lifecycle flow belongs in `launchplane`, not in -branch-oriented `odoo-devkit` commands. +promote, gate, restore, bootstrap, update, and preview lifecycle flow belongs +in `launchplane`, not in branch-oriented `odoo-devkit` commands. ## Command surface @@ -72,8 +72,8 @@ those workspaces. truth for handwritten tenant code. - Local runtime assets live in `odoo-devkit` itself. Tenant scaffolds keep `[repos.runtime]` pointed at the sibling `odoo-devkit` checkout so the same - tracked tenant manifest can target local runtime work and explicit - Dokploy-managed data workflows. + tracked tenant manifest can target local runtime work and artifact handoff + without growing a repo-local remote mutation surface. - Runtime repo ownership remains explicit. When `[repos.runtime]` is present it may be path-based or repo-addressable, `workspace sync` materializes repo-addressed runtime inputs into @@ -90,14 +90,10 @@ Current runtime ownership is intentionally narrow and explicit: `odoo-shell`, `restore`, `workflow bootstrap`, `workflow init`, `workflow update`, and `workflow openupgrade`. -- Dokploy-managed non-local runtime targets also run natively inside - `odoo-devkit` for `restore`, `workflow bootstrap`, and `workflow update` - using the runtime repo's generated env plus Dokploy target metadata from the - control-plane-owned `config/dokploy.toml` and - `config/dokploy-targets.toml` catalogs resolved through - `ODOO_CONTROL_PLANE_ROOT`. -- Those non-local targets are the stable remote lanes (`testing`, `prod`). PR - preview lifecycle and release orchestration stay outside `platform runtime`. +- Non-local runtime mutation is not a devkit command surface. Stable remote + lanes (`testing`, `prod`) route through Launchplane service APIs, operator UI, + or reusable Launchplane workflows. PR preview lifecycle also stays outside + `platform runtime`. - Release actions such as ship, promote, and gate execution belong in `launchplane`, not under `platform runtime`. - non-local `workflow init` and `workflow openupgrade` remain local-only and @@ -121,19 +117,15 @@ Current runtime ownership is intentionally narrow and explicit: uv run python -m unittest discover -s tests ``` -For tenant repos that keep `instance = "local"` in the tracked manifest, use -an explicit runtime target override only for Dokploy-managed data workflows. -Release actions should run through `launchplane`. Data workflow -examples: +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: ```bash -uv --directory ../odoo-devkit run platform runtime restore \ - --manifest ./workspace.toml \ - --instance testing uv --directory ../odoo-devkit run platform runtime workflow \ --manifest ./workspace.toml \ - --workflow bootstrap \ - --instance testing + --workflow bootstrap uv --directory ../odoo-devkit run platform runtime publish \ --manifest ./workspace.toml \ --instance testing \ diff --git a/docs/README.md b/docs/README.md index 3cf40c9..45907c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,8 +3,9 @@ This repo owns the shared DX/runtime contract used to assemble tenant workspaces. -It does not own remote release actions. Ship, promote, gate, and Launchplane -preview lifecycle for stable remote lanes live in `launchplane`. +It does not own remote release or non-local data actions. Ship, promote, gate, +restore, bootstrap, update, and Launchplane preview lifecycle for stable remote +lanes live in `launchplane`. ## Start Here diff --git a/docs/tooling/command-patterns.md b/docs/tooling/command-patterns.md index bd71bdc..885e70e 100644 --- a/docs/tooling/command-patterns.md +++ b/docs/tooling/command-patterns.md @@ -11,9 +11,9 @@ When ## Quick Start -These examples are workspace and local/data-workflow patterns. Remote release -actions such as ship, promote, and Launchplane preview lifecycle belong in -`launchplane`. +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`. - Sync the current tenant workspace: diff --git a/docs/tooling/tenant-overlay.md b/docs/tooling/tenant-overlay.md index 3b97cda..78dfade 100644 --- a/docs/tooling/tenant-overlay.md +++ b/docs/tooling/tenant-overlay.md @@ -54,15 +54,15 @@ When runtime assets come from `odoo-devkit` itself. - The scaffold also keeps `[repos.runtime]` pointed at the sibling `../odoo-devkit` checkout so the same tracked manifest can keep - `instance = "local"` while still running Dokploy-managed data workflows - through an explicit runtime `--instance` override. + `instance = "local"` while artifact publish can stage runtime inputs for + Launchplane handoff. - The scaffold includes a repo-owned `artifact-inputs.toml` beside `workspace.toml` so source selection lives in the tenant repo instead of depending on implicit runtime defaults. - Website bootstrap intent, when present, also lives beside `workspace.toml` as `website-bootstrap.toml`. Devkit consumes that file during runtime selection and data workflows apply the resulting typed payload after module install. -- Release actions for remote environments still belong in +- Remote release and non-local data actions for shared/testing/prod belong in `launchplane`, not in tenant-root `platform runtime` commands. - The generated `Workspace Sync` and `Workspace Status` entrypoints call the tenant-root helper scripts so the manifest stays anchored at the tenant repo diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index 866cc28..92be504 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -10,12 +10,9 @@ Runtime ownership is split by target type: `logs`, `psql`, `odoo-shell`, `restore`, and `platform runtime workflow --workflow bootstrap|init|update|openupgrade`. -- Dokploy-managed non-local data workflows run natively in `odoo-devkit` - for `platform runtime restore` and - `platform runtime workflow --workflow bootstrap|update`. -- Those non-local targets are the stable remote lanes (`testing`, `prod`). PR - previews belong to Launchplane preview workflows in `launchplane`, not to - `platform runtime` as another durable lane. +- Non-local restore/bootstrap/update, release, and preview lifecycle flow + belongs in Launchplane. `platform runtime` fails closed for shared/testing/prod + mutation instead of shelling into a sibling runtime checkout. - non-local `platform runtime workflow --workflow init|openupgrade` remains local-only and fail early with a clear `--instance local` requirement. - Release actions such as ship, promote, and gate execution belong in @@ -153,9 +150,9 @@ Purpose - Expose the local runtime command surface through `odoo-devkit` so tenant overlays and generated PyCharm run configurations do not need to call a sibling runtime repo directly. -- Resolve the runtime target from `workspace.toml` and execute the supported - local or Dokploy-backed workflow natively against the repo declared in - `[repos.runtime]`. +- Resolve the runtime target from `workspace.toml` and execute supported local + workflows natively against `odoo-devkit`, while leaving non-local mutation to + Launchplane service routes and reusable workflows. Notes @@ -166,15 +163,8 @@ Notes at a different `origin` than the manifest declares. - Keep the runtime repo explicit in the manifest. Tenant scaffolds point `[repos.runtime]` at the sibling `odoo-devkit` checkout so the same tracked - manifest can keep `instance = "local"` by default while still targeting - Dokploy-managed data restore/bootstrap/update flows through an explicit - runtime `--instance` override. -- Keep the runtime repo explicit in the manifest for non-local targets because - `odoo-devkit` may still need external runtime metadata from that repo or its - managed `sources/runtime` checkout. Dokploy target definitions prefer the - control-plane-owned `config/dokploy.toml` route catalog and - `config/dokploy-targets.toml` target-id catalog resolved through - `ODOO_CONTROL_PLANE_ROOT`. + manifest can keep `instance = "local"` by default while artifact publish can + still stage runtime inputs for Launchplane handoff. - Runtime ownership remains fail-closed and explicit for non-local targets. `odoo-devkit` does not guess a runtime repo from `[repos.shared_addons]`, even if that path points at a sibling `odoo-shared-addons` checkout. @@ -210,9 +200,9 @@ Notes 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. -- Native non-local ownership currently covers Dokploy-backed `restore`, - `workflow bootstrap`, and `workflow update`; anything else should fail closed - unless `odoo-devkit` grows an explicit remote contract for it. +- 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. - Public and non-local Odoo runtimes fail closed on unsafe startup credentials: the master password must be present and non-default, and an explicit admin password must be configured before the startup wrapper marks the runtime @@ -224,12 +214,10 @@ Notes - Release/deploy ownership for remote environments stays in `launchplane`, even when the same tenant manifest is used to anchor local runtime context. -- The runtime CLI accepts `--instance ` so a tenant repo can keep one - tracked local-first manifest and still run remote data workflows like - `platform runtime restore --manifest ./workspace.toml --instance testing`. -- Do not treat `--instance` as a general environment-expansion hook. The - stable remote lane model is `testing` plus `prod`; preview runtime belongs - in Launchplane preview records and generation workflows instead. +- The runtime CLI accepts `--instance ` for selection and artifact + context, but it is not a remote mutation hook. The stable remote lane model is + `testing` plus `prod`; those mutations belong in Launchplane service routes + and reusable workflows. - `platform runtime logs` and `platform runtime psql` are intentionally local-only helpers for manifest-backed debugging. They require `--instance local` and fail closed for non-local targets instead of falling diff --git a/odoo_devkit/cli.py b/odoo_devkit/cli.py index 43fb252..fe322cb 100644 --- a/odoo_devkit/cli.py +++ b/odoo_devkit/cli.py @@ -372,23 +372,13 @@ def _handle_runtime_down(arguments: argparse.Namespace) -> None: def _handle_runtime_workflow(arguments: argparse.Namespace) -> None: manifest = _load_runtime_manifest(arguments) native_exit_code = _run_runtime_handler(lambda: run_native_runtime_workflow(manifest=manifest, workflow=arguments.workflow)) - if native_exit_code is not None: - raise SystemExit(native_exit_code) - exit_code = run_runtime_platform_command( - manifest=manifest, - platform_subcommand="run", - platform_arguments=("--workflow", arguments.workflow), - ) - raise SystemExit(exit_code) + raise SystemExit(native_exit_code) def _handle_runtime_restore(arguments: argparse.Namespace) -> None: manifest = _load_runtime_manifest(arguments) native_exit_code = _run_runtime_handler(lambda: run_native_runtime_restore(manifest=manifest)) - if native_exit_code is not None: - raise SystemExit(native_exit_code) - exit_code = run_runtime_platform_command(manifest=manifest, platform_subcommand="restore") - raise SystemExit(exit_code) + raise SystemExit(native_exit_code) def _handle_runtime_inspect(arguments: argparse.Namespace) -> None: diff --git a/odoo_devkit/dokploy_api.py b/odoo_devkit/dokploy_api.py deleted file mode 100644 index 59f0714..0000000 --- a/odoo_devkit/dokploy_api.py +++ /dev/null @@ -1,406 +0,0 @@ -from __future__ import annotations - -import logging -import time -from collections.abc import Callable - -import requests - -from .local_runtime import RuntimeCommandError - -JsonPrimitive = str | int | float | bool | None -JsonValue = JsonPrimitive | dict[str, "JsonValue"] | list["JsonValue"] -JsonObject = dict[str, JsonValue] - -DEFAULT_DOKPLOY_DEPLOY_TIMEOUT_SECONDS = 600 -DOKPLOY_CANCELLED_DEPLOYMENT_STATUSES = {"cancelled", "canceled"} -DOKPLOY_SUCCESS_DEPLOYMENT_STATUSES = {"done", "success", "succeeded", "completed", "finished", "healthy"} -DOKPLOY_RUNNING_DEPLOYMENT_STATUSES = {"pending", "queued", "running", "in_progress", "starting"} - -_logger = logging.getLogger(__name__) - - -def dokploy_request( - *, - host: str, - token: str, - path: str, - method: str = "GET", - payload: JsonObject | None = None, - query: dict[str, str | int | float] | None = None, - timeout_seconds: int | float = 60, -) -> JsonValue: - normalized_host = host.rstrip("/") - normalized_path = path if path.startswith("/") else f"/{path}" - url = f"{normalized_host}{normalized_path}" - headers = {"x-api-key": token} - try: - response = requests.request( - method, - url, - headers=headers, - json=payload, - params=query, - timeout=timeout_seconds, - ) - except requests.RequestException as error: - raise RuntimeCommandError(f"Dokploy API {method} {normalized_path} request failed: {error}") from error - if response.status_code >= 400: - body = response.text.strip() - raise RuntimeCommandError(f"Dokploy API {method} {normalized_path} failed ({response.status_code}): {body}") - if not response.content: - return {} - try: - parsed_payload = response.json() - except ValueError: - return {"raw": response.text} - if isinstance(parsed_payload, (dict, list, str, int, float, bool)) or parsed_payload is None: - return parsed_payload - raise RuntimeCommandError(f"Dokploy API {method} {normalized_path} returned an unsupported payload type.") - - -def as_json_object(value: JsonValue) -> JsonObject | None: - if not isinstance(value, dict): - return None - if not all(isinstance(key, str) for key in value): - return None - return value - - -def extract_deployments(raw_payload: JsonValue) -> list[JsonObject]: - return _extract_json_object_list(raw_payload, candidate_keys=("data", "deployments", "items", "result")) - - -def extract_schedules(raw_payload: JsonValue) -> list[JsonObject]: - return _extract_json_object_list(raw_payload, candidate_keys=("data", "schedules", "items", "result")) - - -def _extract_json_object_list(raw_payload: JsonValue, *, candidate_keys: tuple[str, ...]) -> list[JsonObject]: - raw_items: list[JsonValue] = [] - if isinstance(raw_payload, list): - raw_items = raw_payload - elif isinstance(raw_payload, dict): - for candidate_key in candidate_keys: - nested_items = raw_payload.get(candidate_key) - if isinstance(nested_items, list): - raw_items = nested_items - break - - extracted_items: list[JsonObject] = [] - for raw_item in raw_items: - item_as_object = as_json_object(raw_item) - if item_as_object is not None: - extracted_items.append(item_as_object) - return extracted_items - - -def schedule_key(schedule: JsonObject) -> str: - for key_name in ("scheduleId", "schedule_id", "id", "uuid"): - value = schedule.get(key_name) - if value: - return str(value) - return "" - - -def deployment_key(deployment: JsonObject) -> str: - for key_name in ("deploymentId", "deployment_id", "id", "uuid"): - value = deployment.get(key_name) - if value: - return str(value) - return "" - - -def deployment_status(deployment: JsonObject) -> str: - for key_name in ("status", "state", "deploymentStatus"): - value = deployment.get(key_name) - if value: - return str(value).strip().lower() - return "" - - -def latest_deployment_for_compose(host: str, token: str, compose_id: str) -> JsonObject | None: - compose_payload = dokploy_request( - host=host, - token=token, - path="/api/compose.one", - query={"composeId": compose_id}, - ) - compose_payload_as_object = as_json_object(compose_payload) - if compose_payload_as_object is None: - return None - deployments_payload = compose_payload_as_object.get("deployments") - deployments = extract_deployments(deployments_payload if isinstance(deployments_payload, list) else []) - return _latest_deployment_from_list(deployments) - - -def latest_deployment_for_schedule(host: str, token: str, schedule_id: str) -> JsonObject | None: - payload = dokploy_request( - host=host, - token=token, - path="/api/deployment.allByType", - query={"id": schedule_id, "type": "schedule"}, - ) - deployments = extract_deployments(payload) - return _latest_deployment_from_list(deployments) - - -def _latest_deployment_from_list(deployments: list[JsonObject]) -> JsonObject | None: - if not deployments: - return None - return max(deployments, key=_deployment_sort_key) - - -def _deployment_sort_key(deployment: JsonObject) -> str: - for key_name in ("createdAt", "created_at", "updatedAt", "updated_at"): - value = deployment.get(key_name) - if value: - return str(value) - return deployment_key(deployment) - - -def wait_for_dokploy_schedule_deployment( - *, - host: str, - token: str, - schedule_id: str, - before_key: str, - timeout_seconds: int, -) -> str: - return _wait_for_deployment_status( - fetch_latest_deployment=lambda: latest_deployment_for_schedule(host, token, schedule_id), - before_key=before_key, - timeout_seconds=timeout_seconds, - failure_message_prefix="Dokploy schedule deployment failed", - ) - - -def wait_for_dokploy_compose_deployment( - *, - host: str, - token: str, - compose_id: str, - before_key: str, - timeout_seconds: int, -) -> str: - return _wait_for_deployment_status( - fetch_latest_deployment=lambda: latest_deployment_for_compose(host, token, compose_id), - before_key=before_key, - timeout_seconds=timeout_seconds, - failure_message_prefix="Dokploy compose deployment failed", - ) - - -def _wait_for_deployment_status( - *, - fetch_latest_deployment: Callable[[], JsonObject | None], - before_key: str, - timeout_seconds: int, - failure_message_prefix: str, -) -> str: - failure_statuses = {"failed", "error", "canceled", "cancelled", "killed", "unhealthy", "timeout"} - start_time = time.monotonic() - while time.monotonic() - start_time <= timeout_seconds: - latest_deployment = fetch_latest_deployment() - if not latest_deployment: - time.sleep(3) - continue - latest_key = deployment_key(latest_deployment) - latest_status = deployment_status(latest_deployment) - if latest_key and latest_key != before_key: - if latest_status in DOKPLOY_SUCCESS_DEPLOYMENT_STATUSES: - return f"deployment={latest_key} status={latest_status}" - if latest_status in failure_statuses: - raise RuntimeCommandError(f"{failure_message_prefix}: deployment={latest_key} status={latest_status}") - if not latest_status: - return f"deployment={latest_key} status=unknown" - time.sleep(3) - raise RuntimeCommandError("Timed out waiting for Dokploy deployment status.") - - -def resolve_dokploy_user_id(*, host: str, token: str) -> str: - payload = dokploy_request(host=host, token=token, path="/api/user.session") - payload_as_object = as_json_object(payload) - if payload_as_object is None: - raise RuntimeCommandError("Dokploy user.session returned an invalid response payload.") - user_payload = as_json_object(payload_as_object.get("user")) - if user_payload is None: - raise RuntimeCommandError("Dokploy user.session returned no user payload.") - user_id = str(user_payload.get("id") or "").strip() - if not user_id: - raise RuntimeCommandError("Dokploy user.session returned no user id.") - return user_id - - -def list_dokploy_schedules(*, host: str, token: str, target_id: str, schedule_type: str) -> tuple[JsonObject, ...]: - payload = dokploy_request( - host=host, - token=token, - path="/api/schedule.list", - query={"id": target_id, "scheduleType": schedule_type}, - ) - return tuple(extract_schedules(payload)) - - -def find_matching_dokploy_schedule( - *, - host: str, - token: str, - target_id: str, - schedule_type: str, - schedule_name: str, - app_name: str, -) -> JsonObject | None: - for schedule in list_dokploy_schedules( - host=host, - token=token, - target_id=target_id, - schedule_type=schedule_type, - ): - if str(schedule.get("name") or "").strip() != schedule_name: - continue - if str(schedule.get("appName") or "").strip() != app_name: - continue - return schedule - return None - - -def upsert_dokploy_schedule( - *, - host: str, - token: str, - target_id: str, - schedule_type: str, - schedule_name: str, - app_name: str, - schedule_payload: JsonObject, -) -> JsonObject: - existing_schedule = find_matching_dokploy_schedule( - host=host, - token=token, - target_id=target_id, - schedule_type=schedule_type, - schedule_name=schedule_name, - app_name=app_name, - ) - if existing_schedule is not None: - updated_payload = dict(schedule_payload) - updated_payload["scheduleId"] = schedule_key(existing_schedule) - dokploy_request( - host=host, - token=token, - path="/api/schedule.update", - method="POST", - payload=updated_payload, - ) - else: - dokploy_request( - host=host, - token=token, - path="/api/schedule.create", - method="POST", - payload=schedule_payload, - ) - - resolved_schedule = find_matching_dokploy_schedule( - host=host, - token=token, - target_id=target_id, - schedule_type=schedule_type, - schedule_name=schedule_name, - app_name=app_name, - ) - if resolved_schedule is None: - raise RuntimeCommandError( - f"Dokploy schedule {schedule_name!r} for {schedule_type} target {target_id!r} could not be resolved after upsert." - ) - return resolved_schedule - - -def parse_dokploy_env_text(raw_env_text: str) -> dict[str, str]: - env_map: dict[str, str] = {} - for raw_line in raw_env_text.splitlines(): - stripped_line = raw_line.strip() - if not stripped_line or stripped_line.startswith("#"): - continue - if stripped_line.startswith("export "): - stripped_line = stripped_line[7:].strip() - if "=" not in stripped_line: - continue - key_part, value_part = stripped_line.split("=", 1) - env_map[key_part.strip()] = value_part - return env_map - - -def serialize_dokploy_env_text(env_map: dict[str, str]) -> str: - if not env_map: - return "" - rendered_lines = [f"{environment_key}={environment_value}" for environment_key, environment_value in env_map.items()] - return "\n".join(rendered_lines) - - -def fetch_dokploy_target_payload(*, host: str, token: str, target_type: str, target_id: str) -> JsonObject: - if target_type == "compose": - payload = dokploy_request( - host=host, - token=token, - path="/api/compose.one", - query={"composeId": target_id}, - ) - elif target_type == "application": - payload = dokploy_request( - host=host, - token=token, - path="/api/application.one", - query={"applicationId": target_id}, - ) - else: - raise RuntimeCommandError(f"Unsupported target type: {target_type}") - - payload_as_object = as_json_object(payload) - if payload_as_object is None: - raise RuntimeCommandError(f"Dokploy {target_type}.one returned an invalid response payload.") - return payload_as_object - - -def update_dokploy_target_env( - *, - host: str, - token: str, - target_type: str, - target_id: str, - target_payload: JsonObject, - env_text: str, -) -> None: - if target_type == "compose": - dokploy_request( - host=host, - token=token, - path="/api/compose.update", - method="POST", - payload={"composeId": target_id, "env": env_text}, - ) - return - - if target_type == "application": - build_args = target_payload.get("buildArgs") - build_secrets = target_payload.get("buildSecrets") - create_env_file = target_payload.get("createEnvFile") - payload: JsonObject = { - "applicationId": target_id, - "env": env_text, - "createEnvFile": bool(create_env_file) if isinstance(create_env_file, bool) else True, - } - if isinstance(build_args, str): - payload["buildArgs"] = build_args - if isinstance(build_secrets, str): - payload["buildSecrets"] = build_secrets - dokploy_request( - host=host, - token=token, - path="/api/application.saveEnvironment", - method="POST", - payload=payload, - ) - return - - raise RuntimeCommandError(f"Unsupported target type: {target_type}") diff --git a/odoo_devkit/dokploy_config.py b/odoo_devkit/dokploy_config.py deleted file mode 100644 index a0e8cbe..0000000 --- a/odoo_devkit/dokploy_config.py +++ /dev/null @@ -1,430 +0,0 @@ -from __future__ import annotations - -import os -import tomllib -from collections.abc import Mapping -from dataclasses import dataclass, field -from pathlib import Path - -from .local_runtime import RuntimeCommandError, resolve_control_plane_root - -CONTROL_PLANE_DOKPLOY_TARGET_IDS_FILE_ENV_VAR = "ODOO_CONTROL_PLANE_DOKPLOY_TARGET_IDS_FILE" - - -@dataclass(frozen=True) -class DokployTargetDefinition: - context: str - instance: str - project_name: str = "" - target_type: str = "compose" - target_id: str = "" - target_name: str = "" - git_branch: str = "" - source_git_ref: str = "origin/main" - require_test_gate: bool = False - require_prod_gate: bool = False - deploy_timeout_seconds: int | None = None - healthcheck_enabled: bool = True - healthcheck_path: str = "/web/health" - healthcheck_timeout_seconds: int | None = None - env: dict[str, str] = field(default_factory=dict) - domains: tuple[str, ...] = () - - -@dataclass(frozen=True) -class DokploySourceOfTruth: - schema_version: int - targets: tuple[DokployTargetDefinition, ...] = () - - -def load_dokploy_source_of_truth(repo_root: Path) -> DokploySourceOfTruth | None: - source_file_path = _resolve_dokploy_source_file_path(repo_root) - if source_file_path is None: - return None - try: - raw_payload = tomllib.loads(source_file_path.read_text(encoding="utf-8")) - except (OSError, tomllib.TOMLDecodeError) as error: - raise RuntimeCommandError(f"Invalid dokploy source-of-truth file {source_file_path}: {error}") from error - control_plane_root = resolve_control_plane_root() - target_ids_file_path: Path | None = None - if control_plane_root is not None: - target_ids_file_path = _resolve_dokploy_target_ids_file_path(control_plane_root, source_file_path=source_file_path) - configured_target_ids_file = os.environ.get(CONTROL_PLANE_DOKPLOY_TARGET_IDS_FILE_ENV_VAR, "").strip() - should_load_target_ids = bool(configured_target_ids_file) or target_ids_file_path.exists() - if should_load_target_ids: - raw_payload = _apply_dokploy_target_id_catalog( - raw_payload, - target_id_catalog=_load_dokploy_target_id_catalog(target_ids_file_path), - ) - normalized_payload = _normalize_dokploy_source_payload(raw_payload) - schema_version = _read_required_int(normalized_payload, "schema_version", scope="dokploy") - targets_payload = normalized_payload.get("targets") - if not isinstance(targets_payload, list): - raise RuntimeCommandError("Dokploy route catalog targets must be an array of target tables.") - targets = tuple( - _parse_dokploy_target_definition(raw_target, label=f"dokploy.targets[{target_index}]") - for target_index, raw_target in enumerate(targets_payload, start=1) - ) - if control_plane_root is not None: - missing_target_id_routes = [f"{target.context}/{target.instance}" for target in targets if not target.target_id.strip()] - if missing_target_id_routes: - missing_joined = ", ".join(missing_target_id_routes) - target_ids_display = str(target_ids_file_path) if target_ids_file_path is not None else "config/dokploy-targets.toml" - raise RuntimeCommandError( - "Control-plane Dokploy route catalog resolved through ODOO_CONTROL_PLANE_ROOT is missing pinned target ids for " - f"{missing_joined}. Define them in {target_ids_display} or inline target_id values in {source_file_path}." - ) - return DokploySourceOfTruth(schema_version=schema_version, targets=targets) - - -def _resolve_dokploy_source_file_path(repo_root: Path) -> Path | None: - control_plane_root = resolve_control_plane_root() - if control_plane_root is None: - return None - control_plane_source_file = control_plane_root / "config" / "dokploy.toml" - if control_plane_source_file.exists(): - return control_plane_source_file - return None - - -def _resolve_dokploy_target_ids_file_path(control_plane_root: Path, *, source_file_path: Path) -> Path: - configured_target_ids_file = os.environ.get(CONTROL_PLANE_DOKPLOY_TARGET_IDS_FILE_ENV_VAR, "").strip() - if configured_target_ids_file: - candidate_path = Path(configured_target_ids_file) - if not candidate_path.is_absolute(): - candidate_path = control_plane_root / candidate_path - return candidate_path - return source_file_path.parent / "dokploy-targets.toml" - - -def _load_dokploy_target_id_catalog(target_ids_file_path: Path) -> Mapping[str, object]: - try: - raw_payload = tomllib.loads(target_ids_file_path.read_text(encoding="utf-8")) - except FileNotFoundError as error: - raise RuntimeCommandError(f"Dokploy target-id catalog file not found: {target_ids_file_path}") from error - except (OSError, tomllib.TOMLDecodeError) as error: - raise RuntimeCommandError(f"Invalid Dokploy target-id catalog file {target_ids_file_path}: {error}") from error - if not isinstance(raw_payload, Mapping): - raise RuntimeCommandError(f"Dokploy target-id catalog {target_ids_file_path} must contain a top-level table.") - return raw_payload - - -def _apply_dokploy_target_id_catalog( - raw_payload: Mapping[str, object], - *, - target_id_catalog: Mapping[str, object], -) -> dict[str, object]: - merged_payload = dict(raw_payload) - raw_targets = merged_payload.get("targets") - if not isinstance(raw_targets, list): - raise RuntimeCommandError("Dokploy route catalog targets must be an array of target tables.") - catalog_targets = target_id_catalog.get("targets") - if not isinstance(catalog_targets, list): - raise RuntimeCommandError("Dokploy target-id catalog targets must be an array of target tables.") - - override_map: dict[tuple[str, str], str] = {} - for index, raw_target in enumerate(catalog_targets, start=1): - target_table = _ensure_mapping(raw_target, label=f"dokploy-target-ids.targets[{index}]") - context_name = _read_required_string(target_table, "context", scope=f"dokploy-target-ids.targets[{index}]") - instance_name = _read_required_string(target_table, "instance", scope=f"dokploy-target-ids.targets[{index}]") - target_id = _read_required_string(target_table, "target_id", scope=f"dokploy-target-ids.targets[{index}]") - target_route = (context_name, instance_name) - if target_route in override_map: - raise RuntimeCommandError( - f"Duplicate Dokploy target-id override for {context_name}/{instance_name} in target-id catalog" - ) - override_map[target_route] = target_id - - remaining_routes = set(override_map) - merged_targets: list[object] = [] - for index, raw_target in enumerate(raw_targets, start=1): - target_table = _ensure_mapping(raw_target, label=f"dokploy.targets[{index}]") - merged_target = dict(target_table) - context_name = str(merged_target.get("context") or "").strip() - instance_name = str(merged_target.get("instance") or "").strip() - target_route = (context_name, instance_name) - override_target_id = override_map.get(target_route) - if override_target_id is not None: - merged_target["target_id"] = override_target_id - remaining_routes.discard(target_route) - merged_targets.append(merged_target) - - if remaining_routes: - unknown_routes = ", ".join(f"{context_name}/{instance_name}" for context_name, instance_name in sorted(remaining_routes)) - raise RuntimeCommandError( - f"Dokploy target-id catalog contains route(s) that are not present in the control-plane route catalog: {unknown_routes}" - ) - - merged_payload["targets"] = merged_targets - return merged_payload - - -def find_dokploy_target_definition( - source_of_truth: DokploySourceOfTruth, - *, - context_name: str, - instance_name: str, -) -> DokployTargetDefinition | None: - for target in source_of_truth.targets: - if target.context == context_name and target.instance == instance_name: - return target - return None - - -def _parse_dokploy_target_definition(raw_target: object, *, label: str) -> DokployTargetDefinition: - target_table = _ensure_mapping(raw_target, label=label) - target_type = _read_optional_string(target_table, "target_type", scope=label) or "compose" - if target_type not in {"compose", "application"}: - raise RuntimeCommandError(f"{label}.target_type must be 'compose' or 'application'.") - deploy_timeout_seconds = _read_optional_int(target_table, "deploy_timeout_seconds", scope=label) - healthcheck_timeout_seconds = _read_optional_int(target_table, "healthcheck_timeout_seconds", scope=label) - return DokployTargetDefinition( - context=_read_required_string(target_table, "context", scope=label), - instance=_read_required_string(target_table, "instance", scope=label), - project_name=_read_optional_string(target_table, "project_name", scope=label) or "", - target_type=target_type, - target_id=_read_optional_string(target_table, "target_id", scope=label) or "", - target_name=_read_optional_string(target_table, "target_name", scope=label) or "", - git_branch=_read_optional_string(target_table, "git_branch", scope=label) or "", - source_git_ref=_read_optional_string(target_table, "source_git_ref", scope=label) or "origin/main", - require_test_gate=_read_optional_bool(target_table, "require_test_gate", scope=label, default=False), - require_prod_gate=_read_optional_bool(target_table, "require_prod_gate", scope=label, default=False), - deploy_timeout_seconds=deploy_timeout_seconds, - healthcheck_enabled=_read_optional_bool(target_table, "healthcheck_enabled", scope=label, default=True), - healthcheck_path=_read_optional_string(target_table, "healthcheck_path", scope=label) or "/web/health", - healthcheck_timeout_seconds=healthcheck_timeout_seconds, - env=_read_optional_string_map(target_table, "env", scope=label), - domains=_read_optional_string_tuple(target_table, "domains", scope=label), - ) - - -def _normalize_dokploy_source_payload(raw_value: object) -> Mapping[str, object]: - if not isinstance(raw_value, Mapping): - raise RuntimeCommandError("Dokploy route catalog must contain a top-level table.") - - normalized_payload = dict(raw_value) - allowed_top_level_keys = {"defaults", "profiles", "projects", "schema_version", "targets"} - unknown_keys = sorted(key for key in normalized_payload if key not in allowed_top_level_keys) - if unknown_keys: - unknown_key_list = ", ".join(unknown_keys) - raise RuntimeCommandError(f"Unknown top-level dokploy keys: {unknown_key_list}") - - raw_targets = normalized_payload.get("targets") - if not isinstance(raw_targets, list): - raise RuntimeCommandError("Dokploy route catalog targets must be an array of target tables.") - - defaults = _expect_mapping(normalized_payload.get("defaults"), label="defaults") - raw_profiles = _expect_mapping(normalized_payload.get("profiles"), label="profiles") - raw_projects = _expect_mapping(normalized_payload.get("projects"), label="projects") - resolved_profiles: dict[str, dict[str, object]] = {} - targets: list[object] = [] - for target_index, raw_target in enumerate(raw_targets, start=1): - if not isinstance(raw_target, Mapping): - raise RuntimeCommandError(f"dokploy.targets[{target_index}] must be a table.") - target_payload = dict(raw_target) - profile_name = str(target_payload.pop("profile", "") or "").strip() - merged_target = dict(defaults) - if profile_name: - merged_target = _merge_dokploy_settings( - merged_target, - _resolve_dokploy_profile( - profile_name, - raw_profiles=raw_profiles, - raw_projects=raw_projects, - resolved_profiles=resolved_profiles, - active_profiles=(), - ), - ) - merged_target = _merge_dokploy_settings(merged_target, target_payload) - targets.append( - _resolve_dokploy_project_reference( - merged_target, - raw_projects=raw_projects, - label=f"targets[{target_index}]", - ) - ) - - return { - "schema_version": normalized_payload.get("schema_version"), - "targets": targets, - } - - -def _resolve_dokploy_profile( - profile_name: str, - *, - raw_profiles: Mapping[str, object], - raw_projects: Mapping[str, object], - resolved_profiles: dict[str, dict[str, object]], - active_profiles: tuple[str, ...], -) -> dict[str, object]: - if profile_name in resolved_profiles: - return dict(resolved_profiles[profile_name]) - if profile_name in active_profiles: - profile_chain = " -> ".join((*active_profiles, profile_name)) - raise RuntimeCommandError(f"Dokploy profile inheritance cycle detected: {profile_chain}") - - raw_profile = raw_profiles.get(profile_name) - if raw_profile is None: - raise RuntimeCommandError(f"Unknown dokploy profile: {profile_name}") - if not isinstance(raw_profile, Mapping): - raise RuntimeCommandError(f"Dokploy profile '{profile_name}' must be a table/object") - - profile_payload = dict(raw_profile) - parent_profile_name = str(profile_payload.pop("extends", "") or "").strip() - merged_profile: dict[str, object] = {} - if parent_profile_name: - merged_profile = _resolve_dokploy_profile( - parent_profile_name, - raw_profiles=raw_profiles, - raw_projects=raw_projects, - resolved_profiles=resolved_profiles, - active_profiles=(*active_profiles, profile_name), - ) - merged_profile = _merge_dokploy_settings(merged_profile, profile_payload) - merged_profile = _resolve_dokploy_project_reference( - merged_profile, - raw_projects=raw_projects, - label=f"profiles.{profile_name}", - ) - resolved_profiles[profile_name] = dict(merged_profile) - return merged_profile - - -def _resolve_dokploy_project_reference( - payload: dict[str, object], - *, - raw_projects: Mapping[str, object], - label: str, -) -> dict[str, object]: - resolved_payload = dict(payload) - raw_project_alias = resolved_payload.pop("project", None) - if raw_project_alias in (None, ""): - return resolved_payload - - project_alias = str(raw_project_alias).strip() - if not project_alias: - return resolved_payload - if str(resolved_payload.get("project_name") or "").strip(): - raise RuntimeCommandError(f"{label} cannot define both project and project_name") - - raw_project_value = raw_projects.get(project_alias) - if raw_project_value is None: - raise RuntimeCommandError(f"Unknown dokploy project alias '{project_alias}' in {label}") - if isinstance(raw_project_value, str): - project_name = raw_project_value.strip() - elif isinstance(raw_project_value, Mapping): - project_name = str(raw_project_value.get("project_name") or "").strip() - else: - raise RuntimeCommandError(f"Dokploy project alias '{project_alias}' in {label} must be a string or table") - if not project_name: - raise RuntimeCommandError(f"Dokploy project alias '{project_alias}' in {label} is missing project_name") - resolved_payload["project_name"] = project_name - return resolved_payload - - -def _expect_mapping(raw_value: object, *, label: str) -> dict[str, object]: - if raw_value in (None, ""): - return {} - if not isinstance(raw_value, Mapping): - raise RuntimeCommandError(f"Dokploy {label} must be a table/object") - if not all(isinstance(key, str) for key in raw_value): - raise RuntimeCommandError(f"Dokploy {label} keys must be strings") - return dict(raw_value) - - -def _merge_dokploy_settings(base: Mapping[str, object], overlay: Mapping[str, object]) -> dict[str, object]: - merged_settings = dict(base) - for key_name, key_value in overlay.items(): - base_env = merged_settings.get("env") - if key_name == "env" and isinstance(base_env, Mapping) and isinstance(key_value, Mapping): - merged_environment: dict[str, object] = {} - for environment_key, environment_value in base_env.items(): - if isinstance(environment_key, str): - merged_environment[environment_key] = environment_value - for environment_key, environment_value in key_value.items(): - if isinstance(environment_key, str): - merged_environment[environment_key] = environment_value - merged_settings["env"] = merged_environment - continue - merged_settings[key_name] = key_value - return merged_settings - - -def _ensure_mapping(raw_value: object, *, label: str) -> Mapping[str, object]: - if not isinstance(raw_value, Mapping): - raise RuntimeCommandError(f"{label} must be a table/object.") - if not all(isinstance(key, str) for key in raw_value): - raise RuntimeCommandError(f"{label} keys must be strings.") - return raw_value - - -def _read_required_string(raw_value: Mapping[str, object], key_name: str, *, scope: str) -> str: - value = raw_value.get(key_name) - if not isinstance(value, str) or not value.strip(): - raise RuntimeCommandError(f"Missing required string {scope}.{key_name}") - return value.strip() - - -def _read_optional_string(raw_value: Mapping[str, object], key_name: str, *, scope: str) -> str | None: - value = raw_value.get(key_name) - if value in (None, ""): - return None - if not isinstance(value, str): - raise RuntimeCommandError(f"{scope}.{key_name} must be a string") - return value.strip() - - -def _read_required_int(raw_value: Mapping[str, object], key_name: str, *, scope: str) -> int: - value = raw_value.get(key_name) - if not isinstance(value, int): - raise RuntimeCommandError(f"Missing required integer {scope}.{key_name}") - return value - - -def _read_optional_int(raw_value: Mapping[str, object], key_name: str, *, scope: str) -> int | None: - value = raw_value.get(key_name) - if value is None: - return None - if not isinstance(value, int): - raise RuntimeCommandError(f"{scope}.{key_name} must be an integer") - return value - - -def _read_optional_bool(raw_value: Mapping[str, object], key_name: str, *, scope: str, default: bool) -> bool: - value = raw_value.get(key_name) - if value is None: - return default - if not isinstance(value, bool): - raise RuntimeCommandError(f"{scope}.{key_name} must be a boolean") - return value - - -def _read_optional_string_tuple(raw_value: Mapping[str, object], key_name: str, *, scope: str) -> tuple[str, ...]: - value = raw_value.get(key_name) - if value is None: - return () - if not isinstance(value, list): - raise RuntimeCommandError(f"{scope}.{key_name} must be an array of strings") - rendered_values: list[str] = [] - for item_index, raw_item in enumerate(value, start=1): - if not isinstance(raw_item, str): - raise RuntimeCommandError(f"{scope}.{key_name}[{item_index}] must be a string") - rendered_values.append(raw_item.strip()) - return tuple(rendered_values) - - -def _read_optional_string_map(raw_value: Mapping[str, object], key_name: str, *, scope: str) -> dict[str, str]: - value = raw_value.get(key_name) - if value is None: - return {} - if not isinstance(value, Mapping): - raise RuntimeCommandError(f"{scope}.{key_name} must be a table/object") - rendered_values: dict[str, str] = {} - for raw_key, raw_item in value.items(): - if not isinstance(raw_key, str): - raise RuntimeCommandError(f"{scope}.{key_name} keys must be strings") - if not isinstance(raw_item, (str, int, float, bool)): - raise RuntimeCommandError(f"{scope}.{key_name}.{raw_key} must be scalar") - rendered_values[raw_key] = str(raw_item) - return rendered_values diff --git a/odoo_devkit/local_runtime.py b/odoo_devkit/local_runtime.py index 33dd2cb..9b5adf9 100644 --- a/odoo_devkit/local_runtime.py +++ b/odoo_devkit/local_runtime.py @@ -1028,10 +1028,9 @@ def assert_local_instance(*, instance_name: str, operation_name: str) -> None: return raise RuntimeCommandError( f"{operation_name} manages local host runtime only and requires --instance local. " - "Use control-plane ship/promote workflows for release actions, or the " - "explicit Dokploy-managed data workflows (`platform runtime restore` " - "or `platform runtime workflow --workflow bootstrap|update`) when you " - "need a remote data operation." + "Use Launchplane service routes, operator UI, or reusable Launchplane " + "workflows for release actions and non-local restore/bootstrap/update " + "operations." ) diff --git a/odoo_devkit/remote_runtime.py b/odoo_devkit/remote_runtime.py deleted file mode 100644 index 6bb0700..0000000 --- a/odoo_devkit/remote_runtime.py +++ /dev/null @@ -1,568 +0,0 @@ -from __future__ import annotations - -import logging -import shlex -from pathlib import Path - -from .dokploy_api import ( - DEFAULT_DOKPLOY_DEPLOY_TIMEOUT_SECONDS, - DOKPLOY_CANCELLED_DEPLOYMENT_STATUSES, - DOKPLOY_RUNNING_DEPLOYMENT_STATUSES, - DOKPLOY_SUCCESS_DEPLOYMENT_STATUSES, - JsonObject, - as_json_object, - deployment_key, - deployment_status, - dokploy_request, - fetch_dokploy_target_payload, - find_matching_dokploy_schedule, - latest_deployment_for_compose, - latest_deployment_for_schedule, - parse_dokploy_env_text, - resolve_dokploy_user_id, - schedule_key, - serialize_dokploy_env_text, - update_dokploy_target_env, - upsert_dokploy_schedule, - wait_for_dokploy_compose_deployment, - wait_for_dokploy_schedule_deployment, -) -from .dokploy_config import ( - DokployTargetDefinition, - find_dokploy_target_definition, - load_dokploy_source_of_truth, -) -from .local_runtime import ( - RuntimeCommandError, - RuntimeContext, - load_runtime_context, - missing_upstream_source_keys, - parse_env_file, - resolve_data_workflow_environment, - runtime_environment_configuration_guidance, - write_runtime_env_file, - write_runtime_odoo_conf_file, -) -from .manifest import WorkspaceManifest - -DATA_WORKFLOW_SCRIPT = "/volumes/scripts/run_odoo_data_workflows.py" -DOKPLOY_DATA_WORKFLOW_SCHEDULE_NAME = "platform-data-workflow" -DOKPLOY_MANUAL_ONLY_CRON_EXPRESSION = "0 0 31 2 *" - -_logger = logging.getLogger(__name__) - - -def run_remote_restore_workflow(*, manifest: WorkspaceManifest, runtime_repo_path: Path, no_sanitize: bool = False) -> None: - run_remote_data_workflow( - manifest=manifest, - runtime_repo_path=runtime_repo_path, - bootstrap=False, - no_sanitize=no_sanitize, - update_only=False, - ) - - -def run_remote_bootstrap_workflow(*, manifest: WorkspaceManifest, runtime_repo_path: Path, no_sanitize: bool = False) -> None: - run_remote_data_workflow( - manifest=manifest, - runtime_repo_path=runtime_repo_path, - bootstrap=True, - no_sanitize=no_sanitize, - update_only=False, - ) - - -def run_remote_update_workflow(*, manifest: WorkspaceManifest, runtime_repo_path: Path, no_sanitize: bool = False) -> None: - run_remote_data_workflow( - manifest=manifest, - runtime_repo_path=runtime_repo_path, - bootstrap=False, - no_sanitize=no_sanitize, - update_only=True, - ) - - -def run_remote_data_workflow( - *, - manifest: WorkspaceManifest, - runtime_repo_path: Path, - bootstrap: bool, - no_sanitize: bool, - update_only: bool, -) -> None: - runtime_context = load_runtime_context( - manifest=manifest, - runtime_repo_path=runtime_repo_path, - require_local_instance=False, - ) - write_runtime_odoo_conf_file( - runtime_selection=runtime_context.selection, - stack_definition=runtime_context.stack.stack_definition, - source_environment=runtime_context.environment.merged_values, - ) - runtime_env_file = write_runtime_env_file(runtime_context=runtime_context) - resolved_environment = resolve_data_workflow_environment(parse_env_file(runtime_env_file)) - if not bootstrap and not update_only: - missing_environment_keys = missing_upstream_source_keys(resolved_environment) - if missing_environment_keys: - missing_joined = ", ".join(missing_environment_keys) - raise RuntimeCommandError( - "Restore requires upstream settings; missing: " - f"{missing_joined}. {runtime_environment_configuration_guidance()} " - "or run bootstrap intentionally." - ) - - _run_dokploy_managed_remote_data_workflow( - runtime_context=runtime_context, - env_values=resolved_environment, - bootstrap=bootstrap, - no_sanitize=no_sanitize, - update_only=update_only, - ) - - -def _resolve_required_dokploy_compose_target_definition( - runtime_context: RuntimeContext, -) -> DokployTargetDefinition: - source_of_truth = load_dokploy_source_of_truth(runtime_context.repo_root) - if source_of_truth is None: - raise RuntimeCommandError( - "Dokploy-managed remote workflows require ODOO_CONTROL_PLANE_ROOT to point at an " - "harbor checkout with config/dokploy.toml." - ) - - target_definition = find_dokploy_target_definition( - source_of_truth, - context_name=runtime_context.selection.context_name, - instance_name=runtime_context.selection.instance_name, - ) - if target_definition is None: - raise RuntimeCommandError( - "Dokploy-managed remote workflow requires a target definition in the control-plane Dokploy route catalog for " - f"{runtime_context.selection.context_name}/{runtime_context.selection.instance_name}." - ) - if target_definition.target_type != "compose": - raise RuntimeCommandError( - "Dokploy-managed remote data workflows require compose targets, but " - f"the control-plane route catalog configures {runtime_context.selection.context_name}/" - f"{runtime_context.selection.instance_name} as '{target_definition.target_type}'." - ) - compose_id = target_definition.target_id.strip() - if not compose_id: - raise RuntimeCommandError( - "Dokploy-managed remote workflow requires a pinned target_id in the control-plane Dokploy target-id catalog for " - f"{runtime_context.selection.context_name}/{runtime_context.selection.instance_name}." - ) - return target_definition - - -def _resolve_dokploy_schedule_runtime( - *, - dokploy_host: str, - dokploy_token: str, - compose_id: str, - compose_name: str, -) -> tuple[str, str, str, str | None]: - compose_payload = dokploy_request( - host=dokploy_host, - token=dokploy_token, - path="/api/compose.one", - query={"composeId": compose_id}, - ) - compose_payload_as_object = as_json_object(compose_payload) - if compose_payload_as_object is None: - raise RuntimeCommandError(f"Dokploy compose.one returned an invalid response for compose {compose_name!r}.") - - compose_app_name = str(compose_payload_as_object.get("appName") or "").strip() - if not compose_app_name: - raise RuntimeCommandError(f"Dokploy compose {compose_name!r} ({compose_id}) has no appName in API response.") - - compose_server_id = str(compose_payload_as_object.get("serverId") or "").strip() - if compose_server_id: - return "server", compose_server_id, compose_app_name, compose_server_id - - user_id = resolve_dokploy_user_id(host=dokploy_host, token=dokploy_token) - return "dokploy-server", user_id, compose_app_name, None - - -def _build_dokploy_data_workflow_script( - *, - compose_app_name: str, - database_name: str, - filestore_path: str = "/volumes/data/filestore", - bootstrap: bool, - no_sanitize: bool, - update_only: bool, - clear_stale_lock: bool, - data_workflow_lock_path: str, -) -> str: - normalized_filestore_path = filestore_path.strip() or "/volumes/data/filestore" - workflow_arguments: list[str] = [] - if bootstrap: - workflow_arguments.append("--bootstrap") - if no_sanitize: - workflow_arguments.append("--no-sanitize") - if update_only: - workflow_arguments.append("--update-only") - - quoted_workflow_arguments = " ".join(shlex.quote(argument) for argument in workflow_arguments) - workflow_argument_line = ( - f"workflow_arguments=({quoted_workflow_arguments})" if quoted_workflow_arguments else "workflow_arguments=()" - ) - clear_stale_lock_line = f"clear_stale_lock={'1' if clear_stale_lock else '0'}" - - return f"""#!/usr/bin/env bash -set -euo pipefail - -compose_project={shlex.quote(compose_app_name)} -database_name={shlex.quote(database_name)} -filestore_root={shlex.quote(normalized_filestore_path)} -workflow_ssh_dir=/tmp/platform-data-workflow-ssh -{workflow_argument_line} -{clear_stale_lock_line} -data_workflow_lock_path={shlex.quote(data_workflow_lock_path)} - -resolve_container_id() {{ - local service_name="$1" - local container_id - container_id=$(docker ps -aq \ - --filter "label=com.docker.compose.project=${{compose_project}}" \ - --filter "label=com.docker.compose.service=${{service_name}}" | head -n 1) - if [ -z "${{container_id}}" ]; then - echo "Missing container for service '${{service_name}}' in project '${{compose_project}}'." >&2 - exit 1 - fi - printf '%s' "${{container_id}}" -}} - -ensure_running() {{ - local container_id="$1" - local service_name="$2" - local current_status - current_status=$(docker inspect -f '{{{{.State.Status}}}}' "${{container_id}}") - if [ "${{current_status}}" != "running" ]; then - echo "Starting ${{service_name}} container ${{container_id}}" - docker start "${{container_id}}" >/dev/null - fi -}} - -start_web_container() {{ - local current_status - current_status=$(docker inspect -f '{{{{.State.Status}}}}' "${{web_container_id}}" 2>/dev/null || true) - if [ "${{current_status}}" != "running" ]; then - echo "Starting web container ${{web_container_id}}" - docker start "${{web_container_id}}" >/dev/null || true - fi -}} - -database_container_id=$(resolve_container_id "database") -script_runner_container_id=$(resolve_container_id "script-runner") -web_container_id=$(resolve_container_id "web") - -ensure_running "${{database_container_id}}" "database" -ensure_running "${{script_runner_container_id}}" "script-runner" -workflow_uid=$(docker exec "${{script_runner_container_id}}" id -u) -workflow_gid=$(docker exec "${{script_runner_container_id}}" id -g) - -if [ "${{clear_stale_lock}}" = "1" ]; then - echo "Clearing stale data workflow lock ${{data_workflow_lock_path}}" - docker exec -u root "${{script_runner_container_id}}" rm -f "${{data_workflow_lock_path}}" -fi - -trap start_web_container EXIT - -web_status=$(docker inspect -f '{{{{.State.Status}}}}' "${{web_container_id}}") -if [ "${{web_status}}" = "running" ]; then - echo "Stopping web container ${{web_container_id}}" - docker stop "${{web_container_id}}" >/dev/null -fi - -echo "Normalizing filestore ownership for ${{database_name}}" -workflow_identity_key=$(docker exec -u root \ - -e ODOO_DATABASE_NAME="${{database_name}}" \ - -e ODOO_FILESTORE_ROOT="${{filestore_root}}" \ - -e DATA_WORKFLOW_SSH_DIR="${{DATA_WORKFLOW_SSH_DIR:-/root/.ssh}}" \ - -e DATA_WORKFLOW_SSH_KEY="${{DATA_WORKFLOW_SSH_KEY:-}}" \ - -e WORKFLOW_UID="${{workflow_uid}}" \ - -e WORKFLOW_GID="${{workflow_gid}}" \ - -e WORKFLOW_SSH_DIR="${{workflow_ssh_dir}}" \ - "${{script_runner_container_id}}" \ - /bin/bash -lc ' - set -euo pipefail - target_owner=$(stat -c "%u:%g" /volumes/data) - filestore_database_path="$ODOO_FILESTORE_ROOT" - if [ "$(basename "$filestore_database_path")" != "$ODOO_DATABASE_NAME" ]; then - filestore_database_path="$filestore_database_path/$ODOO_DATABASE_NAME" - fi - mkdir -p "$ODOO_FILESTORE_ROOT" "$filestore_database_path" - chown -R "$target_owner" "$filestore_database_path" - chmod -R ug+rwX "$filestore_database_path" - - rm -rf "$WORKFLOW_SSH_DIR" - install -d -m 700 -o "$WORKFLOW_UID" -g "$WORKFLOW_GID" "$WORKFLOW_SSH_DIR" - - if [ -f "$DATA_WORKFLOW_SSH_DIR/known_hosts" ]; then - install -m 600 -o "$WORKFLOW_UID" -g "$WORKFLOW_GID" \ - "$DATA_WORKFLOW_SSH_DIR/known_hosts" "$WORKFLOW_SSH_DIR/known_hosts" - fi - - source_key_path="$DATA_WORKFLOW_SSH_KEY" - if [ -z "$source_key_path" ]; then - for candidate_key in id_ed25519 id_ecdsa id_rsa id_dsa; do - if [ -f "$DATA_WORKFLOW_SSH_DIR/$candidate_key" ]; then - source_key_path="$DATA_WORKFLOW_SSH_DIR/$candidate_key" - break - fi - done - fi - workflow_identity_key="" - if [ -n "$source_key_path" ] && [ -f "$source_key_path" ]; then - workflow_identity_key="$WORKFLOW_SSH_DIR/$(basename "$source_key_path")" - install -m 600 -o "$WORKFLOW_UID" -g "$WORKFLOW_GID" \ - "$source_key_path" "$workflow_identity_key" - fi - printf "%s" "$workflow_identity_key" - ') - -echo "Running platform data workflow in container ${{script_runner_container_id}}" -docker exec \ - -e DATA_WORKFLOW_SSH_DIR="${{workflow_ssh_dir}}" \ - -e DATA_WORKFLOW_SSH_KEY="$workflow_identity_key" \ - "${{script_runner_container_id}}" \ - python3 -u {shlex.quote(DATA_WORKFLOW_SCRIPT)} "${{workflow_arguments[@]}}" - -start_web_container -trap - EXIT -""" - - -def _schedule_deployments(schedule: JsonObject | None) -> tuple[JsonObject, ...]: - if not isinstance(schedule, dict): - return () - raw_deployments = schedule.get("deployments") - if not isinstance(raw_deployments, list): - return () - deployment_entries: list[JsonObject] = [] - for raw_deployment in raw_deployments: - if isinstance(raw_deployment, dict): - deployment_entries.append(raw_deployment) - return tuple(deployment_entries) - - -def _deployment_status_value(deployment: JsonObject) -> str: - return str(deployment.get("status") or "").strip().lower() - - -def _has_running_schedule_deployment(schedule: JsonObject | None) -> bool: - return any( - _deployment_status_value(deployment) in DOKPLOY_RUNNING_DEPLOYMENT_STATUSES for deployment in _schedule_deployments(schedule) - ) - - -def _should_clear_stale_data_workflow_lock(schedule: JsonObject | None) -> bool: - deployments = _schedule_deployments(schedule) - if not deployments or _has_running_schedule_deployment(schedule): - return False - for deployment in deployments: - deployment_status_value = _deployment_status_value(deployment) - if deployment_status_value in DOKPLOY_CANCELLED_DEPLOYMENT_STATUSES: - return True - if deployment_status_value in DOKPLOY_SUCCESS_DEPLOYMENT_STATUSES: - return False - return False - - -def _sync_dokploy_target_environment_and_deploy( - *, - dokploy_host: str, - dokploy_token: str, - target_definition: DokployTargetDefinition, - env_values: dict[str, str], - deploy_timeout_seconds: int, -) -> None: - compose_id = target_definition.target_id.strip() - compose_name = target_definition.target_name.strip() or f"{target_definition.context}-{target_definition.instance}" - target_payload = fetch_dokploy_target_payload( - host=dokploy_host, - token=dokploy_token, - target_type="compose", - target_id=compose_id, - ) - current_env_map = parse_dokploy_env_text(str(target_payload.get("env") or "")) - desired_env_map = dict(current_env_map) - updated_environment_keys: list[str] = [] - for environment_key, environment_value in env_values.items(): - if desired_env_map.get(environment_key) == environment_value: - continue - desired_env_map[environment_key] = environment_value - updated_environment_keys.append(environment_key) - - if updated_environment_keys: - update_dokploy_target_env( - host=dokploy_host, - token=dokploy_token, - target_type="compose", - target_id=compose_id, - target_payload=target_payload, - env_text=serialize_dokploy_env_text(desired_env_map), - ) - _logger.info( - "Updated Dokploy compose env for %s with %s key(s): %s", - compose_name, - len(updated_environment_keys), - ",".join(sorted(updated_environment_keys)), - ) - latest_compose_deployment = latest_deployment_for_compose(dokploy_host, dokploy_token, compose_id) - previous_deployment_key = deployment_key(latest_compose_deployment or {}) - dokploy_request( - host=dokploy_host, - token=dokploy_token, - path="/api/compose.deploy", - method="POST", - payload={"composeId": compose_id}, - timeout_seconds=deploy_timeout_seconds, - ) - deployment_result = wait_for_dokploy_compose_deployment( - host=dokploy_host, - token=dokploy_token, - compose_id=compose_id, - before_key=previous_deployment_key, - timeout_seconds=deploy_timeout_seconds, - ) - _logger.info("Dokploy compose deployment completed before data workflow: %s", deployment_result) - return - - _logger.info("Dokploy compose env already matched generated workflow env for %s; skipping pre-workflow deploy", compose_name) - - -def _run_dokploy_managed_remote_data_workflow( - *, - runtime_context: RuntimeContext, - env_values: dict[str, str], - bootstrap: bool, - no_sanitize: bool, - update_only: bool, -) -> int: - dokploy_host = env_values.get("DOKPLOY_HOST", "").strip() - dokploy_token = env_values.get("DOKPLOY_TOKEN", "").strip() - if not dokploy_host or not dokploy_token: - raise RuntimeCommandError( - "Dokploy remote data workflow requires DOKPLOY_HOST and DOKPLOY_TOKEN " - f"in the resolved environment. {runtime_environment_configuration_guidance()}" - ) - - target_definition = _resolve_required_dokploy_compose_target_definition(runtime_context) - context_name = runtime_context.selection.context_name - instance_name = runtime_context.selection.instance_name - stack_name = f"{context_name}-{instance_name}" - compose_id = target_definition.target_id.strip() - compose_name = target_definition.target_name.strip() or stack_name - schedule_type, schedule_lookup_id, compose_app_name, schedule_server_id = _resolve_dokploy_schedule_runtime( - dokploy_host=dokploy_host, - dokploy_token=dokploy_token, - compose_id=compose_id, - compose_name=compose_name, - ) - schedule_app_name = _build_dokploy_data_workflow_schedule_app_name( - context_name=context_name, - instance_name=instance_name, - ) - database_name = env_values.get("ODOO_DB_NAME", "").strip() - if not database_name: - raise RuntimeCommandError( - "Dokploy-managed remote data workflow requires ODOO_DB_NAME in the resolved environment. " - f"Missing database name for {context_name}/{instance_name}." - ) - filestore_path = (env_values.get("ODOO_FILESTORE_PATH") or "/volumes/data/filestore").strip() or "/volumes/data/filestore" - schedule_timeout_seconds = target_definition.deploy_timeout_seconds or DEFAULT_DOKPLOY_DEPLOY_TIMEOUT_SECONDS - _sync_dokploy_target_environment_and_deploy( - dokploy_host=dokploy_host, - dokploy_token=dokploy_token, - target_definition=target_definition, - env_values=env_values, - deploy_timeout_seconds=schedule_timeout_seconds, - ) - existing_schedule = find_matching_dokploy_schedule( - host=dokploy_host, - token=dokploy_token, - target_id=schedule_lookup_id, - schedule_type=schedule_type, - schedule_name=DOKPLOY_DATA_WORKFLOW_SCHEDULE_NAME, - app_name=schedule_app_name, - ) - if _has_running_schedule_deployment(existing_schedule): - raise RuntimeCommandError( - f"Dokploy-managed data workflow already has a running schedule deployment for {context_name}/{instance_name}." - ) - schedule_payload: JsonObject = { - "name": DOKPLOY_DATA_WORKFLOW_SCHEDULE_NAME, - "cronExpression": DOKPLOY_MANUAL_ONLY_CRON_EXPRESSION, - "appName": schedule_app_name, - "shellType": "bash", - "scheduleType": schedule_type, - "command": "platform data workflow", - "script": _build_dokploy_data_workflow_script( - compose_app_name=compose_app_name, - database_name=database_name, - filestore_path=filestore_path, - bootstrap=bootstrap, - no_sanitize=no_sanitize, - update_only=update_only, - clear_stale_lock=_should_clear_stale_data_workflow_lock(existing_schedule), - data_workflow_lock_path=env_values.get("ODOO_DATA_WORKFLOW_LOCK_FILE", "/volumes/data/.data_workflow_in_progress"), - ), - "serverId": schedule_server_id, - "userId": schedule_lookup_id if schedule_type == "dokploy-server" else None, - "enabled": False, - "timezone": "UTC", - } - schedule = upsert_dokploy_schedule( - host=dokploy_host, - token=dokploy_token, - target_id=schedule_lookup_id, - schedule_type=schedule_type, - schedule_name=DOKPLOY_DATA_WORKFLOW_SCHEDULE_NAME, - app_name=schedule_app_name, - schedule_payload=schedule_payload, - ) - schedule_id = schedule_key(schedule) - if not schedule_id: - raise RuntimeCommandError( - f"Dokploy schedule {DOKPLOY_DATA_WORKFLOW_SCHEDULE_NAME!r} for {context_name}/{instance_name} did not expose a schedule id." - ) - - latest_schedule_deployment = latest_deployment_for_schedule(dokploy_host, dokploy_token, schedule_id) - previous_deployment_key = deployment_key(latest_schedule_deployment or {}) - _logger.info( - "Dokploy remote data workflow: stack=%s schedule=%s schedule_type=%s compose_project=%s", - stack_name, - schedule_id, - schedule_type, - compose_app_name, - ) - dokploy_request( - host=dokploy_host, - token=dokploy_token, - path="/api/schedule.runManually", - method="POST", - payload={"scheduleId": schedule_id}, - timeout_seconds=schedule_timeout_seconds, - ) - deployment_result = wait_for_dokploy_schedule_deployment( - host=dokploy_host, - token=dokploy_token, - schedule_id=schedule_id, - before_key=previous_deployment_key, - timeout_seconds=schedule_timeout_seconds, - ) - _logger.info("Dokploy schedule workflow deployment completed: %s", deployment_result) - latest_schedule_deployment = latest_deployment_for_schedule(dokploy_host, dokploy_token, schedule_id) - latest_schedule_status = deployment_status(latest_schedule_deployment or {}) - if latest_schedule_status and latest_schedule_status not in DOKPLOY_SUCCESS_DEPLOYMENT_STATUSES: - raise RuntimeCommandError(f"Dokploy schedule {schedule_id!r} completed with non-success status {latest_schedule_status!r}.") - _logger.info("Dokploy-managed data workflow completed for stack %s via schedule %s", stack_name, schedule_id) - return 0 - - -def _build_dokploy_data_workflow_schedule_app_name(*, context_name: str, instance_name: str) -> str: - return f"platform-{context_name}-{instance_name}-data-workflow" diff --git a/odoo_devkit/runtime.py b/odoo_devkit/runtime.py index fcc2ff5..d8f71e9 100644 --- a/odoo_devkit/runtime.py +++ b/odoo_devkit/runtime.py @@ -24,9 +24,14 @@ up_runtime, ) from .manifest import WorkspaceManifest -from .remote_runtime import run_remote_bootstrap_workflow, run_remote_restore_workflow, run_remote_update_workflow LOCAL_ONLY_NATIVE_WORKFLOWS = frozenset({"init", "openupgrade"}) +NATIVE_RUNTIME_WORKFLOWS = LOCAL_ONLY_NATIVE_WORKFLOWS | frozenset({"bootstrap", "update"}) +LAUNCHPLANE_REMOTE_RUNTIME_GUIDANCE = ( + "Non-local Odoo runtime mutation belongs in Launchplane. " + "Use the Launchplane service, operator UI, or reusable Launchplane workflow for " + "non-local restore, bootstrap, update, promotion, rollback, and deploy flows." +) def runtime_target_is_local(manifest: WorkspaceManifest) -> bool: @@ -47,6 +52,22 @@ def _raise_local_only_runtime_command_error(*, command_name: str, manifest: Work ) +def _raise_launchplane_owned_runtime_command_error(*, command_name: str, manifest: WorkspaceManifest) -> None: + raise ValueError( + f"platform runtime {command_name} does not mutate non-local Odoo targets. " + f"Received {manifest.runtime.context}/{manifest.runtime.instance}. " + f"{LAUNCHPLANE_REMOTE_RUNTIME_GUIDANCE}" + ) + + +def _raise_launchplane_owned_workflow_error(*, workflow: str, manifest: WorkspaceManifest) -> None: + raise ValueError( + f"platform runtime workflow {workflow!r} does not mutate non-local Odoo targets. " + f"Received {manifest.runtime.context}/{manifest.runtime.instance}. " + f"{LAUNCHPLANE_REMOTE_RUNTIME_GUIDANCE}" + ) + + def resolve_runtime_repo_path(manifest: WorkspaceManifest) -> Path: explicit_runtime_repo = manifest.runtime_repo if explicit_runtime_repo is not None: @@ -281,16 +302,18 @@ def run_native_runtime_down(*, manifest: WorkspaceManifest, volumes: bool) -> in def run_native_runtime_workflow(*, manifest: WorkspaceManifest, workflow: str) -> int | None: normalized_workflow = workflow.strip().lower() + if normalized_workflow not in NATIVE_RUNTIME_WORKFLOWS: + supported_workflows = ", ".join(sorted(NATIVE_RUNTIME_WORKFLOWS)) + raise ValueError(f"Unsupported runtime workflow {workflow!r}. Supported workflows: {supported_workflows}.") runtime_repo_path = resolve_runtime_repo_path(manifest) local_runtime_target = runtime_target_is_local(manifest) try: + if not local_runtime_target and normalized_workflow in {"bootstrap", "update"}: + _raise_launchplane_owned_workflow_error(workflow=normalized_workflow, manifest=manifest) if normalized_workflow in LOCAL_ONLY_NATIVE_WORKFLOWS and not local_runtime_target: _raise_local_only_workflow_error(workflow=normalized_workflow, manifest=manifest) if normalized_workflow == "bootstrap": - if local_runtime_target: - run_bootstrap_workflow(manifest=manifest, runtime_repo_path=runtime_repo_path) - else: - run_remote_bootstrap_workflow(manifest=manifest, runtime_repo_path=runtime_repo_path) + run_bootstrap_workflow(manifest=manifest, runtime_repo_path=runtime_repo_path) print(f"bootstrap={manifest.runtime.context}-{manifest.runtime.instance}") print("workflow=bootstrap") return 0 @@ -300,10 +323,7 @@ def run_native_runtime_workflow(*, manifest: WorkspaceManifest, workflow: str) - print("workflow=init") return 0 if normalized_workflow == "update": - if local_runtime_target: - run_update_workflow(manifest=manifest, runtime_repo_path=runtime_repo_path) - else: - run_remote_update_workflow(manifest=manifest, runtime_repo_path=runtime_repo_path) + run_update_workflow(manifest=manifest, runtime_repo_path=runtime_repo_path) print(f"update={manifest.runtime.context}-{manifest.runtime.instance}") print("workflow=update") return 0 @@ -318,12 +338,11 @@ def run_native_runtime_workflow(*, manifest: WorkspaceManifest, workflow: str) - def run_native_runtime_restore(*, manifest: WorkspaceManifest) -> int | None: + if not runtime_target_is_local(manifest): + _raise_launchplane_owned_runtime_command_error(command_name="restore", manifest=manifest) runtime_repo_path = resolve_runtime_repo_path(manifest) try: - if runtime_target_is_local(manifest): - run_restore_workflow(manifest=manifest, runtime_repo_path=runtime_repo_path) - else: - run_remote_restore_workflow(manifest=manifest, runtime_repo_path=runtime_repo_path) + run_restore_workflow(manifest=manifest, runtime_repo_path=runtime_repo_path) except RuntimeCommandError as error: raise ValueError(str(error)) from error print(f"restore={manifest.runtime.context}-{manifest.runtime.instance}") diff --git a/odoo_devkit/workspace_cockpit.py b/odoo_devkit/workspace_cockpit.py index e994a04..91c66ab 100644 --- a/odoo_devkit/workspace_cockpit.py +++ b/odoo_devkit/workspace_cockpit.py @@ -365,7 +365,9 @@ def _default_docs_working_split_lines() -> tuple[str, ...]: def _default_docs_operational_note_lines() -> tuple[str, ...]: - return ("Historical plans normally live under `/Users/cbusillo/.code/plans/`; check `/Users/cbusillo/.codex/plans/` only for legacy rationale or prior sequencing.",) + return ( + "Historical plans normally live under `/Users/cbusillo/.code/plans/`; check `/Users/cbusillo/.codex/plans/` only for legacy rationale or prior sequencing.", + ) def _default_session_prompt_rule_lines() -> tuple[str, ...]: diff --git a/odoo_devkit/workspace_surface.py b/odoo_devkit/workspace_surface.py index 55e11aa..c77d2ab 100644 --- a/odoo_devkit/workspace_surface.py +++ b/odoo_devkit/workspace_surface.py @@ -108,7 +108,7 @@ def _render_workspace_agents( "- 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 plus explicit Dokploy-managed data workflows. Stable remote lanes are `testing` and `prod`; Launchplane PR previews replace a durable `dev` lane; release actions such as ship/promote/gate stay in `launchplane`.\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" 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" @@ -240,7 +240,7 @@ def _render_workspace_docs_index( "- PyCharm should keep opening the tenant repo directly so search/indexing stays focused on the client code.\n" 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 remote-data-workflow surface. Stable remote lanes are `testing` and `prod`; Launchplane PR previews replace a durable `dev` lane; release actions for remote environments belong in `launchplane`.\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" "- When in doubt about ownership, fix the source repo under `sources/` instead of editing generated files in the workspace root.\n" ) @@ -272,8 +272,8 @@ def _render_workspace_session_prompt( "- Treat the workspace root as a generated cockpit, not the source of truth.\n" "- Keep tenant code in sources/tenant.\n" "- Keep shared DX/runtime/workspace behavior in odoo-devkit.\n" - "- Use platform runtime for local runtime and explicit data workflows.\n" - "- Use launchplane for remote release actions.\n" + "- Use platform runtime for local runtime and artifact handoff.\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" "- When generated files disagree with source repos, fix the source repo or generator rather than hand-editing generated output.\n" diff --git a/tests/test_remote_runtime.py b/tests/test_remote_runtime.py deleted file mode 100644 index 05f36aa..0000000 --- a/tests/test_remote_runtime.py +++ /dev/null @@ -1,355 +0,0 @@ -from __future__ import annotations - -import os -import tempfile -import unittest -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch - -from odoo_devkit import remote_runtime - - -def _sample_remote_target_definition() -> remote_runtime.DokployTargetDefinition: - return remote_runtime.DokployTargetDefinition( - context="opw", - instance="testing", - target_id="compose-1", - target_name="opw-testing", - deploy_timeout_seconds=7200, - ) - - -def _sample_runtime_context(*, repo_root: Path) -> SimpleNamespace: - return SimpleNamespace( - repo_root=repo_root, - selection=SimpleNamespace(context_name="opw", instance_name="testing"), - ) - - -class RemoteRuntimeTests(unittest.TestCase): - def test_load_dokploy_source_of_truth_reads_control_plane_catalog_with_target_id_overrides(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - temp_root = Path(temporary_directory) - repo_root = temp_root / "runtime-repo" - control_plane_root = temp_root / "harbor" - (control_plane_root / "config").mkdir(parents=True, exist_ok=True) - (control_plane_root / "config" / "dokploy.toml").write_text( - """ -schema_version = 1 - -[[targets]] -context = "opw" -instance = "testing" -""".strip() - + "\n", - encoding="utf-8", - ) - (control_plane_root / "config" / "dokploy-targets.toml").write_text( - """ -schema_version = 1 - -[[targets]] -context = "opw" -instance = "testing" -target_id = "control-plane-compose" -""".strip() - + "\n", - encoding="utf-8", - ) - - with patch.dict(os.environ, {"ODOO_CONTROL_PLANE_ROOT": str(control_plane_root)}): - source_of_truth = remote_runtime.load_dokploy_source_of_truth(repo_root) - - assert source_of_truth is not None - self.assertEqual(source_of_truth.targets[0].target_id, "control-plane-compose") - - def test_load_dokploy_source_of_truth_requires_control_plane_root(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - repo_root = Path(temporary_directory) - platform_directory = repo_root / "platform" - platform_directory.mkdir(parents=True, exist_ok=True) - (platform_directory / "dokploy.toml").write_text( - """ -schema_version = 1 - -[[targets]] -context = "opw" -instance = "testing" -target_id = "legacy-compose" -""".strip() - + "\n", - encoding="utf-8", - ) - - with patch.dict(os.environ, {}, clear=True): - source_of_truth = remote_runtime.load_dokploy_source_of_truth(repo_root) - - self.assertIsNone(source_of_truth) - - def test_load_dokploy_source_of_truth_applies_profile_and_project_inheritance(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - temp_root = Path(temporary_directory) - repo_root = temp_root / "runtime-repo" - control_plane_root = temp_root / "harbor" - control_plane_config_directory = control_plane_root / "config" - control_plane_config_directory.mkdir(parents=True, exist_ok=True) - (control_plane_config_directory / "dokploy.toml").write_text( - """ -schema_version = 1 - -[defaults] -target_type = "compose" -deploy_timeout_seconds = 7200 - -[projects] -shared = "shared-project" - -[profiles.testing] -project = "shared" -healthcheck_enabled = false - -[[targets]] -context = "opw" -instance = "testing" -profile = "testing" -target_name = "opw-testing" -domains = ["testing.example.com"] -""".strip() - + "\n", - encoding="utf-8", - ) - (control_plane_config_directory / "dokploy-targets.toml").write_text( - """ -schema_version = 1 - -[[targets]] -context = "opw" -instance = "testing" -target_id = "compose-123" -""".strip() - + "\n", - encoding="utf-8", - ) - - with patch.dict(os.environ, {"ODOO_CONTROL_PLANE_ROOT": str(control_plane_root)}): - source_of_truth = remote_runtime.load_dokploy_source_of_truth(repo_root) - - assert source_of_truth is not None - self.assertEqual(source_of_truth.schema_version, 1) - self.assertEqual(len(source_of_truth.targets), 1) - target_definition = source_of_truth.targets[0] - self.assertEqual(target_definition.project_name, "shared-project") - self.assertEqual(target_definition.target_type, "compose") - self.assertEqual(target_definition.deploy_timeout_seconds, 7200) - self.assertFalse(target_definition.healthcheck_enabled) - self.assertEqual(target_definition.domains, ("testing.example.com",)) - - def test_load_dokploy_source_of_truth_rejects_unknown_target_id_routes(self) -> None: - with tempfile.TemporaryDirectory() as temporary_directory: - temp_root = Path(temporary_directory) - repo_root = temp_root / "runtime-repo" - control_plane_root = temp_root / "harbor" - control_plane_config_directory = control_plane_root / "config" - control_plane_config_directory.mkdir(parents=True, exist_ok=True) - (control_plane_config_directory / "dokploy.toml").write_text( - """ -schema_version = 1 - -[[targets]] -context = "opw" -instance = "testing" -""".strip() - + "\n", - encoding="utf-8", - ) - (control_plane_config_directory / "dokploy-targets.toml").write_text( - """ -schema_version = 1 - -[[targets]] -context = "cm" -instance = "prod" -target_id = "compose-456" -""".strip() - + "\n", - encoding="utf-8", - ) - - with patch.dict(os.environ, {"ODOO_CONTROL_PLANE_ROOT": str(control_plane_root)}): - with self.assertRaisesRegex( - remote_runtime.RuntimeCommandError, - r"route\(s\) that are not present in the control-plane route catalog: cm/prod", - ): - remote_runtime.load_dokploy_source_of_truth(repo_root) - - def test_build_dokploy_data_workflow_script_includes_project_labels_and_flags(self) -> None: - schedule_script = remote_runtime._build_dokploy_data_workflow_script( - compose_app_name="compose-opw-testing-abc123", - database_name="opw-testing", - bootstrap=True, - no_sanitize=True, - update_only=False, - clear_stale_lock=True, - data_workflow_lock_path="/volumes/data/.data_workflow_in_progress", - ) - - self.assertIn("com.docker.compose.project=${compose_project}", schedule_script) - self.assertIn('script_runner_container_id=$(resolve_container_id "script-runner")', schedule_script) - self.assertIn("--bootstrap", schedule_script) - self.assertIn("--no-sanitize", schedule_script) - self.assertIn("Clearing stale data workflow lock ${data_workflow_lock_path}", schedule_script) - self.assertIn("Normalizing filestore ownership for ${database_name}", schedule_script) - self.assertIn("workflow_ssh_dir=/tmp/platform-data-workflow-ssh", schedule_script) - - def test_run_dokploy_managed_remote_data_workflow_upserts_and_runs_schedule(self) -> None: - runtime_context = _sample_runtime_context(repo_root=Path("/tmp/repo")) - target_definition = _sample_remote_target_definition() - dokploy_request_calls: list[dict[str, object]] = [] - updated_target_env_calls: list[dict[str, object]] = [] - - def record_dokploy_request(**kwargs: object) -> object: - dokploy_request_calls.append(dict(kwargs)) - return True - - def record_target_env_update(**kwargs: object) -> None: - updated_target_env_calls.append(dict(kwargs)) - - with ( - patch.object( - remote_runtime, - "_resolve_required_dokploy_compose_target_definition", - return_value=target_definition, - ), - patch.object( - remote_runtime, - "_resolve_dokploy_schedule_runtime", - return_value=("dokploy-server", "user-123", "compose-opw-testing-abc123", None), - ), - patch.object( - remote_runtime, - "find_matching_dokploy_schedule", - return_value={"deployments": [{"status": "cancelled"}]}, - ), - patch.object( - remote_runtime, - "fetch_dokploy_target_payload", - return_value={ - "env": "ODOO_ADDON_REPOSITORIES=cbusillo/disable_odoo_online@main,OCA/OpenUpgrade@19.0\n" - "OPENUPGRADE_ADDON_REPOSITORY=OCA/OpenUpgrade@89e649728027a8ab656b3aa4be18f4bd364db417\n" - "OPENUPGRADELIB_INSTALL_SPEC=git+https://github.com/OCA/openupgradelib.git@46d66ff5ed6a99481f84d3c79fc6e50835da7286", - }, - ), - patch.object(remote_runtime, "update_dokploy_target_env", side_effect=record_target_env_update), - patch.object( - remote_runtime, - "upsert_dokploy_schedule", - return_value={"scheduleId": "schedule-123"}, - ) as upsert_schedule, - patch.object( - remote_runtime, - "latest_deployment_for_compose", - return_value={"deploymentId": "compose-before-1", "status": "done"}, - ), - patch.object( - remote_runtime, - "wait_for_dokploy_compose_deployment", - return_value="deployment=compose-after-1 status=done", - ), - patch.object( - remote_runtime, - "latest_deployment_for_schedule", - side_effect=[{"deploymentId": "before-1", "status": "done"}, {"deploymentId": "after-1", "status": "done"}], - ), - patch.object(remote_runtime, "wait_for_dokploy_schedule_deployment", return_value="deployment=after-1 status=done"), - patch.object(remote_runtime, "dokploy_request", side_effect=record_dokploy_request), - ): - exit_code = remote_runtime._run_dokploy_managed_remote_data_workflow( - runtime_context=runtime_context, - env_values={ - "DOKPLOY_HOST": "https://dokploy.example", - "DOKPLOY_TOKEN": "token", - "ODOO_DB_NAME": "opw", - "ODOO_ADDON_REPOSITORIES": "cbusillo/disable_odoo_online@main," - "OCA/OpenUpgrade@89e649728027a8ab656b3aa4be18f4bd364db417", - "OPENUPGRADE_ADDON_REPOSITORY": "OCA/OpenUpgrade@89e649728027a8ab656b3aa4be18f4bd364db417", - "OPENUPGRADELIB_INSTALL_SPEC": "git+https://github.com/OCA/openupgradelib.git@" - "46d66ff5ed6a99481f84d3c79fc6e50835da7286", - }, - bootstrap=True, - no_sanitize=True, - update_only=False, - ) - - self.assertEqual(exit_code, 0) - self.assertEqual(len(updated_target_env_calls), 1) - rendered_env_text = str(updated_target_env_calls[0]["env_text"]) - self.assertIn( - "ODOO_ADDON_REPOSITORIES=cbusillo/disable_odoo_online@main,OCA/OpenUpgrade@89e649728027a8ab656b3aa4be18f4bd364db417", - rendered_env_text, - ) - upsert_payload = upsert_schedule.call_args.kwargs["schedule_payload"] - self.assertEqual(upsert_payload["scheduleType"], "dokploy-server") - self.assertEqual(upsert_payload["userId"], "user-123") - self.assertEqual(upsert_payload["enabled"], False) - self.assertEqual(upsert_payload["timezone"], "UTC") - self.assertIn("Clearing stale data workflow lock ${data_workflow_lock_path}", str(upsert_payload["script"])) - self.assertIn("Normalizing filestore ownership for ${database_name}", str(upsert_payload["script"])) - self.assertIn("--bootstrap", str(upsert_payload["script"])) - self.assertIn("--no-sanitize", str(upsert_payload["script"])) - self.assertEqual( - dokploy_request_calls, - [ - { - "host": "https://dokploy.example", - "token": "token", - "path": "/api/compose.deploy", - "method": "POST", - "payload": {"composeId": "compose-1"}, - "timeout_seconds": 7200, - }, - { - "host": "https://dokploy.example", - "token": "token", - "path": "/api/schedule.runManually", - "method": "POST", - "payload": {"scheduleId": "schedule-123"}, - "timeout_seconds": 7200, - }, - ], - ) - - def test_run_dokploy_managed_remote_data_workflow_requires_database_name(self) -> None: - runtime_context = _sample_runtime_context(repo_root=Path("/tmp/repo")) - target_definition = _sample_remote_target_definition() - - with ( - patch.object( - remote_runtime, - "_resolve_required_dokploy_compose_target_definition", - return_value=target_definition, - ), - patch.object( - remote_runtime, - "_resolve_dokploy_schedule_runtime", - return_value=("dokploy-server", "user-123", "compose-opw-testing-abc123", None), - ), - patch.object(remote_runtime, "_sync_dokploy_target_environment_and_deploy") as sync_target, - patch.object(remote_runtime, "find_matching_dokploy_schedule", return_value=None), - ): - with self.assertRaisesRegex(remote_runtime.RuntimeCommandError, "requires ODOO_DB_NAME"): - remote_runtime._run_dokploy_managed_remote_data_workflow( - runtime_context=runtime_context, - env_values={ - "DOKPLOY_HOST": "https://dokploy.example", - "DOKPLOY_TOKEN": "token", - }, - bootstrap=False, - no_sanitize=False, - update_only=False, - ) - sync_target.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_runtime.py b/tests/test_runtime.py index f5072ec..6abbf05 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1980,7 +1980,7 @@ def test_native_runtime_odoo_shell_dry_run_prints_redirects(self) -> None: self.assertIn(">", output) self.assertIn("odoo-shell.log", output) - def test_native_runtime_restore_returns_none_for_non_local_instance(self) -> None: + def test_native_runtime_restore_rejects_non_local_instance(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) tenant_repo_path = temp_root / "tenant-repo" @@ -1994,14 +1994,10 @@ def test_native_runtime_restore_returns_none_for_non_local_instance(self) -> Non ) manifest = load_workspace_manifest(manifest_path) - with mock.patch("odoo_devkit.runtime.run_remote_restore_workflow") as remote_restore: - with contextlib.redirect_stdout(io.StringIO()): - exit_code = run_native_runtime_restore(manifest=manifest) - - self.assertEqual(exit_code, 0) - remote_restore.assert_called_once_with(manifest=manifest, runtime_repo_path=runtime_repo_path.resolve()) + with self.assertRaisesRegex(ValueError, "belongs in Launchplane"): + run_native_runtime_restore(manifest=manifest) - def test_native_runtime_workflow_runs_remote_update_for_non_local_instance(self) -> None: + def test_native_runtime_workflow_rejects_non_local_update(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) tenant_repo_path = temp_root / "tenant-repo" @@ -2015,14 +2011,10 @@ def test_native_runtime_workflow_runs_remote_update_for_non_local_instance(self) ) manifest = load_workspace_manifest(manifest_path) - with mock.patch("odoo_devkit.runtime.run_remote_update_workflow") as remote_update: - with contextlib.redirect_stdout(io.StringIO()): - exit_code = run_native_runtime_workflow(manifest=manifest, workflow="update") + with self.assertRaisesRegex(ValueError, "belongs in Launchplane"): + run_native_runtime_workflow(manifest=manifest, workflow="update") - self.assertEqual(exit_code, 0) - remote_update.assert_called_once_with(manifest=manifest, runtime_repo_path=runtime_repo_path.resolve()) - - def test_native_runtime_workflow_runs_remote_bootstrap_for_non_local_instance(self) -> None: + def test_native_runtime_workflow_rejects_non_local_bootstrap(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temp_root = Path(temporary_directory) tenant_repo_path = temp_root / "tenant-repo" @@ -2036,12 +2028,8 @@ def test_native_runtime_workflow_runs_remote_bootstrap_for_non_local_instance(se ) manifest = load_workspace_manifest(manifest_path) - with mock.patch("odoo_devkit.runtime.run_remote_bootstrap_workflow") as remote_bootstrap: - with contextlib.redirect_stdout(io.StringIO()): - exit_code = run_native_runtime_workflow(manifest=manifest, workflow="bootstrap") - - self.assertEqual(exit_code, 0) - remote_bootstrap.assert_called_once_with(manifest=manifest, runtime_repo_path=runtime_repo_path.resolve()) + with self.assertRaisesRegex(ValueError, "belongs in Launchplane"): + run_native_runtime_workflow(manifest=manifest, workflow="bootstrap") def test_native_runtime_logs_runs_local_helper(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: @@ -2387,10 +2375,12 @@ def test_native_runtime_workflow_rejects_non_local_openupgrade(self) -> None: with self.assertRaisesRegex(ValueError, "requires --instance local"): run_native_runtime_workflow(manifest=manifest, workflow="openupgrade") - def test_cli_runtime_workflow_rejects_non_local_local_only_workflows_without_platform_fallback(self) -> None: + def test_cli_runtime_workflow_rejects_non_local_mutations_without_platform_fallback(self) -> None: workflow_cases = ( ("init", "dev"), ("openupgrade", "testing"), + ("bootstrap", "prod"), + ("update", "testing"), ) for workflow_name, instance_name in workflow_cases: with self.subTest(workflow=workflow_name, instance=instance_name): @@ -2412,9 +2402,55 @@ def test_cli_runtime_workflow_rejects_non_local_local_only_workflows_without_pla _handle_runtime_workflow(arguments) self.assertIsInstance(captured_exit.exception.code, str) - self.assertIn("requires --instance local", str(captured_exit.exception.code)) + self.assertTrue( + "requires --instance local" in str(captured_exit.exception.code) + or "belongs in Launchplane" in str(captured_exit.exception.code) + ) platform_command.assert_not_called() + def test_cli_runtime_workflow_rejects_unknown_workflows_without_platform_fallback(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) + runtime_repo_path.mkdir(parents=True, exist_ok=True) + manifest_path = self._write_manifest( + tenant_repo_path=tenant_repo_path, + runtime_repo_path=runtime_repo_path, + ) + arguments = argparse.Namespace(manifest=manifest_path, workflow="custom-remote-flow") + + with mock.patch("odoo_devkit.cli.run_runtime_platform_command") as platform_command: + with self.assertRaises(SystemExit) as captured_exit: + _handle_runtime_workflow(arguments) + + self.assertIsInstance(captured_exit.exception.code, str) + self.assertIn("Unsupported runtime workflow", str(captured_exit.exception.code)) + platform_command.assert_not_called() + + def test_cli_runtime_restore_rejects_non_local_without_platform_fallback(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) + runtime_repo_path.mkdir(parents=True, exist_ok=True) + manifest_path = self._write_manifest( + tenant_repo_path=tenant_repo_path, + runtime_repo_path=runtime_repo_path, + instance_name="testing", + ) + arguments = argparse.Namespace(manifest=manifest_path, runtime_instance=None) + + with mock.patch("odoo_devkit.cli.run_runtime_platform_command") as platform_command: + with self.assertRaises(SystemExit) as captured_exit: + _handle_runtime_restore(arguments) + + self.assertIsInstance(captured_exit.exception.code, str) + self.assertIn("belongs in Launchplane", str(captured_exit.exception.code)) + platform_command.assert_not_called() + @staticmethod def _runtime_data_workflow_side_effect() -> mock.Mock: def run_side_effect(*args: object, **kwargs: object) -> mock.Mock: diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 9be8d01..6421f32 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -135,7 +135,10 @@ def test_sync_creates_workspace_lock_and_pycharm_outputs(self) -> None: self.assertIn(str((tenant_repo_path / "addons" / "shared").resolve()), workspace_docs_index_contents) self.assertIn("Stable remote lanes are `testing` and `prod`", workspace_docs_index_contents) self.assertIn("Launchplane PR previews replace a durable `dev` lane", workspace_docs_index_contents) - self.assertIn("release actions for remote environments belong in `launchplane`", workspace_docs_index_contents) + self.assertIn( + "remote restore/bootstrap/update and release actions belong in `launchplane`", + workspace_docs_index_contents, + ) workspace_session_prompt_contents = result.workspace_session_prompt_path.read_text(encoding="utf-8") self.assertIn("Session Prompt Template", workspace_session_prompt_contents) @@ -577,7 +580,10 @@ def test_workspace_surface_prefers_tenant_root_scripts_when_present(self) -> Non self.assertIn("Legacy or disposable local runtime output", 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("launchplane for remote release actions", workspace_session_prompt_contents) + self.assertIn( + "launchplane for remote release and non-local data actions", + workspace_session_prompt_contents, + ) def test_cli_parser_accepts_workspace_run_remainder(self) -> None: parser = build_parser()