Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
1f9c816
feat(opal-server): gated /internal git-fetcher cache stats endpoint
dshoen619 Jun 23, 2026
c176ce9
test(git-leak): add OPAL git leak/resilience test bed
dshoen619 Jun 23, 2026
bd49676
test(git-leak): add GiteaAdmin and make_repo_unreachable helpers
dshoen619 Jun 23, 2026
afeb969
test(git-leak): correct postgres-bounce framing (passes on master)
dshoen619 Jun 23, 2026
92353f6
style(git-leak): apply black/isort/docformatter (pre-commit)
dshoen619 Jun 23, 2026
db54ae6
test: scope root pytest collection to packages/ (exclude git-leak bed)
dshoen619 Jun 23, 2026
6f9a089
test(git-leak): address Copilot review feedback
dshoen619 Jun 23, 2026
1b23ac0
test(git-leak): isolate scopes per test and fix false repeat-sync gate
dshoen619 Jun 23, 2026
6046a10
test(git-leak): make the regression gates trustworthy (address PR rev…
dshoen619 Jun 24, 2026
f810db8
style(git-leak): apply black/isort/docformatter (pre-commit)
dshoen619 Jun 24, 2026
82ff33a
test(git-leak): tighten stat polling and pin test-bed images (PR review)
dshoen619 Jun 24, 2026
d719c34
Merge branch 'master' into david/per-15155-pr1-git-leakresilience-tes…
dshoen619 Jun 28, 2026
75ad43a
test(git-leak): isolate offline-hang healthy probe to a never-cloned …
dshoen619 Jun 28, 2026
15f3cfe
test(git-leak): harden harness teardown and tighten assertions (PR re…
dshoen619 Jun 28, 2026
a502f2e
Merge branch 'master' into david/per-15155-pr1-git-leakresilience-tes…
dshoen619 Jun 30, 2026
ac29c2e
feat(opal-server): add GitPolicyFetcher.forget_repo to release cached…
Zivxx Jun 23, 2026
b792f23
fix(opal-server): purge git fetcher caches on scope delete
Zivxx Jun 23, 2026
8db1b6b
fix(opal-server): clean all finished webhook tasks (no remove-while-i…
Zivxx Jun 23, 2026
fdebcd5
Merge remote-tracking branch 'origin/master' into ziv/per-15156-pr2-m…
Zivxx Jul 12, 2026
92c8515
fix(opal-server): actually purge fetcher caches on scope DELETE
Zivxx Jul 12, 2026
8d5cfdf
style(opal-server): fix import order in delete_scope_route_test (isort)
Zivxx Jul 12, 2026
89e090b
fix(opal-server): serialize the scope-delete cache purge under the re…
Zivxx Jul 13, 2026
1e7df1c
fix(opal-server): surface rmtree and Repository.free() failures in sc…
Zivxx Jul 13, 2026
deb6a5f
fix(opal-server): retrieve webhook task exceptions before dropping them
Zivxx Jul 13, 2026
36b020f
feat(opal-server): expose pid and cache keys in internal fetcher stats
Zivxx Jul 14, 2026
6b44351
fix(opal-server): only stamp repos_last_fetched after a successful fetch
Zivxx Jul 14, 2026
b7f1ba0
test(opal-server): pin fetch-START semantics of the repos_last_fetche…
Zivxx Jul 14, 2026
138deeb
fix(opal-server): make invalid-clone recovery drop the stale handle a…
Zivxx Jul 14, 2026
d980c9c
test(opal-server): delete-then-recreate serializes on the repo lock
Zivxx Jul 14, 2026
6b07f9c
test(opal-server): stop() cancels and gathers in-flight webhook triggers
Zivxx Jul 14, 2026
436a8a0
fix(opal-server): read fetcher cache stats from atomic snapshots
Zivxx Jul 14, 2026
b0255c6
test(opal-server): assert recovery actually free()s the stale handle
Zivxx Jul 14, 2026
788ae68
test(opal-server): invariant checker + pid-stability teardown for the…
Zivxx Jul 14, 2026
04273d3
docs(opal-server): document stats() merge semantics and fix stale hints
Zivxx Jul 14, 2026
7358ce4
test(opal-server): bed gate for delete/recreate storms on one source
Zivxx Jul 14, 2026
138dd48
test(opal-server): warm-boot gate — restart must reuse on-disk clones
Zivxx Jul 14, 2026
9206590
test(opal-server): seeded randomized churn driver with invariant checks
Zivxx Jul 14, 2026
779dad5
test(opal-server): characterize upstream force-push and branch-delete…
Zivxx Jul 14, 2026
a987bde
test(opal-server): delete-during-hung-fetch gates (no-crash green, bo…
Zivxx Jul 14, 2026
b0bc27b
test(opal-server): repoint-during-hung-fetch gate (red until PR3 upda…
Zivxx Jul 14, 2026
d5a740f
test(opal-server): system gate for corrupt-clone recovery (no re-clon…
Zivxx Jul 14, 2026
52a1fe9
test(opal-server): corrupt clone contents in place so recovery exerci…
Zivxx Jul 14, 2026
8ccd3ac
fix(opal-server): detect gutted object stores during clone validation
Zivxx Jul 14, 2026
3839b6a
test(opal-server): orphan-dir and redis-wipe boot gates (red until or…
Zivxx Jul 14, 2026
f9ee0bd
test(opal-server): clean the wiped scope's clone dir so later I1 chec…
Zivxx Jul 14, 2026
8d5c612
test(opal-server): boot-while-remotes-down gate (red until PR3 fetch …
Zivxx Jul 14, 2026
ac3f9da
test(opal-server): clean stranded blackhole clones after the boot-dow…
Zivxx Jul 14, 2026
a2e7359
test(opal-server): shard-reconfig boot gate (serves green, orphans re…
Zivxx Jul 14, 2026
0ff543a
test(opal-server): multi-worker churn gate — every worker must drain …
Zivxx Jul 14, 2026
06a0b00
docs(opal-server): correct the multi-worker leak mechanism in the chu…
Zivxx Jul 14, 2026
4b7a99c
docs(opal-server): extend git-leak gate matrix with lifecycle transit…
Zivxx Jul 14, 2026
bf53477
test(opal-server): make stats_by_pid min_pids an actual early-exit
Zivxx Jul 14, 2026
4826a7d
fix(opal-server): stop make_bundle reading the shared pygit2 handle o…
Zivxx Jul 15, 2026
bd7d007
fix(opal-server): serialize sibling-check and purge on the source loc…
Zivxx Jul 15, 2026
0093be6
fix(opal-server): log rmtree failures in recovery and clone pre-clean
Zivxx Jul 15, 2026
2596cd4
fix(opal-server): run the clone-validation probe off-loop and stamp r…
Zivxx Jul 15, 2026
152eeee
fix(opal-server): redact credentialed URLs in webhook task failure logs
Zivxx Jul 15, 2026
aed67f7
test(opal-server): cover _get_valid_repo healthy paths and probe life…
Zivxx Jul 15, 2026
b796c38
fix(opal-server): purge defensively when the post-delete sibling chec…
Zivxx Jul 15, 2026
d8d9f6d
fix(opal-server): offload recovery rmtrees and widen the PR3 delete-r…
Zivxx Jul 15, 2026
0450eac
fix(opal-server): fall back to the default bundle when a clone dir va…
Zivxx Jul 15, 2026
a295717
fix(opal-server): stamp clone start time after a reclone
Zivxx Jul 15, 2026
bd6c86d
refactor(opal-server): reuse redact_url_in_text for webhook failure logs
Zivxx Jul 16, 2026
bb171e6
fix(opal-server): keep the delete purge reachable and extract the pur…
Zivxx Jul 16, 2026
caaf713
fix(opal-server): re-check scope liveness right before each queued sync
Zivxx Jul 16, 2026
bf72ca5
test(opal-server): cover the clone-vanish default-bundle fallback
Zivxx Jul 16, 2026
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
20 changes: 20 additions & 0 deletions packages/opal-server/opal_server/git_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,26 @@ def base_dir(base_dir: Path) -> Path:
def repo_clone_path(base_dir: Path, source: GitPolicyScopeSource) -> Path:
return GitPolicyFetcher.base_dir(base_dir) / GitPolicyFetcher.source_id(source)

@staticmethod
def forget_repo(path: str) -> None:
"""Drop the cached repository for a clone path and release its handles.

The cached ``pygit2.Repository`` keeps OS file descriptors and mmapped
pack indexes open; without this, a deleted scope's repo pins memory and
inodes for the lifetime of the process even after the clone is removed.
``Repository.free()`` is called only when available (it is not part of
every pygit2 release); otherwise the dropped reference is reclaimed by GC.
"""
repo = GitPolicyFetcher.repos.pop(path, None)
if repo is None:
return
free = getattr(repo, "free", None)
if callable(free):
try:
free()
Comment thread
Zivxx marked this conversation as resolved.
except Exception:
Comment thread
Zivxx marked this conversation as resolved.
Outdated
logger.debug("pygit2 Repository.free() failed; relying on GC")


class GitCallback(RemoteCallbacks):
def __init__(self, source: GitPolicyScopeSource):
Expand Down
9 changes: 4 additions & 5 deletions packages/opal-server/opal_server/policy/watcher/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,10 @@ async def __aexit__(self, exc_type, exc, tb):

async def _on_webhook(self, topic: Topic, data: Any):
logger.info(f"Webhook listener triggered ({len(self._webhook_tasks)})")
for task in self._webhook_tasks:
if task.done():
# Clean references to finished tasks
self._webhook_tasks.remove(task)

# Rebuild rather than remove-while-iterating: list.remove() inside a
# `for t in self._webhook_tasks` loop skips the element after each removal,
# so finished tasks accumulate.
self._webhook_tasks = [t for t in self._webhook_tasks if not t.done()]
Comment thread
Zivxx marked this conversation as resolved.
self._webhook_tasks.append(asyncio.create_task(self.trigger(topic, data)))

async def _listen_to_webhook_notifications(self):
Expand Down
11 changes: 9 additions & 2 deletions packages/opal-server/opal_server/scopes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from opal_server.data.data_update_publisher import DataUpdatePublisher
from opal_server.git_fetcher import GitPolicyFetcher
from opal_server.scopes.scope_repository import ScopeNotFoundError, ScopeRepository
from opal_server.scopes.service import ScopesService


def verify_private_key(private_key: str, key_format: EncryptionKeyFormat) -> bool:
Expand Down Expand Up @@ -80,6 +81,7 @@ def init_scope_router(
scopes: ScopeRepository,
authenticator: JWTAuthenticator,
pubsub_endpoint: PubSubEndpoint,
scopes_service: ScopesService,
):
router = APIRouter()

Expand Down Expand Up @@ -171,8 +173,13 @@ async def delete_scope(
logger.error(f"Unauthorized to delete scope: {repr(ex)}")
raise

# TODO: This should also asynchronously clean the repo from the disk (if it's not used by other scopes)
await scopes.delete(scope_id)
try:
# Deletes the scope and also cleans the repo clone from disk and the
# GitPolicyFetcher in-memory caches (unless another scope shares them).
await scopes_service.delete_scope(scope_id)
Comment thread
Zivxx marked this conversation as resolved.
except ScopeNotFoundError:
# Deleting a missing scope was always a silent no-op (204); keep it.
pass

return Response(status_code=status.HTTP_204_NO_CONTENT)

Expand Down
41 changes: 26 additions & 15 deletions packages/opal-server/opal_server/scopes/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,24 +163,35 @@ async def delete_scope(self, scope_id: str):
with tracer.trace("scopes_service.delete_scope", resource=scope_id):
logger.info(f"Delete scope: {scope_id}")
scope = await self._scopes.get(scope_id)
url = scope.policy.url

scopes = await self._scopes.all()
remove_repo_clone = True

for scope in scopes:
if scope.scope_id != scope_id and scope.policy.url == url:
logger.info(
f"found another scope with same remote url ({scope.scope_id}), skipping clone deletion"
)
remove_repo_clone = False
break
deleted_source = cast(GitPolicyScopeSource, scope.policy)
Comment thread
Zivxx marked this conversation as resolved.
Outdated
deleted_source_id = GitPolicyFetcher.source_id(deleted_source)
scope_dir = GitPolicyFetcher.repo_clone_path(self._base_dir, deleted_source)

# Clone dir, the `repos` handle cache, and `repos_last_fetched` are
# all keyed by source_id (= the clone path). A sibling only shares
# storage when it resolves to the same source_id; same url with a
# different branch can shard to a different source_id (and a
# different clone dir) when SCOPES_REPO_CLONES_SHARDS > 1, so gate on
# source_id, not url — otherwise the deleted scope's clone + pygit2
# handle leak.
other_scopes = [
Comment thread
Zivxx marked this conversation as resolved.
Outdated
s for s in await self._scopes.all() if s.scope_id != scope_id
]
source_id_shared = any(
isinstance(s.policy, GitPolicyScopeSource)
and GitPolicyFetcher.source_id(s.policy) == deleted_source_id
for s in other_scopes
)

if remove_repo_clone:
scope_dir = GitPolicyFetcher.repo_clone_path(
self._base_dir, cast(GitPolicyScopeSource, scope.policy)
if source_id_shared:
logger.info(
"Another scope shares the same clone (source id), skipping clone deletion"
Comment thread
Zivxx marked this conversation as resolved.
Outdated
)
else:
shutil.rmtree(scope_dir, ignore_errors=True)
Comment thread
Zivxx marked this conversation as resolved.
Outdated
GitPolicyFetcher.forget_repo(str(scope_dir))
Comment thread
Zivxx marked this conversation as resolved.
Outdated
GitPolicyFetcher.repos_last_fetched.pop(deleted_source_id, None)
GitPolicyFetcher.repo_locks.pop(deleted_source_id, None)

await self._scopes.delete(scope_id)

Expand Down
11 changes: 10 additions & 1 deletion packages/opal-server/opal_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import sys
import traceback
from functools import partial
from pathlib import Path
from typing import List, Optional

from fastapi import Depends, FastAPI, Request
Expand Down Expand Up @@ -40,6 +41,7 @@
from opal_server.scopes.api import init_scope_router
from opal_server.scopes.loader import load_scopes
from opal_server.scopes.scope_repository import ScopeRepository
from opal_server.scopes.service import ScopesService
from opal_server.security.api import init_security_router
from opal_server.security.jwks import JwksStaticEndpoint
from opal_server.statistics import OpalStatistics, init_statistics_router
Expand Down Expand Up @@ -270,8 +272,15 @@ def _configure_api_routes(self, app: FastAPI):
)

if opal_server_config.SCOPES:
scopes_service = ScopesService(
base_dir=Path(opal_server_config.BASE_DIR),
scopes=self._scopes,
pubsub_endpoint=self.pubsub.endpoint,
)
app.include_router(
init_scope_router(self._scopes, authenticator, self.pubsub.endpoint),
init_scope_router(
self._scopes, authenticator, self.pubsub.endpoint, scopes_service
),
tags=["Scopes"],
prefix="/scopes",
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import pytest
from opal_common.schemas.policy_source import GitPolicyScopeSource, NoAuthData
from opal_common.schemas.scopes import Scope
from opal_server.git_fetcher import GitPolicyFetcher
from opal_server.scopes.scope_repository import ScopeNotFoundError
from opal_server.scopes.service import ScopesService


class FakeScopeRepository:
def __init__(self, scopes):
self._scopes = {s.scope_id: s for s in scopes}

async def get(self, scope_id):
if scope_id not in self._scopes:
raise ScopeNotFoundError(scope_id)
return self._scopes[scope_id]

async def all(self):
return list(self._scopes.values())

async def delete(self, scope_id):
self._scopes.pop(scope_id, None)


def _scope(scope_id, url, branch="main"):
return Scope(
scope_id=scope_id,
policy=GitPolicyScopeSource(
source_type="git",
url=url,
branch=branch,
auth=NoAuthData(auth_type="none"),
),
data={"entries": []},
)


@pytest.fixture(autouse=True)
def clear_caches():
GitPolicyFetcher.repos.clear()
GitPolicyFetcher.repos_last_fetched.clear()
GitPolicyFetcher.repo_locks.clear()
yield
GitPolicyFetcher.repos.clear()
GitPolicyFetcher.repos_last_fetched.clear()
GitPolicyFetcher.repo_locks.clear()


@pytest.mark.asyncio
async def test_delete_unique_scope_purges_caches(tmp_path, monkeypatch):
scope = _scope("only", "https://git/repo-a.git")
repo = FakeScopeRepository([scope])
svc = ScopesService(base_dir=tmp_path, scopes=repo, pubsub_endpoint=None)

src = scope.policy
sid = GitPolicyFetcher.source_id(src)
clone_path = str(GitPolicyFetcher.repo_clone_path(tmp_path, src))
GitPolicyFetcher.repos[clone_path] = object()
GitPolicyFetcher.repos_last_fetched[sid] = "ts"
GitPolicyFetcher.repo_locks[sid] = object()

monkeypatch.setattr(
"opal_server.scopes.service.shutil.rmtree", lambda *a, **k: None
)

await svc.delete_scope("only")

assert clone_path not in GitPolicyFetcher.repos
assert sid not in GitPolicyFetcher.repos_last_fetched
assert sid not in GitPolicyFetcher.repo_locks


@pytest.mark.asyncio
async def test_delete_keeps_caches_when_sibling_shares_source(tmp_path, monkeypatch):
a = _scope("a", "https://git/shared.git")
b = _scope("b", "https://git/shared.git") # same url+branch -> same source_id
repo = FakeScopeRepository([a, b])
svc = ScopesService(base_dir=tmp_path, scopes=repo, pubsub_endpoint=None)

sid = GitPolicyFetcher.source_id(a.policy)
clone_path = str(GitPolicyFetcher.repo_clone_path(tmp_path, a.policy))
GitPolicyFetcher.repos[clone_path] = object()
GitPolicyFetcher.repos_last_fetched[sid] = "ts"
GitPolicyFetcher.repo_locks[sid] = object()

rmtree_calls = []
monkeypatch.setattr(
"opal_server.scopes.service.shutil.rmtree",
lambda p, **k: rmtree_calls.append(p),
)

await svc.delete_scope("a")

assert rmtree_calls == [] # sibling shares the source id; clone must survive
assert clone_path in GitPolicyFetcher.repos
assert sid in GitPolicyFetcher.repos_last_fetched
assert sid in GitPolicyFetcher.repo_locks


@pytest.mark.asyncio
async def test_delete_purges_when_sibling_shares_url_but_not_source(
tmp_path, monkeypatch
):
"""Same url, different branch, sharded clones (SCOPES_REPO_CLONES_SHARDS>1)
resolve to different source_ids -> different clone dirs.

Deleting one must still purge its own clone + caches; the url-
sharing sibling lives elsewhere.
"""
# shards=4: branch "main" -> index 1, "dev" -> index 3 (distinct source_ids).
monkeypatch.setattr(
"opal_server.git_fetcher.opal_server_config.SCOPES_REPO_CLONES_SHARDS", 4
)
a = _scope("a", "https://git/shared.git", branch="main")
b = _scope("b", "https://git/shared.git", branch="dev") # same url, diff source_id
assert GitPolicyFetcher.source_id(a.policy) != GitPolicyFetcher.source_id(b.policy)

repo = FakeScopeRepository([a, b])
svc = ScopesService(base_dir=tmp_path, scopes=repo, pubsub_endpoint=None)

sid_a = GitPolicyFetcher.source_id(a.policy)
clone_path_a = str(GitPolicyFetcher.repo_clone_path(tmp_path, a.policy))
GitPolicyFetcher.repos[clone_path_a] = object()
GitPolicyFetcher.repos_last_fetched[sid_a] = "ts"

rmtree_calls = []
monkeypatch.setattr(
"opal_server.scopes.service.shutil.rmtree",
lambda p, **k: rmtree_calls.append(str(p)),
)

await svc.delete_scope("a")

assert rmtree_calls == [clone_path_a] # its own clone dir removed
assert clone_path_a not in GitPolicyFetcher.repos
assert sid_a not in GitPolicyFetcher.repos_last_fetched
97 changes: 97 additions & 0 deletions packages/opal-server/opal_server/tests/delete_scope_route_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from opal_common.schemas.policy_source import GitPolicyScopeSource, NoAuthData
from opal_common.schemas.scopes import Scope
from opal_server.git_fetcher import GitPolicyFetcher
from opal_server.scopes.api import init_scope_router
from opal_server.scopes.scope_repository import ScopeNotFoundError
from opal_server.scopes.service import ScopesService


class FakeScopeRepository:
def __init__(self, scopes):
self._scopes = {s.scope_id: s for s in scopes}

async def get(self, scope_id):
if scope_id not in self._scopes:
raise ScopeNotFoundError(scope_id)
return self._scopes[scope_id]

async def all(self):
return list(self._scopes.values())

async def delete(self, scope_id):
self._scopes.pop(scope_id, None)


class FakeAuthenticator:
"""Mimics a JWTAuthenticator whose verifier is disabled (no public key)."""

enabled = False

def __call__(self):
return {}


def _scope(scope_id, url, branch="main"):
return Scope(
scope_id=scope_id,
policy=GitPolicyScopeSource(
source_type="git",
url=url,
branch=branch,
auth=NoAuthData(auth_type="none"),
),
data={"entries": []},
)


def _client(repo, base_dir):
service = ScopesService(base_dir=base_dir, scopes=repo, pubsub_endpoint=None)
app = FastAPI()
app.include_router(
init_scope_router(repo, FakeAuthenticator(), None, service),
prefix="/scopes",
)
return TestClient(app)


@pytest.fixture(autouse=True)
def clear_caches():
GitPolicyFetcher.repos.clear()
GitPolicyFetcher.repos_last_fetched.clear()
GitPolicyFetcher.repo_locks.clear()
yield
GitPolicyFetcher.repos.clear()
GitPolicyFetcher.repos_last_fetched.clear()
GitPolicyFetcher.repo_locks.clear()


def test_delete_route_purges_fetcher_caches(tmp_path, monkeypatch):
"""DELETE /scopes/{id} must flow through ScopesService.delete_scope so the
GitPolicyFetcher caches drain (the git-leak churn gate)."""
scope = _scope("only", "https://git/repo-a.git")
repo = FakeScopeRepository([scope])

sid = GitPolicyFetcher.source_id(scope.policy)
clone_path = str(GitPolicyFetcher.repo_clone_path(tmp_path, scope.policy))
GitPolicyFetcher.repos[clone_path] = object()
GitPolicyFetcher.repos_last_fetched[sid] = "ts"

monkeypatch.setattr(
"opal_server.scopes.service.shutil.rmtree", lambda *a, **k: None
)

resp = _client(repo, tmp_path).delete("/scopes/only")

assert resp.status_code == 204
assert clone_path not in GitPolicyFetcher.repos
assert sid not in GitPolicyFetcher.repos_last_fetched


def test_delete_route_missing_scope_stays_204(tmp_path):
"""Deleting a nonexistent scope was a silent no-op (204) before the purge
wiring and must remain one."""
resp = _client(FakeScopeRepository([]), tmp_path).delete("/scopes/ghost")
assert resp.status_code == 204
Loading
Loading