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
19 changes: 19 additions & 0 deletions projects/openshell-middleware-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ cargo run --locked -- 127.0.0.1:50051
The output path must not exist. Pin an OpenShell tag when you need repeatable
builds. Use `--openshell-version latest` when you want the newest release.

The starter templates still use the older `max_body_bytes` binding field, so
`omm create` does not yet support v0.0.116. Validation stops without publishing
that starter. `omm update` can refresh an existing service already compatible
with the newer contract, such as Egress Gate.

Run `omm --help` for all options. By default, `omm` derives the Python package
name from the project name. Use `--package-name` to set it yourself.

Expand All @@ -94,6 +99,20 @@ regenerates Python protobuf and gRPC bindings when needed, updates `uv.lock` or
`Cargo.lock`, and writes the version and protocol checksum to the manifest.
The manifest must name `openshell-middleware-manager` as its generator.

Python updates run `uv sync` and then `uv run pytest` by default. If a project
needs additional build steps, supply its normal validation command:

```sh
omm update /path/to/egress-gate \
--openshell-version v0.0.116 --check-command 'make check'
```

The command runs in the staged project with its isolated Python environment,
after binding generation and dependency sync. Arguments are split with shell
quoting rules, but no shell is invoked; use a project script for pipelines or
multiple commands. A failure prevents publication. The protobuf compiler runs
in a separate environment from the project's runtime dependencies.

## What you get

