refactor: Run python services in place via uv run
A python-runner service ran from a tool venv (uv tool install --editable), separate from the project venv that uv sync manages. The editable link kept source live, but dependencies were resolved independently in each venv, so a new dependency landed in the project venv and never reached the running service — leaving no clean rollout for it. Deploy now emits ExecStart=uv run --project <source> --no-dev <program>, running the service in place from its own project venv. uv run syncs the env to the lockfile before launching, so a single restart picks up both code and dependency changes; the ExecStart is deterministic from source and never goes stale. The tool-venv install is gated to the command runner only; uv tool install is reserved for tool-behavior programs, where PATH is the point. A python service with no resolvable source falls back to a PATH lookup.
This commit is contained in:
@@ -200,11 +200,15 @@ def _build_deployed_service(
|
|||||||
env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {}
|
env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {}
|
||||||
env = resolve_env_vars(env, _env_context(name, config_key, port))
|
env = resolve_env_vars(env, _env_context(name, config_key, port))
|
||||||
|
|
||||||
# Ensure python tool is installed before resolving binary
|
# `command`-runner services resolve a tool on PATH → ensure it's installed.
|
||||||
|
# `python`-runner services run in place via `uv run` (below) — no tool venv.
|
||||||
|
if run.runner == "command":
|
||||||
_ensure_python_tool(config, svc.program, messages)
|
_ensure_python_tool(config, svc.program, messages)
|
||||||
|
|
||||||
# Build run_cmd
|
# Build run_cmd
|
||||||
run_cmd = _build_run_cmd(name, run, env, messages)
|
run_cmd = _build_run_cmd(
|
||||||
|
name, run, env, messages, _program_source_dir(config, svc.program)
|
||||||
|
)
|
||||||
|
|
||||||
# Proxy: a path prefix (handle_path on the gateway) and/or a hostname (a
|
# Proxy: a path prefix (handle_path on the gateway) and/or a hostname (a
|
||||||
# dedicated host site block, so a root-based app serves unchanged).
|
# dedicated host site block, so a root-based app serves unchanged).
|
||||||
@@ -253,8 +257,11 @@ def _build_deployed_job(
|
|||||||
config_key = job.program or name
|
config_key = job.program or name
|
||||||
env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {}
|
env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {}
|
||||||
env = resolve_env_vars(env, _env_context(name, config_key, None))
|
env = resolve_env_vars(env, _env_context(name, config_key, None))
|
||||||
|
if run.runner == "command":
|
||||||
_ensure_python_tool(config, job.program, messages)
|
_ensure_python_tool(config, job.program, messages)
|
||||||
run_cmd = _build_run_cmd(name, run, env, messages)
|
run_cmd = _build_run_cmd(
|
||||||
|
name, run, env, messages, _program_source_dir(config, job.program)
|
||||||
|
)
|
||||||
|
|
||||||
stack = None
|
stack = None
|
||||||
if job.program and job.program in config.programs:
|
if job.program and job.program in config.programs:
|
||||||
@@ -293,10 +300,25 @@ def _python_tool_needs_install(program: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _program_source_dir(config: CastleConfig, program: str | None) -> Path | None:
|
||||||
|
"""The absolute source dir of a referenced program, if any.
|
||||||
|
|
||||||
|
`load_config` has already resolved `source` to an absolute path (repo: and
|
||||||
|
relative forms included), so this is a plain lookup."""
|
||||||
|
if program and program in config.programs:
|
||||||
|
src = config.programs[program].source
|
||||||
|
if src:
|
||||||
|
return Path(src)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _ensure_python_tool(
|
def _ensure_python_tool(
|
||||||
config: CastleConfig, program: str | None, messages: list[str]
|
config: CastleConfig, program: str | None, messages: list[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Ensure a Python program's editable install is current."""
|
"""Ensure a Python program's editable install is current.
|
||||||
|
|
||||||
|
Only the `command` runner needs this — it resolves a tool on PATH. The
|
||||||
|
`python` runner runs in place via `uv run` and never touches a tool venv."""
|
||||||
if not program or program not in config.programs:
|
if not program or program not in config.programs:
|
||||||
return
|
return
|
||||||
comp = config.programs[program]
|
comp = config.programs[program]
|
||||||
@@ -323,15 +345,31 @@ def _ensure_python_tool(
|
|||||||
messages.append(f"Installed {program}")
|
messages.append(f"Installed {program}")
|
||||||
|
|
||||||
|
|
||||||
def _build_run_cmd(name: str, run: object, env: dict[str, str], messages: list[str]) -> list[str]:
|
def _build_run_cmd(
|
||||||
|
name: str,
|
||||||
|
run: object,
|
||||||
|
env: dict[str, str],
|
||||||
|
messages: list[str],
|
||||||
|
source_dir: Path | None = None,
|
||||||
|
) -> list[str]:
|
||||||
"""Build a run command list from a RunSpec."""
|
"""Build a run command list from a RunSpec."""
|
||||||
match run.runner: # type: ignore[union-attr]
|
match run.runner: # type: ignore[union-attr]
|
||||||
case "python":
|
case "python":
|
||||||
|
# Run the program in place from its own project venv via `uv run`,
|
||||||
|
# which syncs the env to the lockfile before launching. One venv per
|
||||||
|
# program (no separate tool venv); restart picks up both code and
|
||||||
|
# dependency changes. Falls back to a PATH lookup only when there's no
|
||||||
|
# resolvable source (a service that declares run.program without a
|
||||||
|
# catalog program).
|
||||||
|
if source_dir and source_dir.is_dir():
|
||||||
|
uv = shutil.which("uv") or "uv"
|
||||||
|
cmd = [uv, "run", "--project", str(source_dir), "--no-dev", run.program] # type: ignore[union-attr]
|
||||||
|
else:
|
||||||
resolved = shutil.which(run.program) # type: ignore[union-attr]
|
resolved = shutil.which(run.program) # type: ignore[union-attr]
|
||||||
if not resolved:
|
if not resolved:
|
||||||
messages.append(
|
messages.append(
|
||||||
f"Warning: '{run.program}' not on PATH. " # type: ignore[union-attr]
|
f"Warning: '{run.program}' has no source dir and is not on " # type: ignore[union-attr]
|
||||||
f"Install with: uv tool install --editable <source>"
|
f"PATH. Declare a program source, or install it."
|
||||||
)
|
)
|
||||||
cmd = [resolved or run.program] # type: ignore[union-attr]
|
cmd = [resolved or run.program] # type: ignore[union-attr]
|
||||||
if run.args: # type: ignore[union-attr]
|
if run.args: # type: ignore[union-attr]
|
||||||
|
|||||||
50
core/tests/test_deploy_run_cmd.py
Normal file
50
core/tests/test_deploy_run_cmd.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Tests for `_build_run_cmd` — the python runner runs in place via `uv run`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from castle_core.deploy import _build_run_cmd
|
||||||
|
from castle_core.manifest import RunPython
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
|
||||||
|
"""A python service with a real source dir launches via `uv run --project`."""
|
||||||
|
run = RunPython(runner="python", program="my-svc")
|
||||||
|
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
|
||||||
|
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
|
||||||
|
assert cmd == [
|
||||||
|
"/usr/bin/uv",
|
||||||
|
"run",
|
||||||
|
"--project",
|
||||||
|
str(tmp_path),
|
||||||
|
"--no-dev",
|
||||||
|
"my-svc",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_runner_appends_args(tmp_path: Path) -> None:
|
||||||
|
run = RunPython(runner="python", program="my-svc", args=["--flag", "x"])
|
||||||
|
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
|
||||||
|
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
|
||||||
|
assert cmd[-2:] == ["--flag", "x"]
|
||||||
|
assert cmd[:5] == ["/usr/bin/uv", "run", "--project", str(tmp_path), "--no-dev"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_runner_falls_back_to_path_without_source() -> None:
|
||||||
|
"""No resolvable source → PATH lookup of the script (no uv run)."""
|
||||||
|
run = RunPython(runner="python", program="my-svc")
|
||||||
|
with patch("castle_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc"):
|
||||||
|
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=None)
|
||||||
|
assert cmd == ["/home/u/.local/bin/my-svc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_runner_warns_when_unresolvable() -> None:
|
||||||
|
"""No source and not on PATH → a warning, bare program name as last resort."""
|
||||||
|
run = RunPython(runner="python", program="my-svc")
|
||||||
|
messages: list[str] = []
|
||||||
|
with patch("castle_core.deploy.shutil.which", return_value=None):
|
||||||
|
cmd = _build_run_cmd("my-svc", run, {}, messages, source_dir=None)
|
||||||
|
assert cmd == ["my-svc"]
|
||||||
|
assert any("my-svc" in m for m in messages)
|
||||||
@@ -199,12 +199,21 @@ Discriminated union on `runner`:
|
|||||||
|
|
||||||
| Runner | Sync | Deploy | Key fields |
|
| Runner | Sync | Deploy | Key fields |
|
||||||
|--------|------|--------|------------|
|
|--------|------|--------|------------|
|
||||||
| `python` | `uv sync` | `which(program)` → installed binary | `program`, `args` |
|
| `python` | *(none — `uv run` self-syncs)* | `uv run --project <source> --no-dev <program>` | `program`, `args` |
|
||||||
| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` |
|
| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` |
|
||||||
| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
|
| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
|
||||||
| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
|
| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
|
||||||
| `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` |
|
| `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` |
|
||||||
|
|
||||||
|
A `python` service runs **in place from its own project venv** via `uv run`, which
|
||||||
|
syncs the env to the project's lockfile before launching. There is no separate
|
||||||
|
tool venv and no `uv tool install` step: **a restart picks up both code and
|
||||||
|
dependency changes** (the deploy-time `ExecStart` is deterministic from `source`,
|
||||||
|
so it never goes stale). `uv tool install` is reserved for `tool`-behavior
|
||||||
|
programs, where being on a human's PATH is the point. If a `python` service
|
||||||
|
declares a `program` with no resolvable `source`, deploy falls back to a PATH
|
||||||
|
lookup of the script.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
run:
|
run:
|
||||||
runner: python
|
runner: python
|
||||||
|
|||||||
Reference in New Issue
Block a user