Skip to content

Commit 7d8581e

Browse files
huffmancaclaude
andcommitted
[AAP-72504] Add configurable page_size and jwt_expiration for resource sync
Allow operators to tune RESOURCE_SYNC_PAGE_SIZE and RESOURCE_SYNC_JWT_EXPIRATION via Django settings. These are read automatically by SyncExecutor and create_api_client(), so consuming services (tower, hub, EDA) pick up new values without code changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d4ea85e commit 7d8581e

4 files changed

Lines changed: 81 additions & 6 deletions

File tree

ansible_base/lib/dynamic_config/settings_logic.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,10 @@ def get_mergeable_dab_settings(settings: dict) -> dict: # NOSONAR
268268
'RESOURCE_SERVICE_PATH': "/api/gateway/v1/service-index/",
269269
# Disable legacy SSO by default
270270
'ENABLE_SERVICE_BACKED_SSO': False,
271+
# Page size for assignment pagination during resource sync (capped server-side by MAX_PAGE_SIZE)
272+
'RESOURCE_SYNC_PAGE_SIZE': 50,
273+
# JWT service token lifetime in seconds for resource sync API calls
274+
'RESOURCE_SYNC_JWT_EXPIRATION': 60,
271275
}
272276
if 'ansible_base.resource_registry' in installed_apps:
273277
for key, value in resource_registry_defaults.items():

ansible_base/resource_registry/management/commands/resource_sync.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,20 @@ def add_arguments(self, parser):
6262
required=False,
6363
)
6464
parser.add_argument("--asyncio", action="store_true", default=False, help="Enable asyncio executor")
65+
parser.add_argument(
66+
"--page-size",
67+
type=int,
68+
default=None,
69+
dest="page_size",
70+
help="Page size for pagination when fetching assignments (default: from settings, capped server-side by MAX_PAGE_SIZE)",
71+
required=False,
72+
)
6573

6674
def handle(self, *args, **options):
6775
"""Handle RESOURCE_PROVIDER sync"""
68-
arguments = ["resource_type_names", "retries", "retrysleep", "asyncio"]
76+
if options.get("page_size") is not None and options["page_size"] < 1:
77+
raise CommandError("--page-size must be at least 1")
78+
arguments = ["resource_type_names", "retries", "retrysleep", "asyncio", "page_size"]
6979
options = {k: v for k, v in options.items() if k in arguments}
7080
try:
7181
executor = SyncExecutor(**options, stdout=self.stdout)

ansible_base/resource_registry/tasks/sync.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def create_api_client() -> ResourceAPIClient:
109109
if not service_path:
110110
raise ValueError("RESOURCE_SERVICE_PATH is not set.")
111111
params["service_path"] = service_path
112+
params["jwt_expiration"] = getattr(settings, "RESOURCE_SYNC_JWT_EXPIRATION", 60)
112113

113114
client = get_resource_server_client(**params)
114115
return client
@@ -190,9 +191,10 @@ class RemoteAssignmentFetcher:
190191
incomplete so the caller can skip deletions safely.
191192
"""
192193

193-
def __init__(self, api_client: ResourceAPIClient):
194+
def __init__(self, api_client: ResourceAPIClient, page_size: int | None = None):
194195
self.api_client = api_client
195196
self.assignments: set[AssignmentTuple] = set()
197+
self.page_size = page_size if page_size is not None else getattr(settings, 'RESOURCE_SYNC_PAGE_SIZE', 50)
196198

197199
def fetch(self) -> RemoteAssignmentResult:
198200
"""Paginate user then team assignments and return the result.
@@ -219,7 +221,7 @@ def _paginate(self, list_fn, actor_id_key: str, assignment_type: str) -> bool:
219221
page = 1
220222
try:
221223
while True:
222-
resp = list_fn(filters={'page': page})
224+
resp = list_fn(filters={'page': page, 'page_size': self.page_size})
223225
if resp.status_code != 200:
224226
logger.warning(f"Failed to fetch {assignment_type} assignments page {page}: HTTP {resp.status_code}")
225227
return False
@@ -250,14 +252,14 @@ def _paginate(self, list_fn, actor_id_key: str, assignment_type: str) -> bool:
250252
return False
251253

252254

253-
def get_remote_assignments(api_client: ResourceAPIClient) -> RemoteAssignmentResult:
255+
def get_remote_assignments(api_client: ResourceAPIClient, page_size: int | None = None) -> RemoteAssignmentResult:
254256
"""Fetch remote assignments from the resource server and convert to tuples.
255257
256258
Returns a ``RemoteAssignmentResult`` so the caller can distinguish a
257259
complete fetch from a partial one (e.g. due to HTTP errors or
258260
timeouts mid-pagination).
259261
"""
260-
return RemoteAssignmentFetcher(api_client).fetch()
262+
return RemoteAssignmentFetcher(api_client, page_size=page_size).fetch()
261263

