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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ uv run platform workspace clean --manifest /path/to/workspace.toml
uv run platform workspace run --manifest /path/to/workspace.toml -- pwd
uv run platform runtime select --manifest /path/to/workspace.toml
uv run platform runtime build --manifest /path/to/workspace.toml --no-cache
uv run platform runtime publish --manifest /path/to/workspace.toml \
--instance testing \
--image-repository ghcr.io/example/odoo-opw \
--image-tag opw-20260416-deadbeef \
--output-file /tmp/opw-artifact.json
uv run platform runtime up --manifest /path/to/workspace.toml --build
uv run platform runtime down --manifest /path/to/workspace.toml --volumes
uv run platform runtime workflow --manifest /path/to/workspace.toml --workflow update
Expand Down Expand Up @@ -139,23 +134,18 @@ uv run python -m unittest discover -s tests
For tenant repos that keep `instance = "local"` in the tracked manifest,
`--instance testing` or `--instance prod` is not a shortcut for remote
mutation. Release and non-local data actions should run through `launchplane`.
Local and artifact-handoff examples:
Local workflow example:

```bash
uv --directory ../odoo-devkit run platform runtime workflow \
--manifest ./workspace.toml \
--workflow bootstrap
uv --directory ../odoo-devkit run platform runtime publish \
--manifest ./workspace.toml \
--instance testing \
--image-repository ghcr.io/example/odoo-opw \
--image-tag opw-20260416-deadbeef \
--output-file /tmp/opw-artifact.json
uv --directory ../launchplane run launchplane artifacts write \
--state-dir ./state \
--input-file /tmp/opw-artifact.json
```

Non-local artifact publishing runs through Launchplane's reusable workflow,
which supplies the authoritative runtime-environment payload and records the
resulting artifact.

`platform runtime publish` stages the tenant addons plus shared addons into a
real build context, requires clean git worktrees for the repos it captures,
pushes the resulting image, resolves the pushed digest, and emits a
Expand Down
5 changes: 4 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ When

- PyCharm opens the tenant repo directly.
- Every Code starts from the materialized workspace root.
- `odoo-devkit` owns the shared DX/runtime/bootstrap contract.
- `odoo-devkit` owns the shared local DX/runtime/bootstrap contract.
- The control plane owns canonical deploy/build tuples and release-sensitive
behavior.
- Remote release flow remains artifact-backed and control-plane-owned rather
Expand All @@ -44,6 +44,9 @@ When
- Generated PyCharm metadata and run configurations for the tenant repo.
- Devkit-owned local runtime bundle (`docker-compose.yml`, `platform/stack.toml`,
Dockerfile, and local runtime scripts).
- Checked-in stack runtime values and Odoo overrides are local-development
defaults only; Launchplane runtime-environment records own non-local artifact
publish values and hosted lane authority.
- Shared tenant runtime contracts that must stay safe across stable-lane
redeploys, including conservative database image/version changes for
long-lived tenant volumes.
Expand Down
4 changes: 3 additions & 1 deletion docs/tooling/artifact-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ When

- Keep source input intent versioned in the repo that owns the build.
- Keep `workspace.toml` focused on workspace assembly and runtime targeting.
- Keep runtime stack config focused on runtime selection state.
- Keep runtime stack config focused on local runtime selection state.
- Keep hosted runtime values in Launchplane records. `artifact-inputs.toml`
may override source repository selection, not hosted runtime authority.
- Keep artifact, release, and deploy truth pinned to resolved SHAs and image
digests after publish.

Expand Down
26 changes: 14 additions & 12 deletions docs/tooling/workspace-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,6 @@ uv run platform workspace clean --manifest /path/to/workspace.toml
uv run platform workspace run --manifest /path/to/workspace.toml -- pwd
uv run platform runtime select --manifest /path/to/workspace.toml
uv run platform runtime build --manifest /path/to/workspace.toml --no-cache
uv run platform runtime publish --manifest /path/to/workspace.toml \
--instance testing \
--image-repository ghcr.io/example/odoo-opw \
--image-tag opw-20260416-deadbeef \
--output-file /tmp/opw-artifact.json
uv run platform runtime up --manifest /path/to/workspace.toml --build
uv run platform runtime down --manifest /path/to/workspace.toml --volumes
uv run platform runtime workflow --manifest /path/to/workspace.toml --workflow update
Expand All @@ -54,6 +49,9 @@ uv run platform runtime odoo-shell --manifest /path/to/workspace.toml \
If `--manifest` is omitted, the command looks for `workspace.toml` in the
current directory.

Non-local `platform runtime publish` is invoked by Launchplane's reusable
artifact workflow, which supplies the required runtime-environment payload.

## `workspace sync`

