Skip to content

Commit 2b2f7b6

Browse files
committed
Address middleware initializer review findings
1 parent ab42064 commit 2b2f7b6

10 files changed

Lines changed: 490 additions & 51 deletions

File tree

projects/openshell-middleware-init/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,27 @@ Unlike the original `middleware_dev_setup` spike, this project initializer does
7575
not install or replace OpenShell. Install the desired OpenShell release through
7676
its official installer separately.
7777

78+
### Recover a stale reservation
79+
80+
A process killed without cleanup can leave
81+
`.<output>.openshell-middleware-init.lock` and its hidden staging directory. The
82+
initializer deliberately leaves ambiguous state in place instead of guessing
83+
that it is stale.
84+
85+
1. Read the reservation's `metadata.json`. It records the hostname, PID, start
86+
time, target version, final output, and staging output.
87+
2. On the recorded host, confirm that the PID is no longer an
88+
`openshell-middleware-init` process. Account for PID reuse by comparing the
89+
process start time and command. Confirm that the final output still does not
90+
exist.
91+
3. Inspect the recorded staging directory and preserve anything needed for
92+
diagnosis. Remove it only after confirming the initializer is no longer
93+
active.
94+
4. Remove only `owner` and `metadata.json` from the reservation, then remove the
95+
empty reservation directory with `rmdir`. If it contains any other entry,
96+
stop and investigate rather than deleting recursively.
97+
5. Run the initializer again.
98+
7899
## Requirements
79100

80101
- All generation: network access to GitHub and the selected OpenShell release.

projects/openshell-middleware-init/src/openshell_middleware_init/generator.py

Lines changed: 155 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,25 @@
22

33
from __future__ import annotations
44

5+
import ctypes
6+
import errno
57
import hashlib
68
import json
79
import os
810
import re
911
import secrets
1012
import shutil
13+
import socket
14+
import stat
1115
import subprocess
16+
import sys
1217
import tempfile
1318
import urllib.error
1419
import urllib.request
1520
from collections.abc import Callable, Mapping, Sequence
21+
from contextlib import suppress
1622
from dataclasses import dataclass
23+
from datetime import datetime, timezone
1724
from importlib.resources import files
1825
from pathlib import Path
1926

@@ -42,6 +49,19 @@ class InitializationResult:
4249
run_command: str
4350

4451

