Initial server source import

This commit is contained in:
sashatrask
2026-09-30 20:30:56 +03:00
commit 170dd941b9
498 changed files with 261563 additions and 0 deletions
+749
View File
@@ -0,0 +1,749 @@
from datetime import datetime
from pathlib import Path
import concurrent.futures
import copy
import sys
import threading
import time
from types import SimpleNamespace
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
APP_DIR = ROOT / 'app'
sys.path.insert(0, str(APP_DIR))
import console_runner
import scanner
class Response:
def __init__(self, status_code=200, payload=None, headers=None):
self.status_code = status_code
self.payload = payload if payload is not None else {}
self.headers = headers or {}
self.text = ''
self.content = b''
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f'HTTP {self.status_code}')
def json(self):
return self.payload
def close(self):
return None
def manager_with_accounts(count=1):
manager = scanner.DockerTokenManager()
manager.accounts = [
scanner.DockerAccount(
f'account-{index}', f'user-{index}', f'fixture-secret-{index}', '',
)
for index in range(count)
]
manager.explicit_pool = True
return manager
class DockerHubSearchAuthTests(unittest.TestCase):
def test_search_uses_hub_bearer_and_bounded_request_attempts(self):
manager = manager_with_accounts()
responses = [
Response(payload={'access_token': 'fixture-bearer', 'expires_in': 600}),
Response(payload={'count': 0, 'results': []}),
]
with mock.patch.object(scanner, 'docker_token_manager', manager), \
mock.patch.object(scanner, 'api_request', side_effect=responses) as request, \
mock.patch.object(
manager, 'report_success', wraps=manager.report_success,
) as report_success:
response = scanner.dockerhub_search_response(
'https://hub.docker.com/v2/search/repositories',
{'query': 'fixture', 'page': 3, 'page_size': 100},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(request.call_count, 2)
search_call = request.call_args_list[1]
self.assertEqual(
search_call.kwargs['headers']['Authorization'],
'Bearer fixture-bearer',
)
self.assertEqual(search_call.kwargs['max_retries'], 1)
self.assertTrue(any(
call.args[1] == 'hub_search'
for call in report_success.call_args_list
))
self.assertNotIn(('account-0', 'hub_search'), manager.cooldown_until)
self.assertNotIn(('account-0', 'hub_tags'), manager.cooldown_until)
def test_explicit_empty_pool_fails_without_anonymous_request(self):
manager = scanner.DockerTokenManager()
manager.explicit_pool = True
with mock.patch.object(scanner, 'docker_token_manager', manager), \
mock.patch.object(scanner, 'api_request') as request:
with self.assertRaisesRegex(
scanner.DockerRemoteAccessError, 'authentication is unavailable',
):
scanner.dockerhub_search_response(
'https://hub.docker.com/v2/search/repositories',
{'query': 'fixture', 'page': 1, 'page_size': 100},
)
request.assert_not_called()
def test_search_refreshes_once_after_401(self):
manager = manager_with_accounts()
responses = [
Response(payload={'access_token': 'old-bearer'}),
Response(status_code=401),
Response(payload={'access_token': 'new-bearer'}),
Response(payload={'count': 0, 'results': []}),
]
with mock.patch.object(scanner, 'docker_token_manager', manager), \
mock.patch.object(scanner, 'api_request', side_effect=responses) as request:
scanner.dockerhub_search_response(
'https://hub.docker.com/v2/search/repositories',
{'query': 'fixture', 'page': 1, 'page_size': 100},
)
self.assertEqual(request.call_count, 4)
self.assertEqual(
request.call_args_list[1].kwargs['headers']['Authorization'],
'Bearer old-bearer',
)
self.assertEqual(
request.call_args_list[3].kwargs['headers']['Authorization'],
'Bearer new-bearer',
)
def test_search_rotates_and_cools_account_after_403_or_429(self):
for status_code, expected_category in ((403, 'auth_forbidden'), (429, 'rate_limit')):
with self.subTest(status_code=status_code):
manager = manager_with_accounts(2)
responses = [
Response(payload={'access_token': 'bearer-a'}),
Response(status_code=status_code),
Response(payload={'access_token': 'bearer-b'}),
Response(payload={'count': 0, 'results': []}),
]
with mock.patch.object(scanner, 'docker_token_manager', manager), \
mock.patch.object(scanner, 'api_request', side_effect=responses) as request:
scanner.dockerhub_search_response(
'https://hub.docker.com/v2/search/repositories',
{'query': 'fixture', 'page': 1, 'page_size': 100},
)
self.assertEqual(request.call_count, 4)
self.assertEqual(
manager.cooldown_categories[('account-0', 'hub_search')],
expected_category,
)
self.assertEqual(
request.call_args_list[3].kwargs['headers']['Authorization'],
'Bearer bearer-b',
)
def test_transient_page_failure_gets_exactly_two_attempts(self):
manager = manager_with_accounts()
manager.cache_hub_token('account-0', 'fixture-bearer', 600)
timeout = scanner.requests.exceptions.ReadTimeout('fixture timeout')
response = Response(payload={'count': 0, 'results': []})
with mock.patch.object(scanner, 'docker_token_manager', manager), \
mock.patch.object(scanner.scan_config, 'api_proxy_enabled', False), \
mock.patch.object(
scanner.requests, 'request', side_effect=[timeout, response],
) as request, \
mock.patch.object(scanner, '_wait_or_raise_scan_slot_fatal'):
returned = scanner.dockerhub_search_response(
'https://hub.docker.com/v2/search/repositories',
{'query': 'fixture', 'page': 1, 'page_size': 100},
)
self.assertIs(returned, response)
self.assertEqual(request.call_count, 2)
def test_page_attempt_budget_does_not_reset_after_account_rotation(self):
manager = manager_with_accounts(3)
for index in range(3):
manager.cache_hub_token(f'account-{index}', f'bearer-{index}', 600)
with mock.patch.object(scanner, 'docker_token_manager', manager), \
mock.patch.object(
scanner, 'api_request',
side_effect=[
Response(status_code=403),
scanner.ApiRequestError('fixture transient failure'),
],
) as request:
with self.assertRaises(scanner.ApiRequestError):
scanner.dockerhub_search_response(
'https://hub.docker.com/v2/search/repositories',
{'query': 'fixture', 'page': 1, 'page_size': 100},
)
self.assertEqual(request.call_count, 2)
self.assertEqual(
request.call_args_list[1].kwargs['headers']['Authorization'],
'Bearer bearer-1',
)
def test_search_pool_exhaustion_does_not_set_tag_backoff(self):
manager = manager_with_accounts(2)
for index in range(2):
manager.cache_hub_token(f'account-{index}', f'bearer-{index}', 600)
with mock.patch.object(scanner, 'docker_token_manager', manager), \
mock.patch.object(
scanner, 'api_request',
side_effect=[Response(status_code=429), Response(status_code=429)],
), \
mock.patch.object(scanner, 'put_dockerhub_tags_rate_limit') as tag_backoff:
with self.assertRaisesRegex(
scanner.DockerRemoteAccessError, 'accounts are rate-limited',
):
scanner.dockerhub_search_response(
'https://hub.docker.com/v2/search/repositories',
{'query': 'fixture', 'page': 1, 'page_size': 100},
)
tag_backoff.assert_not_called()
self.assertTrue(manager.all_unavailable('hub_search'))
self.assertFalse(manager.all_unavailable('hub_tags'))
def test_concurrent_pages_share_token_acquisition(self):
manager = manager_with_accounts()
def token_response(*_args, **_kwargs):
time.sleep(0.02)
return Response(payload={'access_token': 'shared-bearer'})
with mock.patch.object(scanner, 'docker_token_manager', manager), \
mock.patch.object(
scanner, 'api_request', side_effect=token_response,
) as request, \
concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(
scanner._docker_hub_access_token,
manager.accounts[0],
False,
'hub_search',
)
for _ in range(2)
]
tokens = [future.result() for future in futures]
self.assertEqual(tokens, ['shared-bearer', 'shared-bearer'])
self.assertEqual(request.call_count, 1)
def test_search_event_persists_without_disabling_tag_account(self):
source_config = {
'auth_pool': 'dockerhub_main', 'rate_limit_cooldown': 300,
}
secrets = {'auth_pools': {'dockerhub_main': [{
'name': 'account-0', 'username': 'user-0', 'token': 'fixture-secret',
}]}}
state = {'sources': {'dockerhub': console_runner.default_source_state()}}
events = [{
'name': 'account-0',
'endpoint': 'hub_search',
'category': 'rate_limit',
'reset_at': '2099-01-01T00:00:00+00:00',
'message': 'Docker hub_search HTTP 429',
}]
with mock.patch.object(
console_runner, 'drain_docker_auth_events', return_value=events,
):
console_runner.persist_docker_auth_events(
'dockerhub', source_config, state, secrets,
)
source_state = state['sources']['dockerhub']
self.assertEqual(source_state['auth_status'].get('account-0'), {})
self.assertTrue(console_runner.auth_entry_is_available(
'dockerhub', secrets['auth_pools']['dockerhub_main'][0], state,
))
endpoint_state = source_state['auth_endpoint_status']['hub_search']['account-0']
self.assertEqual(endpoint_state['disabled_reason'], 'rate_limit')
self.assertNotIn('fixture-secret', repr(state))
manager = manager_with_accounts()
manager.restore_endpoint_cooldowns(source_state['auth_endpoint_status'])
self.assertTrue(manager.all_unavailable('hub_search'))
self.assertFalse(manager.all_unavailable('hub_tags'))
def test_auth_invalid_remains_global_after_late_endpoint_events(self):
manager = manager_with_accounts()
account = manager.accounts[0]
manager.report_http_status(
account, 'hub_search', 401, Response(status_code=401), 'auth_invalid',
)
manager.report_http_status(
account, 'hub_tags', 429, Response(status_code=429), 'rate_limit',
)
manager.report_success(account, 'registry')
self.assertTrue(manager.all_unavailable('hub_search'))
self.assertTrue(manager.all_unavailable('hub_tags'))
self.assertTrue(manager.all_unavailable('registry'))
self.assertEqual(
manager.cooldown_categories[('account-0', 'hub_search')],
'auth_invalid',
)
self.assertEqual(
[(event['endpoint'], event['category']) for event in manager.drain_status_events()],
[('hub_search', 'auth_invalid')],
)
def test_auth_invalid_account_is_excluded_from_cli_config_rotation(self):
manager = scanner.DockerTokenManager()
manager.accounts = [
scanner.DockerAccount('account-0', 'user-0', 'secret-0', 'config-0'),
scanner.DockerAccount('account-1', 'user-1', 'secret-1', 'config-1'),
]
manager.report_http_status(
manager.accounts[0], 'hub_search', 401,
Response(status_code=401), 'auth_invalid',
)
self.assertEqual(manager.get_next_config(), 'config-1')
self.assertEqual(manager.get_next_config(), 'config-1')
manager.report_http_status(
manager.accounts[1], 'registry', 401,
Response(status_code=401), 'auth_invalid',
)
self.assertIsNone(manager.get_next_config())
def test_persisted_auth_invalid_cannot_be_downgraded(self):
source_config = {
'auth_pool': 'dockerhub_main', 'rate_limit_cooldown': 300,
}
secrets = {'auth_pools': {'dockerhub_main': [{
'name': 'account-0', 'username': 'user-0', 'token': 'fixture-secret',
}]}}
state = {'sources': {'dockerhub': console_runner.default_source_state()}}
events = [
{
'name': 'account-0', 'endpoint': 'hub_search',
'category': 'auth_invalid', 'reset_at': 'manual',
'message': 'Docker hub_search HTTP 401',
},
{
'name': 'account-0', 'endpoint': 'hub_tags',
'category': 'rate_limit',
'reset_at': '2099-01-01T00:00:00+00:00',
'message': 'Docker hub_tags HTTP 429',
},
]
with mock.patch.object(
console_runner, 'drain_docker_auth_events', return_value=events,
):
console_runner.persist_docker_auth_events(
'dockerhub', source_config, state, secrets,
)
status = state['sources']['dockerhub']['auth_status']['account-0']
self.assertEqual(status['status'], 'dead')
self.assertEqual(status['disabled_until'], 'manual')
self.assertEqual(status['disabled_reason'], 'auth_invalid')
self.assertNotIn('fixture-secret', repr(state))
class DockerHubSearchPageTests(unittest.TestCase):
def test_page_helper_returns_validated_structured_data(self):
response = Response(payload={
'count': '26',
'results': [{
'repo_name': 'synthetic/repository-a',
'last_updated': '2026-01-02T03:04:05Z',
'url': 'raw-url-marker',
'token': 'raw-token-marker',
}],
})
with mock.patch.object(
scanner, 'dockerhub_search_response', return_value=response,
) as search, mock.patch.object(
scanner.time, 'perf_counter', side_effect=[10.0, 10.25],
):
result = scanner.fetch_dockerhub_search_page(
'synthetic-query', 2, per_page=25, sort_by='name',
sort_order='asc', request_timeout=9,
)
self.assertEqual(result, {
'page': 2,
'repositories': [{
'repo_name': 'synthetic/repository-a',
'last_updated': '2026-01-02T03:04:05Z',
}],
'total_count': 26,
'elapsed': 0.25,
})
search.assert_called_once_with(
'https://hub.docker.com/v2/search/repositories',
{
'query': 'synthetic-query',
'page': 2,
'page_size': 25,
'sort': 'name',
'order': 'asc',
},
request_timeout=9,
)
self.assertNotIn('raw-url-marker', repr(result))
self.assertNotIn('raw-token-marker', repr(result))
def test_page_helper_rejects_invalid_counts(self):
for count in (None, -1, True, 1.5, float('inf'), 'not-a-count'):
with self.subTest(count=count), mock.patch.object(
scanner, 'dockerhub_search_response',
return_value=Response(payload={'count': count, 'results': []}),
):
with self.assertRaisesRegex(
scanner.DockerHubDiscoveryTransportError,
'invalid result count',
):
scanner.fetch_dockerhub_search_page('synthetic-query', 1)
def test_page_helper_rejects_count_below_absolute_result_bound(self):
response = Response(payload={
'count': 25,
'results': [{'repo_name': 'synthetic/repository-a'}],
})
with mock.patch.object(
scanner, 'dockerhub_search_response', return_value=response,
), self.assertRaisesRegex(
scanner.DockerHubDiscoveryTransportError,
'incoherent pagination evidence',
):
scanner.fetch_dockerhub_search_page(
'synthetic-query', 2, per_page=25,
)
def test_page_helper_rejects_malformed_payloads(self):
payloads = (
[],
{'count': 1},
{'count': 1, 'results': {}},
{'count': 1, 'results': [None]},
{'count': 1, 'results': [{}]},
{'count': 1, 'results': [{'repo_name': ''}]},
{'count': 1, 'results': [{'repo_name': ' '}]},
{'count': 1, 'results': [{'repo_name': 42}]},
)
for payload in payloads:
with self.subTest(payload=payload), mock.patch.object(
scanner, 'dockerhub_search_response',
return_value=Response(payload=payload),
):
with self.assertRaisesRegex(
scanner.DockerHubDiscoveryTransportError,
'invalid payload',
):
scanner.fetch_dockerhub_search_page('synthetic-query', 1)
def test_page_helper_deduplicates_names_in_first_seen_order(self):
response = Response(payload={
'count': 3,
'results': [
{
'repo_name': 'synthetic/repository-a',
'last_updated': '2026-01-03T00:00:00Z',
},
{
'repo_name': 'synthetic/repository-b',
'last_modified': '2026-01-02T00:00:00Z',
},
{
'repo_name': 'synthetic/repository-a',
'last_updated': '2026-01-01T00:00:00Z',
},
],
})
with mock.patch.object(
scanner, 'dockerhub_search_response', return_value=response,
):
result = scanner.fetch_dockerhub_search_page('synthetic-query', 1)
self.assertEqual(result['repositories'], [
{
'repo_name': 'synthetic/repository-a',
'last_updated': '2026-01-03T00:00:00Z',
},
{
'repo_name': 'synthetic/repository-b',
'last_modified': '2026-01-02T00:00:00Z',
},
])
def test_page_helper_preserves_explicit_pool_fail_closed(self):
manager = scanner.DockerTokenManager()
manager.explicit_pool = True
with mock.patch.object(scanner, 'docker_token_manager', manager), \
mock.patch.object(scanner, 'api_request') as request:
with self.assertRaisesRegex(
scanner.DockerHubDiscoveryTransportError,
'failed after bounded attempts',
):
scanner.fetch_dockerhub_search_page('synthetic-query', 1)
request.assert_not_called()
def test_page_helper_preserves_two_get_authenticated_budget(self):
manager = manager_with_accounts()
manager.cache_hub_token('account-0', 'synthetic-bearer', 600)
with mock.patch.object(scanner, 'docker_token_manager', manager), \
mock.patch.object(
scanner, 'api_request',
side_effect=[
scanner.ApiRequestError('raw-url-marker'),
scanner.ApiRequestError('raw-token-marker'),
],
) as request, \
mock.patch.object(scanner, '_wait_or_raise_scan_slot_fatal'):
with self.assertRaises(
scanner.DockerHubDiscoveryTransportError,
) as raised:
scanner.fetch_dockerhub_search_page('synthetic-query', 1)
self.assertEqual(request.call_count, 2)
self.assertTrue(all(
call.kwargs['max_retries'] == 1
for call in request.call_args_list
))
self.assertNotIn('raw-url-marker', str(raised.exception))
self.assertNotIn('raw-token-marker', str(raised.exception))
class DockerHubSearchPaginationTests(unittest.TestCase):
@staticmethod
def response(page, count):
return Response(payload={
'count': count,
'results': [{'repo_name': f'owner/repo-{page}'}],
})
def test_search_caps_at_thirty_pages_and_preserves_page_order(self):
requested_pages = []
lock = threading.Lock()
def search(_url, params, request_timeout=15):
page = int(params['page'])
with lock:
requested_pages.append(page)
if page == 2:
time.sleep(0.02)
return self.response(page, 4000)
with mock.patch.object(scanner, 'dockerhub_search_response', side_effect=search):
repos = scanner.fetch_dockerhub_images(
'fixture', pages=40, per_page=100,
fetch_workers=8, resolve_tags=False,
)
self.assertEqual(requested_pages[0], 1)
self.assertEqual(sorted(requested_pages), list(range(1, 31)))
self.assertEqual(
repos,
[f'owner/repo-{page}' for page in range(1, 31)],
)
def test_page_one_count_limits_expected_request_range(self):
requested_pages = []
def search(_url, params, request_timeout=15):
page = int(params['page'])
requested_pages.append(page)
return self.response(page, 250)
with mock.patch.object(scanner, 'dockerhub_search_response', side_effect=search):
repos = scanner.fetch_dockerhub_images(
'fixture', pages=30, per_page=100, resolve_tags=False,
)
self.assertEqual(sorted(requested_pages), [1, 2, 3])
self.assertEqual(repos, [
'owner/repo-1', 'owner/repo-2', 'owner/repo-3',
])
def test_invalid_page_one_count_fails_closed(self):
for payload in (
{'results': []},
{'count': -1, 'results': []},
):
with self.subTest(payload=payload), mock.patch.object(
scanner, 'dockerhub_search_response',
return_value=Response(payload=payload),
):
with self.assertRaises(scanner.DockerHubDiscoveryTransportError):
scanner.fetch_dockerhub_images(
'fixture', pages=30, per_page=100, resolve_tags=False,
)
def test_expected_page_failure_rejects_all_partial_results(self):
def search(_url, params, request_timeout=15):
page = int(params['page'])
if page == 2:
raise scanner.ApiRequestError('bounded fixture failure')
return self.response(page, 300)
with mock.patch.object(scanner, 'dockerhub_search_response', side_effect=search), \
mock.patch.object(scanner, 'fetch_dockerhub_tags') as tags:
with self.assertRaisesRegex(
scanner.DockerHubDiscoveryTransportError,
'pagination incomplete',
):
scanner.fetch_dockerhub_images(
'fixture', pages=3, per_page=100, resolve_tags=True,
)
tags.assert_not_called()
def test_recent_search_uses_authenticated_response_path(self):
response = Response(payload={'count': 0, 'results': [], 'next': None})
with mock.patch.object(
scanner, 'dockerhub_search_response', return_value=response,
) as search:
repos = scanner.fetch_recent_dockerhub_images(
'fixture', datetime(2026, 1, 1), pages=30,
per_page=100, resolve_tags=False,
)
self.assertEqual(repos, [])
self.assertEqual(search.call_count, 1)
self.assertEqual(search.call_args.args[1]['page'], 1)
self.assertEqual(search.call_args.kwargs['request_timeout'], 30)
def test_recent_search_preserves_page_update_metadata(self):
response = Response(payload={
'count': 1,
'results': [{
'repo_name': 'synthetic/repository-a',
'last_updated': '2025-12-31T00:00:00Z',
}],
})
with mock.patch.object(
scanner, 'dockerhub_search_response', return_value=response,
), mock.patch.object(scanner, 'fetch_dockerhub_last_updated') as metadata:
repos = scanner.fetch_recent_dockerhub_images(
'synthetic-query', datetime(2026, 1, 1), pages=1,
per_page=100, resolve_tags=False,
)
self.assertEqual(repos, [])
metadata.assert_not_called()
class DockerHubSearchCycleTests(unittest.TestCase):
class Connection:
is_postgres = True
def __init__(self, events):
self.events = events
def rollback(self):
self.events.append('rollback')
class DB:
def __init__(self, events):
self.conn = DockerHubSearchCycleTests.Connection(events)
self.events = events
self.finished = []
def start_source_cycle(self, *args, **kwargs):
return 7
def finish_source_cycle(self, *args, **kwargs):
self.events.append('finish')
self.finished.append((args, kwargs))
@staticmethod
def state():
source_state = console_runner.default_source_state()
return {'sources': {'dockerhub': source_state}}
@staticmethod
def patches(args, result):
run_cycle = (
mock.patch.object(console_runner, 'run_cycle', side_effect=result)
if isinstance(result, BaseException)
else mock.patch.object(console_runner, 'run_cycle', return_value=result)
)
return (
mock.patch.object(console_runner, 'save_state'),
mock.patch.object(
console_runner, 'select_auth_entry',
return_value={'name': 'docker-1', 'token': 'fixture'},
),
mock.patch.object(console_runner, 'refresh_auth_summary'),
mock.patch.object(
console_runner, 'build_args_from_source_config',
return_value=args,
),
mock.patch.object(console_runner, 'configure_source_auth'),
mock.patch.object(console_runner, 'persist_docker_auth_events'),
mock.patch.object(console_runner, 'queue_counts_for_args', return_value={}),
run_cycle,
)
def run_source(self, state, db, run_result):
args = SimpleNamespace(platform='docker', mode='search')
patches = self.patches(args, run_result)
with patches[0], patches[1], patches[2], patches[3], \
patches[4], patches[5], patches[6], patches[7]:
return console_runner.run_configured_source(
'dockerhub',
{'global': {}, 'sources': {'dockerhub': {
'queries': ['first', 'second'], 'mode': 'search',
}}},
state, 'state.json', {}, db, run_id=3,
)
def test_failed_pagination_finishes_cycle_without_advancing(self):
events = []
db = self.DB(events)
state = self.state()
metrics = self.run_source(
state, db,
scanner.DockerHubDiscoveryTransportError('bounded failure'),
)
self.assertEqual(events, ['rollback', 'finish'])
self.assertTrue(metrics['discovery_transport_failed'])
self.assertEqual(state['sources']['dockerhub']['query_index'], 0)
self.assertEqual(state['sources']['dockerhub']['last_status'], 'failed')
self.assertEqual(db.finished[0][0][1], 'failed')
def test_successful_cycle_keeps_existing_query_advance(self):
events = []
db = self.DB(events)
state = self.state()
metrics = self.run_source(
state, db, {'fetched_count': 0, 'scanned_count': 0},
)
self.assertEqual(metrics['fetched_count'], 0)
self.assertEqual(state['sources']['dockerhub']['query_index'], 1)
self.assertEqual(state['sources']['dockerhub']['last_status'], 'completed')
if __name__ == '__main__':
unittest.main()