This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new 7089b4c  Cache admins from LDAP using a server task
7089b4c is described below

commit 7089b4cebcd0847c5e953cf7a4f75c5537f5c170
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Jan 23 16:03:29 2026 +0000

    Cache admins from LDAP using a server task
---
 atr/server.py            | 10 +++++++
 atr/tasks/__init__.py    |  2 +-
 tests/unit/test_cache.py | 69 ++++++++++++++++++++++++++++++++++++++++++++++++
 tests/unit/test_ldap.py  | 46 ++++++++++++++++++++++++++++++++
 4 files changed, 126 insertions(+), 1 deletion(-)

diff --git a/atr/server.py b/atr/server.py
index c979fbe..0408f29 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -46,6 +46,7 @@ import werkzeug.routing as routing
 
 import atr
 import atr.blueprints as blueprints
+import atr.cache as cache
 import atr.config as config
 import atr.db as db
 import atr.db.interaction as interaction
@@ -244,6 +245,10 @@ def _app_setup_lifecycle(app: base.QuartApp, app_config: 
type[config.AppConfig])
 
         await asyncio.to_thread(_set_file_permissions_to_read_only)
 
+        await cache.admins_startup_load()
+        admins_task = asyncio.create_task(cache.admins_refresh_loop())
+        app.extensions["admins_task"] = admins_task
+
         worker_manager = manager.get_worker_manager()
         await worker_manager.start()
 
@@ -282,6 +287,11 @@ def _app_setup_lifecycle(app: base.QuartApp, app_config: 
type[config.AppConfig])
             with contextlib.suppress(asyncio.CancelledError):
                 await task
 