262264

263265
def get_local_assignments() -> set[AssignmentTuple]:
@@ -617,6 +619,7 @@ class SyncExecutor:
617619
asyncio: bool = False
618620
results: dict = field(default_factory=lambda: defaultdict(list))
619621
sync_assignments: bool = True
622+
page_size: int | None = None
620623

621624
def write(self, text: str = ""):
622625
"""Write to assigned IO or simply ignores the text."""
@@ -763,7 +766,7 @@ def _sync_assignments(self):
763766
self.write(">>> Syncing role assignments")
764767

765768
try:
766-
remote_result = get_remote_assignments(self.api_client)
769+
remote_result = get_remote_assignments(self.api_client, page_size=self.page_size)
767770
local_assignments = get_local_assignments()
768771

769772
# Deletions are only safe when the remote fetch was complete.

test_app/tests/resource_registry/test_resource_sync.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pytest
66
from django.db.utils import Error
7+
from django.test import override_settings
78

89
from ansible_base.lib.testing.util import StaticResourceAPIClient
910
from ansible_base.lib.utils.response import get_relative_url
@@ -13,11 +14,13 @@
1314
from ansible_base.resource_registry.tasks.sync import (
1415
AssignmentTuple,
1516
ManifestItem,
17+
RemoteAssignmentFetcher,
1618
RemoteAssignmentResult,
1719
ResourceSyncHTTPError,
1820
SyncExecutor,
1921
_attempt_create_resource,
2022
_attempt_update_resource,
23+
create_api_client,
2124
get_remote_assignments,
2225
)
2326

@@ -543,3 +546,58 @@ def test_get_remote_assignments_filters_unknown_roles(static_api_client):
543546
assert len(result.assignments) == 1
544547
assignment = next(iter(result.assignments))
545548
assert assignment.role_definition_name == local_role.name
549+
550+
551+
@pytest.mark.django_db
552+
def test_remote_assignment_fetcher_passes_page_size():
553+
"""page_size should be included in the pagination filters."""
554+
api_client = mock.Mock(spec=["list_user_assignments", "list_team_assignments"])
555+
ok = _mock_response()
556+
api_client.list_user_assignments.return_value = ok
557+
api_client.list_team_assignments.return_value = ok
558+
559+
RemoteAssignmentFetcher(api_client, page_size=100).fetch()
560+
561+
api_client.list_user_assignments.assert_called_with(filters={'page': 1, 'page_size': 100})
562+
api_client.list_team_assignments.assert_called_with(filters={'page': 1, 'page_size': 100})
563+
564+
565+
@pytest.mark.django_db
566+
def test_remote_assignment_fetcher_reads_page_size_from_settings():
567+
"""When page_size is not provided, it should be read from settings."""
568+
api_client = mock.Mock(spec=["list_user_assignments", "list_team_assignments"])
569+
ok = _mock_response()
570+
api_client.list_user_assignments.return_value = ok
571+
api_client.list_team_assignments.return_value = ok
572+
573+
with override_settings(RESOURCE_SYNC_PAGE_SIZE=200):
574+
fetcher = RemoteAssignmentFetcher(api_client)
575+
assert fetcher.page_size == 200
576+
fetcher.fetch()
577+
578+
api_client.list_user_assignments.assert_called_with(filters={'page': 1, 'page_size': 200})
579+
580+
581+
@mock.patch('ansible_base.resource_registry.tasks.sync.get_remote_assignments')
582+
@mock.patch('ansible_base.resource_registry.tasks.sync.get_local_assignments', return_value=set())
583+
@pytest.mark.django_db
584+
def test_sync_executor_passes_page_size(mock_local, mock_remote, static_api_client, stdout):
585+
"""SyncExecutor should forward page_size to get_remote_assignments."""
586+
mock_remote.return_value = RemoteAssignmentResult(assignments=set(), is_complete=True)
587+
executor = SyncExecutor(api_client=static_api_client, stdout=stdout, page_size=75)
588+
executor._sync_assignments()
589+
mock_remote.assert_called_once_with(static_api_client, page_size=75)
590+
591+
592+
@mock.patch("ansible_base.resource_registry.tasks.sync.get_resource_server_client")
593+
def test_create_api_client_reads_jwt_expiration(mock_get_client):
594+
"""create_api_client should read RESOURCE_SYNC_JWT_EXPIRATION from settings."""
595+
mock_get_client.return_value = mock.Mock()
596+
597+
with override_settings(
598+
RESOURCE_SERVICE_PATH="/api/gateway/v1/service-index/",
599+
RESOURCE_SYNC_JWT_EXPIRATION=120,
600+
):
601+
create_api_client()
602+
603+
assert mock_get_client.call_args.kwargs["jwt_expiration"] == 120

0 commit comments

Comments
 (0)