Each project contains:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import shlex
from enum import Enum
from pathlib import Path
from typing import Annotated
Expand Down Expand Up @@ -101,12 +102,24 @@ def update(
help="OpenShell release tag (for example v0.0.86), or latest.",
),
] = "latest",
check_command: Annotated[
str | None,
typer.Option(
"--check-command",
help="Python project validation command instead of pytest (no shell expansion).",
),
] = None,
) -> None:
"""Update an existing middleware project's OpenShell contract and generated files."""
try:
try:
command = shlex.split(check_command) if check_command is not None else None
except ValueError as error:
raise ProjectError(f"invalid check command: {error}") from error
result = update_project(
project_dir=project,
requested_version=openshell_version,
check_command=command,
)
except ProjectError as error:
_report_error(error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import partial
from importlib.resources import files
from pathlib import Path

Expand Down Expand Up @@ -266,6 +267,7 @@ def update_project(
*,
project_dir: Path,
requested_version: str = "latest",
check_command: Sequence[str] | None = None,
download_proto: DownloadProto | None = None,
command_runner: CommandRunner | None = None,
) -> ProjectResult:
Expand All @@ -276,11 +278,19 @@ def update_project(
project_dir = project_dir.resolve()
project_stat = project_dir.stat(follow_symlinks=False)
metadata = _read_project_metadata(project_dir)
if check_command is not None and (not check_command or metadata.language != "python"):
raise ProjectError(
"check command must be non-empty and is supported for Python projects only"
)
if command_runner is None:
_preflight_language(metadata.language)
version = _normalize_version(requested_version)
downloader = download_proto if download_proto is not None else _download_proto
runner = command_runner if command_runner is not None else _prepare_project
runner = (
command_runner
if command_runner is not None
else partial(_prepare_project, check_command=check_command)
)

legacy_reservation, reservation = _acquire_output_locks(project_dir, version)
staging_path: Path | None = None
Expand Down Expand Up @@ -1066,9 +1076,15 @@ def _write_manifest(
(project_dir / _MANIFEST_FILENAME).write_text(json.dumps(manifest, indent=2) + "\n")


def _prepare_project(language: str, project_dir: Path, package_name: str) -> None:
def _prepare_project(
language: str,
project_dir: Path,
package_name: str,
*,
check_command: Sequence[str] | None = None,
) -> None:
if language == "python":
_prepare_python_project(project_dir, package_name)
_prepare_python_project(project_dir, package_name, check_command=check_command)
else:
_prepare_rust_project(project_dir)

Expand Down Expand Up @@ -1103,7 +1119,9 @@ def _run(
) from error


def _prepare_python_project(project_dir: Path, package_name: str) -> None:
def _prepare_python_project(
project_dir: Path, package_name: str, *, check_command: Sequence[str] | None = None
) -> None:
uv = _require_command("uv")
bindings_dir = project_dir / "src" / package_name / "bindings"
proto_path = project_dir / "proto" / "supervisor_middleware.proto"
Expand Down Expand Up @@ -1151,7 +1169,7 @@ def _prepare_python_project(project_dir: Path, package_name: str) -> None:
"run",
"--project",
str(project_dir),
"pytest",
*(check_command if check_command is not None else ("pytest",)),
),
cwd=project_dir,
environment=process_environment,
Expand Down
12 changes: 11 additions & 1 deletion projects/openshell-middleware-manager/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from pathlib import Path

import pytest
from typer.testing import CliRunner

from openshell_middleware_manager import cli
Expand Down Expand Up @@ -85,13 +86,15 @@ def fake_create_project(**options):
assert "omm: error: output exists" in result.stderr


def test_cli_reports_update_success(monkeypatch, tmp_path: Path) -> None:
@pytest.mark.parametrize("check_command", [None, 'python "scripts/check project.py"'])
def test_cli_reports_update_success(monkeypatch, tmp_path: Path, check_command: str | None) -> None:
destination = tmp_path / "audit"

def fake_update_project(**options):
assert options == {
"project_dir": destination,
"requested_version": "v1.2.3",
"check_command": ["python", "scripts/check project.py"] if check_command else None,
}
return ProjectResult(
destination=destination,
Expand All @@ -109,6 +112,7 @@ def fake_update_project(**options):
str(destination),
"--openshell-version",
"v1.2.3",
*(["--check-command", check_command] if check_command else []),
],
)

Expand All @@ -117,6 +121,12 @@ def fake_update_project(**options):
assert "OpenShell contract: v1.2.3" in result.stdout


def test_cli_rejects_malformed_check_command() -> None:
result = runner.invoke(cli.app, ["update", "--check-command", 'python "unfinished'])
assert result.exit_code == 1
assert "invalid check command" in result.stderr


def test_cli_reports_update_error(monkeypatch, tmp_path: Path) -> None:
def fake_update_project(**options):
del options
Expand Down
65 changes: 60 additions & 5 deletions projects/openshell-middleware-manager/tests/test_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,51 @@ def updated_proto(version: str) -> tuple[bytes, str]:
assert not list(tmp_path.glob(".audit-headers.openshell-middleware-manager.*"))


@pytest.mark.parametrize("fails", [False, True])
def test_update_custom_check_runs_before_publication(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, fails: bool
) -> None:
destination = tmp_path / "audit"
create_project(
name="audit",
language="python",
requested_version="v0.0.86",
destination=destination,
download_proto=local_proto,
command_runner=no_op_runner,
)
original = (destination / ".openshell-middleware-manifest.json").read_bytes()

def check(language, project, package, *, check_command) -> None:
assert project != destination
assert check_command == ("make", "check")
assert (destination / ".openshell-middleware-manifest.json").read_bytes() == original
no_op_runner(language, project, package)
if fails:
raise ProjectError("custom check failed")

monkeypatch.setattr(generator, "_preflight_language", lambda language: None)
monkeypatch.setattr(generator, "_prepare_project", check)

def update() -> None:
update_project(
project_dir=destination,
requested_version="v1.2.3",
download_proto=local_proto,
check_command=("make", "check"),
)

if fails:
with pytest.raises(ProjectError, match="custom check failed"):
update()
assert (destination / ".openshell-middleware-manifest.json").read_bytes() == original
else:
update()
assert (destination / ".openshell-middleware-manifest.json").read_bytes() != original
with pytest.raises(ProjectError, match="non-empty"):
update_project(project_dir=destination, check_command=())


def test_failed_update_keeps_original_project_unchanged(tmp_path: Path) -> None:
destination = tmp_path / "audit"
create_project(
Expand Down Expand Up @@ -1230,7 +1275,7 @@ def test_prepare_project_dispatches_by_language(
monkeypatch.setattr(
generator,
"_prepare_python_project",
lambda path, package: calls.append(("python", path, package)),
lambda path, package, *, check_command: calls.append(("python", path, package)),
)
monkeypatch.setattr(
generator,
Expand Down Expand Up @@ -1268,8 +1313,9 @@ def test_run_passes_environment_to_subprocess(tmp_path: Path) -> None:
)


@pytest.mark.parametrize("check_command", [None, ("make", "check")])
def test_prepare_python_generates_relative_import_and_smoke_checks(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, check_command: tuple[str, ...] | None
) -> None:
bindings = tmp_path / "src" / "audit_headers" / "bindings"
bindings.mkdir(parents=True)
Expand All @@ -1280,7 +1326,10 @@ def test_prepare_python_generates_relative_import_and_smoke_checks(
monkeypatch.setattr(generator, "_require_command", lambda command: f"/tools/{command}")

def fake_run(command, *, cwd, environment=None) -> None:
del cwd, environment
assert cwd == tmp_path
if "grpc_tools.protoc" not in command:
assert environment is not None
assert "UV_PROJECT_ENVIRONMENT" in environment
calls.append(tuple(command))
if "grpc_tools.protoc" in command:
(bindings / "supervisor_middleware_pb2_grpc.py").write_text(
Expand All @@ -1289,13 +1338,19 @@ def fake_run(command, *, cwd, environment=None) -> None:

monkeypatch.setattr(generator, "_run", fake_run)

generator._prepare_python_project(tmp_path, "audit_headers")
generator._prepare_python_project(tmp_path, "audit_headers", check_command=check_command)

generated = (bindings / "supervisor_middleware_pb2_grpc.py").read_text()
assert generated.startswith("from . import supervisor_middleware_pb2")
assert len(calls) == 3
assert calls[1][1] == "sync"
assert calls[2][-1] == "pytest"
assert calls[2] == (
"/tools/uv",
"run",
"--project",
str(tmp_path),
*(check_command or ("pytest",)),
)


def test_prepare_python_rejects_unexpected_generated_import(
Expand Down
Loading