52+
@dataclass(frozen=True)
53+
class OutputReservation:
54+
"""Identity and recovery data for an output-path reservation."""
55+
56+
path: Path
57+
token: str
58+
device: int
59+
inode: int
60+
destination: Path
61+
version: str
62+
started_at: str
63+
64+
4565
@dataclass(frozen=True)
4666
class TemplateContext:
4767
"""Normalized names used while rendering a project."""
@@ -88,7 +108,7 @@ def initialize_project(
88108
destination.parent.mkdir(parents=True, exist_ok=True)
89109
lock_path = destination.parent / f".{destination.name}.openshell-middleware-init.lock"
90110
lock_token = secrets.token_hex(16)
91-
_acquire_lock(lock_path, lock_token, destination, version)
111+
reservation = _acquire_lock(lock_path, lock_token, destination, version)
92112
staging_path: Path | None = None
93113
try:
94114
_validate_destination(destination)
@@ -98,6 +118,7 @@ def initialize_project(
98118
dir=destination.parent,
99119
)
100120
)
121+
_write_reservation_metadata(reservation, staging_path)
101122
proto, proto_url = downloader(version)
102123
_validate_proto(proto, version)
103124
_render_project(staging_path, language, context)
@@ -113,9 +134,8 @@ def initialize_project(
113134
python_package=context.package_name if language == "python" else None,
114135
)
115136
runner(language, staging_path, context.package_name)
116-
_verify_lock(lock_path, lock_token)
117-
_validate_destination(destination)
118-
staging_path.replace(destination)
137+
_verify_lock(reservation)
138+
_publish_no_replace(staging_path, destination)
119139
staging_path = None
120140
except InitializationError:
121141
raise
@@ -124,7 +144,7 @@ def initialize_project(
124144
finally:
125145
if staging_path is not None:
126146
shutil.rmtree(staging_path, ignore_errors=True)
127-
_release_lock(lock_path, lock_token)
147+
_release_lock(reservation)
128148

129149
return InitializationResult(
130150
destination=destination,
@@ -133,7 +153,7 @@ def initialize_project(
133153
run_command=(
134154
f"uv run {context.distribution_name}"
135155
if language == "python"
136-
else "cargo run -- 127.0.0.1:50051"
156+
else "cargo run -- 0.0.0.0:50051"
137157
),
138158
)
139159

@@ -235,48 +255,156 @@ def _validate_destination(destination: Path) -> None:
235255
raise InitializationError(f"invalid output path: {destination}")
236256

237257

238-
def _acquire_lock(lock_path: Path, token: str, destination: Path, version: str) -> None:
258+
def _acquire_lock(
259+
lock_path: Path, token: str, destination: Path, version: str
260+
) -> OutputReservation:
239261
try:
240-
lock_path.mkdir()
262+
lock_path.mkdir(mode=0o700)
241263
except FileExistsError as error:
242264
raise InitializationError(
243265
f"output path is reserved by another initializer: {destination}; "
244-
f"inspect {lock_path} before removing a stale reservation"
266+
f"inspect {lock_path / 'metadata.json'} and follow the stale-reservation "
267+
"recovery steps in the openshell-middleware-init README"
245268
) from error
269+
lock_stat = lock_path.stat(follow_symlinks=False)
270+
reservation = OutputReservation(
271+
path=lock_path,
272+
token=token,
273+
device=lock_stat.st_dev,
274+
inode=lock_stat.st_ino,
275+
destination=destination,
276+
version=version,
277+
started_at=datetime.now(timezone.utc).isoformat(),
278+
)
246279
try:
247280
(lock_path / "owner").write_text(token)
248-
(lock_path / "metadata.json").write_text(
249-
json.dumps(
250-
{
251-
"pid": os.getpid(),
252-
"target_version": version,
253-
"final_output": str(destination),
254-
},
255-
indent=2,
256-
)
257-
+ "\n"
258-
)
281+
_write_reservation_metadata(reservation, None)
259282
except OSError:
260-
shutil.rmtree(lock_path, ignore_errors=True)
283+
_remove_reservation_files(lock_path)
261284
raise
285+
return reservation
286+
287+
288+
def _write_reservation_metadata(reservation: OutputReservation, staging_path: Path | None) -> None:
289+
(reservation.path / "metadata.json").write_text(
290+
json.dumps(
291+
{
292+
"pid": os.getpid(),
293+
"host": socket.gethostname(),
294+
"started_at": reservation.started_at,
295+
"target_version": reservation.version,
296+
"final_output": str(reservation.destination),
297+
"staging_output": str(staging_path) if staging_path is not None else None,
298+
},
299+
indent=2,
300+
)
301+
+ "\n"
302+
)
262303

263304

264-
def _verify_lock(lock_path: Path, token: str) -> None:
305+
def _verify_lock(reservation: OutputReservation) -> None:
265306
try:
266-
recorded = (lock_path / "owner").read_text()
307+
lock_stat = reservation.path.stat(follow_symlinks=False)
308+
if (
309+
not stat.S_ISDIR(lock_stat.st_mode)
310+
or lock_stat.st_dev != reservation.device
311+
or lock_stat.st_ino != reservation.inode
312+
):
313+
raise OSError("reservation identity changed")
314+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
315+
descriptor = os.open(reservation.path / "owner", flags)
316+
with os.fdopen(descriptor) as owner:
317+
owner_stat = os.fstat(owner.fileno())
318+
if not stat.S_ISREG(owner_stat.st_mode):
319+
raise OSError("reservation owner is not a regular file")
320+
recorded = owner.read()
267321
except OSError as error:
268322
raise InitializationError("output reservation was lost; refusing to publish") from error
269-
if recorded != token:
323+
if recorded != reservation.token:
270324
raise InitializationError("output reservation ownership changed; refusing to publish")
271325

272326

273-
def _release_lock(lock_path: Path, token: str) -> None:
327+
def _remove_reservation_files(lock_path: Path) -> None:
328+
known_names = {"owner", "metadata.json"}
274329
try:
275-
if (lock_path / "owner").read_text() != token:
330+
lock_stat = lock_path.stat(follow_symlinks=False)
331+
if not stat.S_ISDIR(lock_stat.st_mode):
332+
return
333+
if {entry.name for entry in lock_path.iterdir()} - known_names:
276334
return
277335
except OSError:
278336
return
279-
shutil.rmtree(lock_path, ignore_errors=True)
337+
for name in known_names:
338+
try:
339+
(lock_path / name).unlink()
340+
except FileNotFoundError:
341+
pass
342+
except OSError:
343+
return
344+
with suppress(OSError):
345+
lock_path.rmdir()
346+
347+
348+
def _release_lock(reservation: OutputReservation) -> None:
349+
try:
350+
_verify_lock(reservation)
351+
except InitializationError:
352+
return
353+
_remove_reservation_files(reservation.path)
354+
355+
356+
def _publish_no_replace(source: Path, destination: Path) -> None:
357+
"""Atomically publish ``source`` without replacing any destination entry."""
358+
source_bytes = os.fsencode(source)
359+
destination_bytes = os.fsencode(destination)
360+
if sys.platform.startswith("linux"):
361+
library = ctypes.CDLL(None, use_errno=True)
362+
try:
363+
rename = library.renameat2
364+
except AttributeError as error: # pragma: no cover - old Linux libc
365+
raise InitializationError(
366+
"this Linux runtime cannot publish atomically without replacing an output"
367+
) from error
368+
rename.argtypes = (
369+
ctypes.c_int,
370+
ctypes.c_char_p,
371+
ctypes.c_int,
372+
ctypes.c_char_p,
373+
ctypes.c_uint,
374+
)
375+
rename.restype = ctypes.c_int
376+
result = rename(-100, source_bytes, -100, destination_bytes, 1)
377+
elif sys.platform == "darwin": # pragma: no cover - platform-specific
378+
library = ctypes.CDLL(None, use_errno=True)
379+
rename = library.renamex_np
380+
rename.argtypes = (ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint)
381+
rename.restype = ctypes.c_int
382+
result = rename(source_bytes, destination_bytes, 0x00000004)
383+
elif os.name == "nt": # pragma: no cover - platform-specific
384+
try:
385+
source.rename(destination)
386+
except FileExistsError as error:
387+
raise InitializationError(
388+
f"output path appeared during setup; refusing to overwrite it: {destination}"
389+
) from error
390+
return
391+
else: # pragma: no cover - unsupported platform
392+
raise InitializationError(
393+
"this platform cannot publish atomically without replacing an output"
394+
)
395+
396+
if result == 0:
397+
return
398+
error_number = ctypes.get_errno()
399+
if error_number in {errno.EEXIST, errno.ENOTEMPTY}:
400+
raise InitializationError(
401+
f"output path appeared during setup; refusing to overwrite it: {destination}"
402+
)
403+
if error_number in {errno.EINVAL, errno.ENOSYS, errno.EOPNOTSUPP}:
404+
raise InitializationError(
405+
"the output filesystem does not support atomic no-replace publication"
406+
)
407+
raise OSError(error_number, os.strerror(error_number), destination)
280408

281409

282410
def _render_project(destination: Path, language: str, context: TemplateContext) -> None:

projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ uv run pytest
1717
uv build
1818
```
1919

20-
Start the middleware on loopback:
20+
Start the middleware on all host interfaces so containerized supervisors can
21+
reach it:
2122

2223
```sh
23-
uv run __DISTRIBUTION_NAME__ --listen 127.0.0.1:50051
24+
uv run __DISTRIBUTION_NAME__ --listen 0.0.0.0:50051
2425
```
2526

2627
The server implementation is in `src/__PACKAGE_NAME__/server.py`. Extend
@@ -35,14 +36,17 @@ Register the running service in the gateway configuration:
3536
```toml
3637
[[openshell.supervisor.middleware]]
3738
name = "__SERVICE_NAME__"
38-
grpc_endpoint = "http://127.0.0.1:50051"
39+
grpc_endpoint = "http://<supervisor-reachable-host>:50051"
3940
max_body_bytes = 4194304
4041
timeout = "500ms"
4142
```
4243

43-
Then reference `__SERVICE_NAME__` from a sandbox policy's middleware stage.
44-
Review the supervisor middleware documentation for the policy syntax supported
45-
by your pinned OpenShell release.
44+
Replace `<supervisor-reachable-host>` with a host IP or DNS name reachable from
45+
both the gateway and sandbox supervisors; loopback works only when every process
46+
shares the middleware's network namespace. The development server is insecure,
47+
so restrict port exposure to trusted networks. Then reference `__SERVICE_NAME__`
48+
from a sandbox policy's middleware stage. Review the supervisor middleware
49+
documentation for the policy syntax supported by your pinned OpenShell release.
4650

4751
## Version-matched generated files
4852

projects/openshell-middleware-init/src/openshell_middleware_init/templates/python/src/package/server.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
SERVICE_NAME = "__SERVICE_NAME__"
1515
SERVICE_VERSION = "0.1.0"
1616
MAX_BODY_BYTES = 4 * 1024 * 1024
17+
MAX_MESSAGE_BYTES = MAX_BODY_BYTES + 1024 * 1024
1718

1819

1920
def build_manifest() -> pb2.MiddlewareManifest:
@@ -76,10 +77,21 @@ async def EvaluateHttpRequest(
7677
return evaluate_http_request(request)
7778

7879

80+
def create_server() -> grpc.aio.Server:
81+
"""Create an unstarted server that accepts a maximum-sized body envelope."""
82+
server = grpc.aio.server(
83+
options=(
84+
("grpc.max_receive_message_length", MAX_MESSAGE_BYTES),
85+
("grpc.max_send_message_length", MAX_MESSAGE_BYTES),
86+
)
87+
)
88+
pb2_grpc.add_SupervisorMiddlewareServicer_to_server(Middleware(), server)
89+
return server
90+
91+
7992
async def serve(listen: str) -> None:
8093
"""Serve the middleware until termination."""
81-
server = grpc.aio.server()
82-
pb2_grpc.add_SupervisorMiddlewareServicer_to_server(Middleware(), server)
94+
server = create_server()
8395
if server.add_insecure_port(listen) == 0:
8496
raise RuntimeError(f"could not bind middleware server to {listen}")
8597
await server.start()
@@ -92,7 +104,7 @@ async def serve(listen: str) -> None:
92104
def main(argv: Sequence[str] | None = None) -> None:
93105
"""Run the middleware server."""
94106
parser = argparse.ArgumentParser(description="Run the __PROJECT_NAME__ middleware")
95-
parser.add_argument("--listen", default="127.0.0.1:50051")
107+
parser.add_argument("--listen", default="0.0.0.0:50051")
96108
arguments = parser.parse_args(argv)
97109
asyncio.run(serve(arguments.listen))
98110

0 commit comments

Comments
 (0)