feat(harness): allocate canonical issue-scoped broker runtime
This commit is contained in:
@@ -17,6 +17,7 @@ if __package__ in (None, ""):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from harness.lib.managed_lock import LockTimeoutError, ManagedLockSet, probe_free
|
||||
from harness.lib.runtime_root import issue_runtime_path, prepare_issue_runtime
|
||||
|
||||
ROLES = {"gitea-impl-agent", "gitea-spec-agent", "gitea-review-agent"}
|
||||
MAX_TTL = 3600
|
||||
@@ -184,13 +185,18 @@ class Broker:
|
||||
raise ValueError("issue must be positive")
|
||||
if not 1 <= args.ttl_seconds <= MAX_TTL:
|
||||
raise ValueError("ttl_seconds out of bounds")
|
||||
runtime_path = issue_runtime_path(args.root, args.owner, args.repository, args.issue, args.run_id)
|
||||
self.root = _abs(args.root, "root")
|
||||
self.handle = _abs(args.handle, "handle")
|
||||
self.sock_path = _abs(args.socket, "socket")
|
||||
self.handle = runtime_path / "broker.handle"
|
||||
self.sock_path = self.handle.parent / "broker.sock"
|
||||
supplied_handle = getattr(args, "handle", None)
|
||||
supplied_socket = getattr(args, "socket", None)
|
||||
if supplied_handle is not None and os.fspath(supplied_handle) != str(self.handle):
|
||||
raise ValueError("handle path must be canonical")
|
||||
if supplied_socket is not None and os.fspath(supplied_socket) != str(self.sock_path):
|
||||
raise ValueError("socket path must be canonical")
|
||||
locks = self.root / "repositories" / args.owner / args.repository / "locks"
|
||||
_safe_dir(locks, "repository locks")
|
||||
_safe_dir(self.handle.parent, "handle")
|
||||
_safe_dir(self.sock_path.parent, "socket")
|
||||
self.token = secrets.token_hex(32)
|
||||
self.deadline = time.monotonic() + args.ttl_seconds
|
||||
self.set = ManagedLockSet.for_issue(self.root, args.owner, args.repository, args.issue, timeout=args.acquire_timeout)
|
||||
@@ -337,6 +343,14 @@ class Broker:
|
||||
signal.signal(signal.SIGTERM, lambda *_: setattr(self, "stopping", True))
|
||||
signal.signal(signal.SIGINT, lambda *_: setattr(self, "stopping", True))
|
||||
self.set.issue.write_metadata(self.meta)
|
||||
with self.set.repository_scope():
|
||||
runtime = prepare_issue_runtime(self.root, self.meta["owner"], self.meta["repository"],
|
||||
self.meta["issue"], self.meta["run_id"], lock_set=self.set)
|
||||
try:
|
||||
runtime_fd = runtime.fd
|
||||
self.socket_parent_fd = os.dup(runtime_fd)
|
||||
finally:
|
||||
os.close(runtime.fd)
|
||||
try:
|
||||
_write_handle(self.handle, self.state, exclusive=True,
|
||||
on_created=self._record_handle_identity)
|
||||
@@ -346,7 +360,9 @@ class Broker:
|
||||
if (st.st_dev, st.st_ino) != self.handle_identity:
|
||||
raise ValueError("created handle identity changed")
|
||||
self.handle_created = True
|
||||
self.socket_parent_fd, socket_name = _open_parent(self.sock_path)
|
||||
if self.socket_parent_fd is None:
|
||||
raise ValueError("runtime directory unavailable")
|
||||
socket_name = self.sock_path.name
|
||||
try:
|
||||
os.stat(socket_name, dir_fd=self.socket_parent_fd, follow_symlinks=False)
|
||||
raise ValueError("socket path already exists")
|
||||
@@ -404,13 +420,26 @@ def request_handle(handle_path: str | Path, action: str, ttl: float | None = Non
|
||||
handle = _abs(str(handle_path), "handle")
|
||||
data = _read_handle(handle)
|
||||
sock_path = _abs(data["socket"], "socket")
|
||||
req = {"action": action, "token": data["token"]}
|
||||
if ttl is not None:
|
||||
req["ttl_seconds"] = ttl
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(3); sock.connect(str(sock_path)); sock.sendall((json.dumps(req) + "\n").encode())
|
||||
with sock.makefile("rb") as response_file:
|
||||
response = json.loads(response_file.readline())
|
||||
parent_fd, name = _open_parent(sock_path)
|
||||
try:
|
||||
parent_st = os.fstat(parent_fd)
|
||||
if (parent_st.st_uid != os.getuid() or stat.S_IMODE(parent_st.st_mode) != 0o700):
|
||||
raise ValueError("unsafe socket parent")
|
||||
st = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
||||
if (not stat.S_ISSOCK(st.st_mode) or st.st_uid != os.getuid()
|
||||
or stat.S_IMODE(st.st_mode) != 0o600):
|
||||
raise ValueError("unsafe socket")
|
||||
req = {"action": action, "token": data["token"]}
|
||||
if ttl is not None:
|
||||
req["ttl_seconds"] = ttl
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(3)
|
||||
sock.connect(f"/proc/self/fd/{parent_fd}/{name}")
|
||||
sock.sendall((json.dumps(req) + "\n").encode())
|
||||
with sock.makefile("rb") as response_file:
|
||||
response = json.loads(response_file.readline())
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
if not isinstance(response, dict):
|
||||
raise ValueError("invalid broker response")
|
||||
return response
|
||||
@@ -444,7 +473,7 @@ def parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser()
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
s = sub.add_parser("serve")
|
||||
s.add_argument("--root", required=True); s.add_argument("--owner", required=True); s.add_argument("--repository", required=True); s.add_argument("--issue", required=True, type=int); s.add_argument("--role", required=True); s.add_argument("--run-id", required=True); s.add_argument("--handle", required=True); s.add_argument("--socket", required=True); s.add_argument("--ttl-seconds", required=True, type=float); s.add_argument("--acquire-timeout", type=float, default=0.0)
|
||||
s.add_argument("--root", required=True); s.add_argument("--owner", required=True); s.add_argument("--repository", required=True); s.add_argument("--issue", required=True, type=int); s.add_argument("--role", required=True); s.add_argument("--run-id", required=True); s.add_argument("--handle"); s.add_argument("--socket"); s.add_argument("--ttl-seconds", required=True, type=float); s.add_argument("--acquire-timeout", type=float, default=0.0)
|
||||
r = sub.add_parser("request")
|
||||
r.add_argument("--handle", required=True); r.add_argument("--action", required=True, choices=["status", "renew", "acquire_repository", "release_repository", "release"]); r.add_argument("--ttl-seconds", type=float)
|
||||
check = sub.add_parser("probe")
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Canonical, private Issue runtime allocation (not cache/worktree admission)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import grp
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in (None, ''):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from harness.lib.managed_lock import ManagedLockSet, validate_held_flock
|
||||
|
||||
_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,254}")
|
||||
_RUN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")
|
||||
_DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||
|
||||
|
||||
def _identity(root, owner, repository, issue, run_id=None):
|
||||
raw = os.fspath(root)
|
||||
if not isinstance(raw, str) or not raw.startswith('/') or '\x00' in raw:
|
||||
raise ValueError('invalid harness root')
|
||||
if any(part in {'', '.', '..'} for part in raw.split('/')[1:]):
|
||||
raise ValueError('invalid harness root')
|
||||
for value in (owner, repository):
|
||||
if not isinstance(value, str) or not _IDENTIFIER.fullmatch(value):
|
||||
raise ValueError('invalid repository identity')
|
||||
if isinstance(issue, bool) or not isinstance(issue, int) or issue <= 0:
|
||||
raise ValueError('invalid Issue')
|
||||
if run_id is not None and (not isinstance(run_id, str) or not _RUN.fullmatch(run_id)):
|
||||
raise ValueError('invalid workflow run id')
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def issue_runtime_path(root, owner, repository, issue, run_id):
|
||||
root = _identity(root, owner, repository, issue, run_id)
|
||||
return root / 'repositories' / owner / repository / 'worktrees' / f'issue-{issue}' / 'runtime' / run_id
|
||||
|
||||
|
||||
def _open_absolute(path):
|
||||
fd = os.open('/', _DIR_FLAGS)
|
||||
try:
|
||||
for part in path.parts[1:]:
|
||||
child = os.open(part, _DIR_FLAGS, dir_fd=fd)
|
||||
os.close(fd)
|
||||
fd = child
|
||||
return fd
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def _private(st):
|
||||
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) != 0o700:
|
||||
raise ValueError('unsafe private directory')
|
||||
|
||||
|
||||
def _repository_container(st):
|
||||
mode = stat.S_IMODE(st.st_mode)
|
||||
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or mode & 0o002:
|
||||
raise ValueError('unsafe repositories directory')
|
||||
if mode & 0o020:
|
||||
try:
|
||||
group = grp.getgrgid(st.st_gid)
|
||||
members = {pwd.getpwnam(name).pw_uid for name in group.gr_mem}
|
||||
members.update(user.pw_uid for user in pwd.getpwall() if user.pw_gid == st.st_gid)
|
||||
except KeyError as exc:
|
||||
raise ValueError('unresolved repositories group') from exc
|
||||
if members != {os.getuid()}:
|
||||
raise ValueError('unsafe repositories group')
|
||||
|
||||
|
||||
def _existing(parent, name, check=_private):
|
||||
child = os.open(name, _DIR_FLAGS, dir_fd=parent)
|
||||
try:
|
||||
check(os.fstat(child))
|
||||
named = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
||||
opened = os.fstat(child)
|
||||
if (named.st_dev, named.st_ino) != (opened.st_dev, opened.st_ino):
|
||||
raise ValueError('directory identity changed')
|
||||
return child
|
||||
except BaseException:
|
||||
os.close(child)
|
||||
raise
|
||||
|
||||
|
||||
def _cache_fd(root, owner, repository):
|
||||
fd = _open_absolute(root)
|
||||
try:
|
||||
if os.fstat(fd).st_mode & 0o002:
|
||||
raise ValueError('world-writable harness root')
|
||||
for name, check in [('repositories', _repository_container), (owner, _private), (repository, _private)]:
|
||||
child = _existing(fd, name, check)
|
||||
os.close(fd)
|
||||
fd = child
|
||||
return fd
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def _new_child(parent, name, created):
|
||||
os.mkdir(name, 0o700, dir_fd=parent)
|
||||
initial = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
||||
created.append((os.dup(parent), name, (initial.st_dev, initial.st_ino)))
|
||||
# Correct only a directory just created by this call, including restrictive umask.
|
||||
if not stat.S_ISDIR(initial.st_mode) or initial.st_uid != os.getuid():
|
||||
raise ValueError('new directory identity changed')
|
||||
os.chmod(name, 0o700, dir_fd=parent, follow_symlinks=False)
|
||||
fd = _existing(parent, name)
|
||||
opened = os.fstat(fd)
|
||||
if (opened.st_dev, opened.st_ino) != (initial.st_dev, initial.st_ino):
|
||||
os.close(fd)
|
||||
raise ValueError('new directory identity changed')
|
||||
return fd
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeDirectory:
|
||||
path: Path
|
||||
fd: int # Caller owns this descriptor and must close it.
|
||||
|
||||
|
||||
def prepare_issue_runtime(root, owner, repository, issue, run_id, *, lock_set):
|
||||
path = issue_runtime_path(root, owner, repository, issue, run_id)
|
||||
expected = ManagedLockSet.paths_for_issue(root, owner, repository, issue)
|
||||
if (not isinstance(lock_set, ManagedLockSet) or not lock_set.issue.held or not lock_set.repository.held
|
||||
or (lock_set.issue.path, lock_set.repository.path) != expected):
|
||||
raise ValueError('matching Issue and repository leases required')
|
||||
for lock in (lock_set.issue, lock_set.repository):
|
||||
lock_fd = lock.fd
|
||||
if lock_fd is None or not validate_held_flock(lock_fd, lock.path):
|
||||
raise ValueError('kernel lease unavailable')
|
||||
parent = _open_absolute(lock.path.parent)
|
||||
try:
|
||||
_private(os.fstat(parent))
|
||||
named = os.stat(lock.path.name, dir_fd=parent, follow_symlinks=False)
|
||||
opened = os.fstat(lock_fd)
|
||||
if (named.st_dev, named.st_ino) != (opened.st_dev, opened.st_ino):
|
||||
raise ValueError('kernel lease path identity mismatch')
|
||||
finally:
|
||||
os.close(parent)
|
||||
fd = _cache_fd(Path(root), owner, repository)
|
||||
created = []
|
||||
success = False
|
||||
try:
|
||||
for name in ('worktrees', f'issue-{issue}', 'runtime'):
|
||||
try:
|
||||
child = _existing(fd, name)
|
||||
except FileNotFoundError:
|
||||
child = _new_child(fd, name, created)
|
||||
os.close(fd)
|
||||
fd = child
|
||||
# Runs are immutable identities; even a safe, empty stale run is a collision.
|
||||
try:
|
||||
fd_run = _new_child(fd, run_id, created)
|
||||
except FileExistsError as exc:
|
||||
raise ValueError('runtime run already exists') from exc
|
||||
os.close(fd)
|
||||
fd = fd_run
|
||||
success = True
|
||||
return RuntimeDirectory(path, fd)
|
||||
finally:
|
||||
if not success:
|
||||
os.close(fd)
|
||||
for parent, name, identity in reversed(created):
|
||||
try:
|
||||
current = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
||||
if (current.st_dev, current.st_ino) == identity and stat.S_ISDIR(current.st_mode):
|
||||
os.rmdir(name, dir_fd=parent) # Empty, owned inode only; never recursive.
|
||||
except OSError:
|
||||
pass
|
||||
for parent, _, _ in created:
|
||||
os.close(parent)
|
||||
|
||||
|
||||
def inspect_issue_worktree_admission(root, owner, repository, issue):
|
||||
"""Classify container shape; caller still authenticates state/base/registration.
|
||||
|
||||
This read-only check is used under Issue + repository leases. It does not
|
||||
authorize Git writes or declare an existing checkout valid.
|
||||
"""
|
||||
root = _identity(root, owner, repository, issue)
|
||||
fd = _cache_fd(root, owner, repository)
|
||||
issue_fd = None
|
||||
try:
|
||||
bare = root / 'repositories' / owner / repository / 'repository.git'
|
||||
bare_fd = os.open('repository.git', _DIR_FLAGS, dir_fd=fd)
|
||||
try:
|
||||
bare_st = os.fstat(bare_fd)
|
||||
if bare_st.st_uid != os.getuid() or bare_st.st_mode & 0o022:
|
||||
raise ValueError('unsafe bare repository')
|
||||
result = subprocess.run(['git', '--git-dir', f'/proc/self/fd/{bare_fd}', 'worktree', 'list', '--porcelain', '-z'],
|
||||
pass_fds=(bare_fd,), capture_output=True, text=True, timeout=30, check=False)
|
||||
finally:
|
||||
os.close(bare_fd)
|
||||
if result.returncode:
|
||||
raise ValueError('Git registration unavailable')
|
||||
checkout = bare.parent / 'worktrees' / f'issue-{issue}' / 'checkout'
|
||||
registrations = [field[9:] for field in result.stdout.split('\x00') if field.startswith('worktree ')]
|
||||
registered = registrations.count(str(checkout))
|
||||
try:
|
||||
worktrees = _existing(fd, 'worktrees')
|
||||
except FileNotFoundError:
|
||||
return 'invalid' if registered else 'absent'
|
||||
try:
|
||||
try:
|
||||
issue_fd = _existing(worktrees, f'issue-{issue}')
|
||||
except FileNotFoundError:
|
||||
return 'invalid' if registered else 'absent'
|
||||
finally:
|
||||
os.close(worktrees)
|
||||
entries = set(os.listdir(issue_fd))
|
||||
for name in entries & {'runtime', 'verification'}:
|
||||
child = _existing(issue_fd, name)
|
||||
os.close(child)
|
||||
if entries <= {'runtime', 'verification'}:
|
||||
return 'invalid' if registered else 'runtime-only'
|
||||
if entries != (entries & {'runtime', 'verification'}) | {'checkout', 'state.json'} or registered != 1:
|
||||
return 'invalid'
|
||||
source = os.stat('checkout', dir_fd=issue_fd, follow_symlinks=False)
|
||||
state = os.stat('state.json', dir_fd=issue_fd, follow_symlinks=False)
|
||||
if (not stat.S_ISDIR(source.st_mode) or source.st_uid != os.getuid() or source.st_mode & 0o022
|
||||
or not stat.S_ISREG(state.st_mode) or state.st_uid != os.getuid()
|
||||
or stat.S_IMODE(state.st_mode) != 0o600 or state.st_nlink != 1):
|
||||
return 'invalid'
|
||||
return 'checkout-candidate'
|
||||
finally:
|
||||
if issue_fd is not None:
|
||||
os.close(issue_fd)
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def main():
|
||||
# Direct-script bootstrap is below; imports are delayed to avoid broker cycles.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('command', choices=['inspect-worktree'])
|
||||
for name in ('root', 'owner', 'repository', 'handle'):
|
||||
parser.add_argument('--' + name, required=True)
|
||||
parser.add_argument('--issue', required=True, type=int)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
from harness.lib import managed_lock_broker as broker, verification_root
|
||||
root = _identity(args.root, args.owner, args.repository, args.issue)
|
||||
verification_root._lease(Path(args.handle), root, args.owner, args.repository, args.issue)
|
||||
if broker.request_handle(args.handle, 'status').get('status') != 'repository-held':
|
||||
raise ValueError('repository lease required')
|
||||
shape = inspect_issue_worktree_admission(root, args.owner, args.repository, args.issue)
|
||||
print(json.dumps({'container': shape}))
|
||||
return 1 if shape == 'invalid' else 0
|
||||
except (ValueError, OSError, subprocess.SubprocessError):
|
||||
print(json.dumps({'error': 'worktree-container-unavailable'}))
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user