+        if task := app.extensions.get("admins_task"):
+            task.cancel()
+            with contextlib.suppress(asyncio.CancelledError):
+                await task
+
         await db.shutdown_database()
 
         if audit_listener := app.extensions.get("audit_listener"):
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index 325a7e6..38d57d6 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -60,7 +60,7 @@ async def asc_checks(asf_uid: str, release: sql.Release, 
revision: str, signatur
     return tasks
 
 
-async def clear_scheduled(caller_data: db.Session | None = None):
+async def clear_scheduled(caller_data: db.Session | None = None) -> None:
     """Clear all future scheduled tasks of the given types."""
     async with db.ensure_session(caller_data) as data:
         via = sql.validate_instrumented_attribute
diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py
index 4aba1f9..a8f962f 100644
--- a/tests/unit/test_cache.py
+++ b/tests/unit/test_cache.py
@@ -29,11 +29,23 @@ if TYPE_CHECKING:
     from pytest import MonkeyPatch
 
 
+class _MockApp:
+    def __init__(self):
+        self.extensions: dict[str, object] = {}
+
+
 class _MockConfig:
     def __init__(self, state_dir: pathlib.Path):
         self.STATE_DIR = str(state_dir)
 
 
[email protected]
+def mock_app(monkeypatch: "MonkeyPatch") -> _MockApp:
+    app = _MockApp()
+    monkeypatch.setattr("asfquart.APP", app)
+    return app
+
+
 @pytest.fixture
 def state_dir(tmp_path: pathlib.Path, monkeypatch: "MonkeyPatch") -> 
pathlib.Path:
     monkeypatch.setattr("atr.config.get", lambda: _MockConfig(tmp_path))
@@ -130,3 +142,60 @@ async def 
test_admins_save_to_file_creates_parent_dirs(state_dir: pathlib.Path):
     await cache.admins_save_to_file(frozenset({"alice"}))
     assert cache_dir.exists()
     assert cache_dir.is_dir()
+
+
[email protected]
+async def test_admins_startup_load_calls_ldap_when_cache_missing(
+    state_dir: pathlib.Path, mock_app: _MockApp, monkeypatch: "MonkeyPatch"
+):
+    ldap_called = False
+
+    async def mock_fetch_admin_users() -> frozenset[str]:
+        nonlocal ldap_called
+        ldap_called = True
+        return frozenset({"ldap_alice", "ldap_bob"})
+
+    monkeypatch.setattr("atr.ldap.fetch_admin_users", mock_fetch_admin_users)
+
+    await cache.admins_startup_load()
+
+    assert ldap_called is True
+    assert mock_app.extensions["admins"] == frozenset({"ldap_alice", 
"ldap_bob"})
+    cache_path = state_dir / "cache" / "admins.json"
+    assert cache_path.exists()
+
+
[email protected]
+async def test_admins_startup_load_handles_ldap_failure(
+    state_dir: pathlib.Path, mock_app: _MockApp, monkeypatch: "MonkeyPatch"
+):
+    async def mock_fetch_admin_users() -> frozenset[str]:
+        raise ConnectionError("LDAP server unavailable")
+
+    monkeypatch.setattr("atr.ldap.fetch_admin_users", mock_fetch_admin_users)
+
+    await cache.admins_startup_load()
+
+    assert "admins" not in mock_app.extensions
+    cache_path = state_dir / "cache" / "admins.json"
+    assert not cache_path.exists()
+
+
[email protected]
+async def test_admins_startup_load_uses_cache_when_present(
+    state_dir: pathlib.Path, mock_app: _MockApp, monkeypatch: "MonkeyPatch"
+):
+    ldap_called = False
+
+    async def mock_fetch_admin_users() -> frozenset[str]:
+        nonlocal ldap_called
+        ldap_called = True
+        return frozenset({"from_ldap"})
+
+    monkeypatch.setattr("atr.ldap.fetch_admin_users", mock_fetch_admin_users)
+
+    await cache.admins_save_to_file(frozenset({"cached_alice", "cached_bob"}))
+    await cache.admins_startup_load()
+
+    assert ldap_called is False
+    assert mock_app.extensions["admins"] == frozenset({"cached_alice", 
"cached_bob"})
diff --git a/tests/unit/test_ldap.py b/tests/unit/test_ldap.py
index 5a51481..43fdf8a 100644
--- a/tests/unit/test_ldap.py
+++ b/tests/unit/test_ldap.py
@@ -15,16 +15,62 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import pathlib
+from typing import TYPE_CHECKING
+
 import pytest
 
+import atr.cache as cache
 import atr.ldap as ldap
 
+if TYPE_CHECKING:
+    from pytest import MonkeyPatch
+
+
+class _MockApp:
+    def __init__(self):
+        self.extensions: dict[str, object] = {}
+
+
+class _MockConfig:
+    def __init__(self, state_dir: pathlib.Path, ldap_bind_dn: str | None, 
ldap_bind_password: str | None):
+        self.STATE_DIR = str(state_dir)
+        self.LDAP_BIND_DN = ldap_bind_dn
+        self.LDAP_BIND_PASSWORD = ldap_bind_password
+
 
 @pytest.fixture
 def ldap_configured() -> bool:
     return ldap.get_bind_credentials() is not None
 
 
[email protected]
+async def test_admins_startup_load_fetches_real_admins(
+    ldap_configured: bool, tmp_path: pathlib.Path, monkeypatch: "MonkeyPatch"
+):
+    _skip_if_unavailable(ldap_configured)
+
+    import atr.config as config
+
+    real_config = config.get()
+    mock_config = _MockConfig(tmp_path, real_config.LDAP_BIND_DN, 
real_config.LDAP_BIND_PASSWORD)
+    monkeypatch.setattr("atr.config.get", lambda: mock_config)
+
+    mock_app = _MockApp()
+    monkeypatch.setattr("asfquart.APP", mock_app)
+
+    await cache.admins_startup_load()
+
+    admins = mock_app.extensions.get("admins")
+    assert admins is not None
+    assert isinstance(admins, frozenset)
+    assert len(admins) > 1
+    assert "wave" in admins
+
+    cache_path = tmp_path / "cache" / "admins.json"
+    assert cache_path.exists()
+
+
 @pytest.mark.asyncio
 async def 
test_fetch_admin_users_contains_only_nonempty_strings(ldap_configured: bool):
     _skip_if_unavailable(ldap_configured)


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to