Purpose
Expand Down Expand Up @@ -180,6 +178,9 @@ Notes
`launchplane_settings`. `config_parameters` tables write Odoo
`ir.config_parameter` keys, while `addon_settings.<addon>` tables write
supported addon settings such as `authentik_sso` values.
- Checked-in stack `runtime_env` and `odoo_overrides` values are local-only.
Active dev, testing, and production values or domains belong to Launchplane
runtime-environment records.
- Non-local Launchplane-managed instances (`dev`, `testing`, and `prod`) always
prepend `launchplane_settings` and `disable_odoo_online` to the resolved Odoo
install module list. Artifact inputs or base images make addon files
Expand Down Expand Up @@ -252,13 +253,14 @@ Notes
pushes the requested image tag, reads the pushed image digest from Buildx's
build metadata output, and writes a control-plane-compatible artifact manifest
JSON file.
- When Launchplane supplies `ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON`, publish can
synthesize the selected manifest context from that explicit payload instead of
requiring every Launchplane-owned product context to be listed in the shared
devkit stack. Synthesized contexts do not inherit stack-level install-module
lists; their artifact install intent comes from the managed-instance required
modules plus any repo-owned `website-bootstrap.toml` modules. Unknown contexts
still fail closed without the explicit payload.
- Non-local publish requires Launchplane to supply
`ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON`. The payload is authoritative for
artifact build runtime keys and can synthesize a missing context or instance
instead of requiring hosted lanes in the shared devkit stack. Synthesized
contexts do not inherit stack-level install-module lists; their artifact
install intent comes from managed-instance required modules plus any
repo-owned `website-bootstrap.toml` modules. Unknown contexts and non-local
instances fail closed without the explicit payload.
- Publish-time GHCR credentials can be split by purpose. Private base image
reads prefer `GHCR_READ_TOKEN`, artifact image pushes prefer `GHCR_TOKEN`,
and private source checkout secrets still belong in the transient runtime
Expand Down
87 changes: 59 additions & 28 deletions odoo_devkit/local_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@
ARTIFACT_SOURCE_ENV_KEYS = ("ODOO_ADDON_REPOSITORIES", "OPENUPGRADE_ADDON_REPOSITORY")
SOURCE_GITHUB_TOKEN_ENV_KEYS = ("ODOO_DEVKIT_SOURCE_GITHUB_TOKEN", "ODOO_SOURCE_GITHUB_TOKEN")
RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR = "ODOO_DEVKIT_RUNTIME_ENVIRONMENT_JSON"
ARTIFACT_PUBLISH_RUNTIME_ENV_KEYS = (
"ODOO_VERSION",
"ODOO_BASE_RUNTIME_IMAGE",
"ODOO_BASE_DEVTOOLS_IMAGE",
"ODOO_ADDON_REPOSITORIES",
"OPENUPGRADE_ADDON_REPOSITORY",
"OPENUPGRADELIB_INSTALL_SPEC",
"ODOO_PYTHON_SYNC_SKIP_ADDONS",
)
ODOO_INSTANCE_OVERRIDES_PAYLOAD_ENV_KEY = "ODOO_INSTANCE_OVERRIDES_PAYLOAD_B64"
LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED_ENV_KEY = "LAUNCHPLANE_INSTANCE_OVERRIDES_REQUIRED"
LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED_ENV_KEY = "LAUNCHPLANE_WEBSITE_BOOTSTRAP_REQUIRED"
Expand Down Expand Up @@ -452,6 +461,10 @@ def publish_runtime_artifact(
normalized_platforms = tuple(platform.strip() for platform in platforms if platform.strip())
if not normalized_platforms:
raise RuntimeCommandError("Artifact publish requires at least one target platform.")
if manifest.runtime.instance.strip().lower() != "local" and not explicit_runtime_environment_payload_is_configured():
raise RuntimeCommandError(
f"Non-local artifact publish requires Launchplane runtime environment payload via {RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR}."
)

runtime_context = load_runtime_context(
manifest=manifest,
Expand All @@ -469,6 +482,7 @@ def publish_runtime_artifact(
runtime_context=runtime_context,
runtime_values=runtime_values,
)
validate_artifact_publish_runtime_values(runtime_values)
runtime_values, artifact_source_selectors = resolve_artifact_runtime_source_repository_refs(
runtime_values=runtime_values,
)
Expand Down Expand Up @@ -512,15 +526,7 @@ def publish_runtime_artifact(
build_command.extend(["--secret", "id=github_token,env=GITHUB_TOKEN"])
if no_cache:
build_command.append("--no-cache")
for build_argument_name in (
"ODOO_VERSION",
"ODOO_BASE_RUNTIME_IMAGE",
"ODOO_BASE_DEVTOOLS_IMAGE",
"ODOO_ADDON_REPOSITORIES",
"OPENUPGRADE_ADDON_REPOSITORY",
"OPENUPGRADELIB_INSTALL_SPEC",
"ODOO_PYTHON_SYNC_SKIP_ADDONS",
):
for build_argument_name in ARTIFACT_PUBLISH_RUNTIME_ENV_KEYS:
build_command.extend(["--build-arg", f"{build_argument_name}={runtime_values.get(build_argument_name, '')}"])
build_command.append(str(staged_context_root))
run_command(
Expand Down Expand Up @@ -576,6 +582,13 @@ def publish_runtime_artifact(
)


def validate_artifact_publish_runtime_values(runtime_values: dict[str, str]) -> None:
if not runtime_values.get("ODOO_VERSION", "").strip():
raise RuntimeCommandError(
f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment must include ODOO_VERSION for artifact publish."
)


def down_runtime(*, manifest: WorkspaceManifest, runtime_repo_path: Path, volumes: bool) -> None:
load_runtime_context(manifest=manifest, runtime_repo_path=runtime_repo_path)
runtime_env_file = ensure_runtime_env_file(
Expand Down Expand Up @@ -872,23 +885,33 @@ def explicit_runtime_environment_payload_is_configured() -> bool:


def synthesize_runtime_payload_context(*, manifest: WorkspaceManifest, stack_definition: StackDefinition) -> StackDefinition:
if manifest.runtime.context in stack_definition.contexts:
return stack_definition
contexts = dict(stack_definition.contexts)
contexts[manifest.runtime.context] = ContextDefinition(
context_definition = contexts.get(manifest.runtime.context)
if context_definition is None:
context_definition = ContextDefinition(
database=manifest.runtime.database,
install_modules=(),
runtime_env={},
odoo_overrides=empty_odoo_override_definition(),
update_modules="AUTO",
instances={},
)
if manifest.runtime.instance in context_definition.instances:
return stack_definition
instances = dict(context_definition.instances)
instances[manifest.runtime.instance] = InstanceDefinition(
database=manifest.runtime.database,
install_modules=(),
install_modules_add=(),
runtime_env={},
odoo_overrides=empty_odoo_override_definition(),
update_modules="AUTO",
instances={
manifest.runtime.instance: InstanceDefinition(
database=manifest.runtime.database,
install_modules_add=(),
runtime_env={},
odoo_overrides=empty_odoo_override_definition(),
)
},
)
contexts[manifest.runtime.context] = ContextDefinition(
database=context_definition.database or manifest.runtime.database,
install_modules=context_definition.install_modules,
runtime_env=context_definition.runtime_env,
odoo_overrides=context_definition.odoo_overrides,
update_modules=context_definition.update_modules,
instances=instances,
)
return StackDefinition(
schema_version=stack_definition.schema_version,
Expand Down Expand Up @@ -1306,8 +1329,12 @@ def load_environment_from_explicit_payload(
raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} must contain a JSON object.") from error
if not isinstance(payload, dict):
raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} must contain a JSON object.")
payload_context = clean_optional_value(str(payload.get("context", "")))
payload_instance = clean_optional_value(str(payload.get("instance", "")))
raw_payload_context = payload.get("context")
raw_payload_instance = payload.get("instance")
if not isinstance(raw_payload_context, str) or not isinstance(raw_payload_instance, str):
raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} context and instance must be strings.")
payload_context = clean_optional_value(raw_payload_context)
payload_instance = clean_optional_value(raw_payload_instance)
if payload_context != context_name or payload_instance != instance_name:
raise RuntimeCommandError(
f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} context/instance does not match the selected runtime. "
Expand All @@ -1316,11 +1343,12 @@ def load_environment_from_explicit_payload(
raw_environment = payload.get("environment")
if not isinstance(raw_environment, dict):
raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} must include an environment object.")
resolved_environment = {
environment_key: str(environment_value)
if any(
not isinstance(environment_key, str) or not isinstance(environment_value, str)
for environment_key, environment_value in raw_environment.items()
if isinstance(environment_key, str)
}
):
raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment keys and values must be strings.")
resolved_environment = dict(raw_environment)
if not resolved_environment:
raise RuntimeCommandError(f"{RUNTIME_ENVIRONMENT_PAYLOAD_ENV_VAR} environment object must not be empty.")
synthetic_env_file = Path(".generated") / "runtime-env" / f"{context_name}.{instance_name}.env"
Expand Down Expand Up @@ -1802,6 +1830,9 @@ def build_runtime_env_values(
runtime_values[environment_key] = source_environment[environment_key]
for runtime_key, runtime_value in runtime_selection.effective_runtime_env.items():
runtime_values[runtime_key] = runtime_value
if explicit_runtime_environment_payload_is_configured():
for runtime_key in ARTIFACT_PUBLISH_RUNTIME_ENV_KEYS:
runtime_values[runtime_key] = source_environment.get(runtime_key, "")
apply_typed_odoo_instance_override_payload(
runtime_values=runtime_values,
context_name=runtime_selection.context_name,
Expand Down
72 changes: 7 additions & 65 deletions platform/stack.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,6 @@ install_modules = [
"cm_custom",
]

[contexts.cm.runtime_env]
ODOO_DEV_MODE = ""

[contexts.cm.odoo_overrides.addon_settings.authentik_sso]
base_url = "https://authentik.cellmechanic.com"
disable_providers = "Odoo.com"
group_claim = "groups"

[contexts.cm.instances.local]
install_modules_add = [
"fishbowl_import",
Expand All @@ -67,29 +59,10 @@ COMPOSE_BUILD_TARGET = "development"
[contexts.cm.instances.local.odoo_overrides.config_parameters]
"web.base.url" = "https://cm-local.shinycomputers.com"

[contexts.cm.instances.dev.runtime_env]
ODOO_WORKERS = 4
COMPOSE_BUILD_TARGET = "production"

[contexts.cm.instances.dev.odoo_overrides.config_parameters]
"web.base.url" = "https://cm-dev.shinycomputers.com"

[contexts.cm.instances.testing.runtime_env]
ODOO_WORKERS = 4
COMPOSE_BUILD_TARGET = "production"

[contexts.cm.instances.testing.odoo_overrides.config_parameters]
"web.base.url" = "https://cm-testing.shinycomputers.com"

[contexts.cm.instances.prod.runtime_env]
ODOO_WORKERS = 6
POSTGRES_SHARED_BUFFERS = "2GB"
POSTGRES_EFFECTIVE_CACHE_SIZE = "6GB"
POSTGRES_WORK_MEM = "64MB"
COMPOSE_BUILD_TARGET = "production"

[contexts.cm.instances.prod.odoo_overrides.config_parameters]
"web.base.url" = "https://odoo.cellmechanic.com"
[contexts.cm.instances.local.odoo_overrides.addon_settings.authentik_sso]
base_url = "https://authentik.cellmechanic.com"
disable_providers = "Odoo.com"
group_claim = "groups"

[contexts.cm.instances.dev]
install_modules_add = [
Expand All @@ -109,12 +82,6 @@ install_modules = [
"opw_custom",
]

[contexts.opw.runtime_env]
ODOO_DEV_MODE = ""
OPENUPGRADE_ENABLED = true
OPENUPGRADE_ADDON_REPOSITORY = "OCA/OpenUpgrade@89e649728027a8ab656b3aa4be18f4bd364db417"
OPENUPGRADELIB_INSTALL_SPEC = "git+https://github.com/OCA/openupgradelib.git@46d66ff5ed6a99481f84d3c79fc6e50835da7286"

[contexts.opw.instances.local]

[contexts.opw.instances.local.runtime_env]
Expand All @@ -123,34 +90,9 @@ ODOO_WORKERS = 2
ODOO_LIMIT_MEMORY_SOFT = 2147483648
ODOO_LIMIT_MEMORY_HARD = 3221225472
COMPOSE_BUILD_TARGET = "development"
OPENUPGRADE_ENABLED = true
OPENUPGRADE_ADDON_REPOSITORY = "OCA/OpenUpgrade@89e649728027a8ab656b3aa4be18f4bd364db417"
OPENUPGRADELIB_INSTALL_SPEC = "git+https://github.com/OCA/openupgradelib.git@46d66ff5ed6a99481f84d3c79fc6e50835da7286"

[contexts.opw.instances.local.odoo_overrides.config_parameters]
"web.base.url" = "https://opw-local.shinycomputers.com"

[contexts.opw.instances.dev]

[contexts.opw.instances.dev.runtime_env]
ODOO_WORKERS = 4
COMPOSE_BUILD_TARGET = "production"

[contexts.opw.instances.dev.odoo_overrides.config_parameters]
"web.base.url" = "https://opw-dev.shinycomputers.com"

[contexts.opw.instances.testing]

[contexts.opw.instances.testing.runtime_env]
ODOO_WORKERS = 4
COMPOSE_BUILD_TARGET = "production"

[contexts.opw.instances.testing.odoo_overrides.config_parameters]
"web.base.url" = "https://opw-testing.shinycomputers.com"

[contexts.opw.instances.prod.runtime_env]
ODOO_WORKERS = 6
POSTGRES_SHARED_BUFFERS = "2GB"
POSTGRES_EFFECTIVE_CACHE_SIZE = "6GB"
POSTGRES_WORK_MEM = "64MB"
COMPOSE_BUILD_TARGET = "production"

[contexts.opw.instances.prod.odoo_overrides.config_parameters]
"web.base.url" = "https://opw-prod.shinycomputers.com"
Loading