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 = resolve_env_vars(env, _env_context(name, config_key, port))
|
||||
|
||||
# Ensure python tool is installed before resolving binary
|
||||
_ensure_python_tool(config, svc.program, messages)
|
||||
# `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)
|
||||
|
||||
# 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
|
||||
# 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
|
||||
env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {}
|
||||
env = resolve_env_vars(env, _env_context(name, config_key, None))
|
||||
_ensure_python_tool(config, job.program, messages)
|
||||
run_cmd = _build_run_cmd(name, run, env, messages)
|
||||
if run.runner == "command":
|
||||
_ensure_python_tool(config, job.program, messages)
|
||||
run_cmd = _build_run_cmd(
|
||||
name, run, env, messages, _program_source_dir(config, job.program)
|
||||
)
|
||||
|
||||
stack = None
|
||||
if job.program and job.program in config.programs:
|
||||
@@ -293,10 +300,25 @@ def _python_tool_needs_install(program: str) -> bool:
|
||||
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(
|
||||
config: CastleConfig, program: str | None, messages: list[str]
|
||||
) -> 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:
|
||||
return
|
||||
comp = config.programs[program]
|
||||
@@ -323,17 +345,33 @@ def _ensure_python_tool(
|
||||
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."""
|
||||
match run.runner: # type: ignore[union-attr]
|
||||
case "python":
|
||||
resolved = shutil.which(run.program) # type: ignore[union-attr]
|
||||
if not resolved:
|
||||
messages.append(
|
||||
f"Warning: '{run.program}' not on PATH. " # type: ignore[union-attr]
|
||||
f"Install with: uv tool install --editable <source>"
|
||||
)
|
||||
cmd = [resolved or run.program] # type: ignore[union-attr]
|
||||
# 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]
|
||||
if not resolved:
|
||||
messages.append(
|
||||
f"Warning: '{run.program}' has no source dir and is not on " # type: ignore[union-attr]
|
||||
f"PATH. Declare a program source, or install it."
|
||||
)
|
||||
cmd = [resolved or run.program] # type: ignore[union-attr]
|
||||
if run.args: # type: ignore[union-attr]
|
||||
cmd.extend(run.args) # type: ignore[union-attr]
|
||||
return cmd
|
||||
|
||||
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 |
|
||||
|--------|------|--------|------------|
|
||||
| `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` |
|
||||
| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
|
||||
| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
|
||||
| `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
|
||||
run:
|
||||
runner: python
|
||||
|
||||
Reference in New Issue
Block a user