Files
truf-server/tests/test_docker_depth_experiment.py
T
2026-09-30 20:30:56 +03:00

567 lines
25 KiB
Python

import copy
import os
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
import yaml
ROOT = Path(__file__).resolve().parents[1]
APP_DIR = ROOT / 'app'
sys.path.insert(0, str(APP_DIR))
import console_runner
import docker_depth_experiment as depth_config
import scanner
import supervisor
TEST_QUERIES = [f'query-{index:02d}' for index in range(61)]
def valid_config(enabled=False):
experiment = {
'experiment_key': 'docker-depth-test-v1',
'enabled': enabled,
'queries': list(TEST_QUERIES),
'repositories_per_query': 10,
'shallow_images_per_repository': 1,
'deep_repositories_per_query': 1,
'deep_images_per_repository': 10,
'target_limit': 1200,
'selector_version': depth_config.DOCKER_DEPTH_SELECTOR_VERSION,
}
return {
'global': {
'database_url': 'postgresql://example.invalid/truf',
'sync_file_queues': False,
},
'sources': {
'dockerhub': {
'mode': 'search',
'require_digest': True,
'queries': list(TEST_QUERIES),
'pages': 30,
'per_page': 100,
'docker_platform_filter_enabled': True,
'docker_platform_os': 'linux',
'docker_platform_arch': 'amd64',
'docker_platform_candidate_tags': 20,
'docker_images_per_repository': 3,
'docker_depth_experiment': experiment,
},
},
}
def layer(value):
return 'sha256:' + f'{value:064x}'
def graph_candidate(target, updated_at, source_index, layers):
return {
'target': target,
'updated_at': updated_at,
'source_index': source_index,
'layers': tuple(layer(value) for value in layers),
}
class DockerDepthConfigurationTests(unittest.TestCase):
def test_image_depth_accepts_only_strict_integers_from_one_through_ten(self):
self.assertEqual(depth_config.validate_docker_images_per_repository(1), 1)
self.assertEqual(depth_config.validate_docker_images_per_repository(10), 10)
for value in (
False, True, None, '1', '10', 1.0, 10.0, [], {}, 0, -1, 11,
):
with self.subTest(value=value), self.assertRaises(ValueError):
depth_config.validate_docker_images_per_repository(value)
with self.subTest(scanner_value=value), self.assertRaises(ValueError):
scanner.docker_images_per_repository_limit(value)
def test_experiment_mapping_rejects_unknown_missing_and_malformed_ids(self):
config = valid_config()
config['sources']['dockerhub']['docker_depth_experiment']['unexpected'] = 1
with self.assertRaisesRegex(ValueError, 'unsupported keys: unexpected'):
depth_config.validate_docker_depth_config(config)
config = valid_config()
del config['sources']['dockerhub']['docker_depth_experiment']['target_limit']
with self.assertRaisesRegex(ValueError, 'missing required keys: target_limit'):
depth_config.validate_docker_depth_config(config)
for identifier in ('', 'Docker Depth', '../depth', 'depth/', '-depth'):
config = valid_config()
config['sources']['dockerhub']['docker_depth_experiment']['experiment_key'] = identifier
with self.subTest(identifier=identifier), self.assertRaisesRegex(
ValueError, 'stable identifier',
):
depth_config.validate_docker_depth_config(config)
def test_experiment_queries_candidate_depth_cap_and_fixed_limits_fail_closed(self):
config = valid_config()
config['sources']['dockerhub']['docker_depth_experiment']['queries'][-1] = TEST_QUERIES[0]
config['sources']['dockerhub']['queries'][-1] = TEST_QUERIES[0]
with self.assertRaisesRegex(ValueError, 'must be unique'):
depth_config.validate_docker_depth_config(config)
config = valid_config()
config['sources']['dockerhub']['queries'] = list(reversed(TEST_QUERIES))
with self.assertRaisesRegex(ValueError, 'exactly match ordered'):
depth_config.validate_docker_depth_config(config)
config = valid_config()
config['sources']['dockerhub']['docker_platform_candidate_tags'] = 9
with self.assertRaisesRegex(ValueError, 'at least the configured deep image depth'):
depth_config.validate_docker_depth_config(config)
config = valid_config(enabled=True)
config['sources']['dockerhub']['docker_repository_refresh_max_per_cycle'] = 1
with self.assertRaisesRegex(
ValueError, 'docker_repository_refresh_max_per_cycle=0',
):
depth_config.validate_docker_depth_config(config)
config = valid_config(enabled=False)
config['sources']['dockerhub']['docker_repository_refresh_max_per_cycle'] = 1
self.assertIsNotNone(
depth_config.validate_docker_depth_config(config).experiment
)
config = valid_config()
config['sources']['dockerhub']['docker_depth_experiment']['target_limit'] = 1158
with self.assertRaisesRegex(ValueError, 'theoretical target maximum'):
depth_config.validate_docker_depth_config(config)
for key, value in (
('repositories_per_query', 9),
('shallow_images_per_repository', 2),
('deep_repositories_per_query', 2),
('deep_images_per_repository', 9),
('target_limit', 1201),
):
config = valid_config()
config['sources']['dockerhub']['docker_depth_experiment'][key] = value
with self.subTest(key=key), self.assertRaises(ValueError):
depth_config.validate_docker_depth_config(config)
def test_disabled_collection_keeps_ordinary_fifo_resolution_at_three(self):
config = valid_config(enabled=False)
validated = depth_config.validate_docker_depth_config(config)
source = validated.normalized_config['sources']['dockerhub']
args = console_runner.build_args_from_source_config(
'dockerhub', source, validated.normalized_config['global'], TEST_QUERIES[0],
)
self.assertEqual(args.docker_images_per_repository, 3)
self.assertEqual(validated.experiment.deep_images_per_repository, 10)
invalid = valid_config(enabled=False)
invalid['sources']['dockerhub']['docker_images_per_repository'] = 10
with self.assertRaisesRegex(ValueError, 'ordinary resolver depth 3'):
depth_config.validate_docker_depth_config(invalid)
class DB:
last_error = ''
def __init__(self):
self.claimed = False
self.finished = []
def claim_docker_resolutions(self, *_args, **_kwargs):
if self.claimed:
return []
self.claimed = True
return [{'id': 1, 'target': 'owner/repository', 'resolver_token': 'token'}]
def finish_docker_resolution(self, *values, **kwargs):
self.finished.append((values, kwargs))
return True
selected = []
def fetch(_repository, _since, limit, *_args, **_kwargs):
candidates = [
graph_candidate(f'image-{index}', 100 - index, index, (index + 1,))
for index in range(10)
]
selected.extend(scanner.select_docker_layer_graphs(candidates, limit))
return SimpleNamespace(
tags=tuple(item['target'] for item in selected), status='ok',
remote_attempted=True, retry_at=None, error='',
)
database = DB()
with mock.patch.object(console_runner, 'fetch_dockerhub_tags', side_effect=fetch), \
mock.patch('builtins.print'):
self.assertEqual(
console_runner.resolve_due_docker_queue_targets(
database, 'dockerhub', args,
),
1,
)
self.assertEqual([item['rank'] for item in selected], [1, 2, 3])
self.assertEqual(len(database.finished[0][0][3]), 3)
def test_enabled_and_disabled_collection_require_all_static_authorities(self):
for enabled in (False, True):
config = valid_config(enabled=enabled)
validated = depth_config.validate_docker_depth_config(config)
self.assertIs(validated.experiment.enabled, enabled)
with self.subTest(enabled=enabled), self.assertRaisesRegex(
ValueError, 'managed PostgreSQL',
):
depth_config.validate_docker_depth_config(
config, managed_postgres=False, final_cutover=True,
)
with self.subTest(enabled=enabled), self.assertRaisesRegex(
ValueError, 'final cutover',
):
depth_config.validate_docker_depth_config(
config, managed_postgres=True, final_cutover=False,
)
for key, value, message in (
('mode', 'recent', 'search mode'),
('require_digest', False, 'require_digest=true'),
('sync_file_queues', True, 'final cutover'),
):
invalid = valid_config(enabled=enabled)
invalid['sources']['dockerhub'][key] = value
with self.subTest(enabled=enabled, key=key), self.assertRaisesRegex(
ValueError, message,
):
depth_config.validate_docker_depth_config(invalid)
invalid = valid_config(enabled=enabled)
invalid['global']['database_url'] = 'sqlite:///scanner.db'
with self.subTest(enabled=enabled), self.assertRaisesRegex(
ValueError, 'PostgreSQL database_url',
):
depth_config.validate_docker_depth_config(
invalid, managed_postgres=True,
)
def test_disabled_collection_rejects_late_policy_and_platform_coercions(self):
for key, value, message in (
('pages', '30', 'pages must be an integer'),
('per_page', True, 'per_page must be an integer'),
('docker_platform_candidate_tags', '20', 'must be an integer'),
('docker_platform_candidate_tags', 101, 'must be from 1 through 100'),
('docker_platform_filter_enabled', 'true', 'must be a boolean'),
('docker_platform_os', 'windows', 'supports only linux/amd64'),
('docker_platform_arch', 'arm64', 'supports only linux/amd64'),
):
invalid = valid_config(enabled=False)
invalid['sources']['dockerhub'][key] = value
with self.subTest(key=key, value=value), self.assertRaisesRegex(
ValueError, message,
):
depth_config.validate_docker_depth_config(invalid)
invalid = valid_config(enabled=False)
invalid['sources']['dockerhub']['query_overrides'] = []
with self.assertRaisesRegex(ValueError, 'query_overrides must be a mapping'):
depth_config.validate_docker_depth_config(invalid)
invalid = valid_config(enabled=False)
invalid['sources']['dockerhub']['query_overrides'] = {
'not-configured': {'pages': 30},
}
with self.assertRaisesRegex(ValueError, 'unconfigured queries'):
depth_config.validate_docker_depth_config(invalid)
invalid = valid_config(enabled=False)
invalid['sources']['dockerhub']['query_overrides'] = {
TEST_QUERIES[0]: {'pages': '30'},
}
with self.assertRaisesRegex(ValueError, 'pages.*must be an integer'):
depth_config.validate_docker_depth_config(invalid)
def test_mixed_experiment_discovery_policies_are_rejected(self):
config = valid_config(enabled=False)
config['sources']['dockerhub']['query_overrides'] = {
TEST_QUERIES[0]: {'pages': 29},
}
with self.assertRaisesRegex(ValueError, 'one consistent pages/per_page'):
depth_config.validate_docker_depth_config(config)
def test_validation_is_side_effect_free_and_hashes_are_canonical(self):
config = valid_config()
original = copy.deepcopy(config)
first = depth_config.validate_docker_depth_config(config)
self.assertEqual(config, original)
self.assertIsNot(first.normalized_config, config)
self.assertEqual(first.experiment.theoretical_max_targets, 1159)
reordered = copy.deepcopy(config)
mapping = reordered['sources']['dockerhub']['docker_depth_experiment']
reordered['sources']['dockerhub']['docker_depth_experiment'] = dict(
reversed(list(mapping.items()))
)
second = depth_config.validate_docker_depth_config(reordered)
self.assertEqual(
(first.ordered_query_hash, first.config_hash, first.selector_hash),
(second.ordered_query_hash, second.config_hash, second.selector_hash),
)
changed = copy.deepcopy(config)
changed_queries = list(reversed(TEST_QUERIES))
changed['sources']['dockerhub']['queries'] = changed_queries
changed['sources']['dockerhub']['docker_depth_experiment']['queries'] = changed_queries
third = depth_config.validate_docker_depth_config(changed)
self.assertNotEqual(first.ordered_query_hash, third.ordered_query_hash)
self.assertNotEqual(first.config_hash, third.config_hash)
changed_policy = copy.deepcopy(config)
changed_policy['sources']['dockerhub']['pages'] = 29
policy_hash = depth_config.validate_docker_depth_config(changed_policy)
self.assertNotEqual(first.config_hash, policy_hash.config_hash)
without_filter = copy.deepcopy(config)
without_filter['sources']['dockerhub']['docker_platform_filter_enabled'] = False
fourth = depth_config.validate_docker_depth_config(without_filter)
self.assertNotEqual(first.config_hash, fourth.config_hash)
arm64 = copy.deepcopy(without_filter)
arm64['sources']['dockerhub']['docker_platform_arch'] = 'arm64'
fifth = depth_config.validate_docker_depth_config(arm64)
self.assertNotEqual(fourth.config_hash, fifth.config_hash)
windows = copy.deepcopy(without_filter)
windows['sources']['dockerhub']['docker_platform_os'] = 'windows'
sixth = depth_config.validate_docker_depth_config(windows)
self.assertNotEqual(fourth.config_hash, sixth.config_hash)
enabled = valid_config(enabled=True)
activated = depth_config.validate_docker_depth_config(enabled)
self.assertFalse(first.experiment.enabled)
self.assertTrue(activated.experiment.enabled)
self.assertEqual(first.config_hash, activated.config_hash)
unauthorized = copy.deepcopy(enabled)
unauthorized['sources']['dockerhub']['pages'] = 29
self.assertNotEqual(
activated.config_hash,
depth_config.validate_docker_depth_config(unauthorized).config_hash,
)
def test_production_returns_to_default_docker_depth(self):
with open(APP_DIR / 'config.yaml', 'r', encoding='utf-8') as handle:
configured = yaml.safe_load(handle)
validated = depth_config.validate_docker_depth_config(configured)
source = configured['sources']['dockerhub']
self.assertEqual(source['docker_images_per_repository'], 3)
self.assertNotIn('docker_depth_experiment', source)
self.assertIsNone(validated.experiment)
class DockerDepthStartupOrderingTests(unittest.TestCase):
def invalid_config_path(self, directory):
path = Path(directory) / 'invalid-config.yaml'
config = valid_config(enabled=False)
config['sources']['dockerhub']['pages'] = '30'
path.write_text(yaml.safe_dump(config), encoding='utf-8')
return str(path)
def test_supervisor_validation_precedes_lifecycle_workers_secrets_state_and_database(self):
with tempfile.TemporaryDirectory() as directory:
path = self.invalid_config_path(directory)
args = SimpleNamespace(
config=path, sources=None, dry_run=False, background_status=False,
cmd=None, stop_background=False, attach=False, background=True,
with_postgres=True, background_child=False,
)
with mock.patch.object(supervisor, 'parse_args', return_value=args), \
mock.patch.dict(os.environ, {
supervisor.RUNTIME_BOOTSTRAP_ENV: supervisor.RUNTIME_BOOTSTRAP_VALUE,
}, clear=False), \
mock.patch.object(supervisor, 'preflight_lifecycle_paths') as lifecycle, \
mock.patch.object(supervisor, 'start_background') as background, \
mock.patch.object(supervisor, 'load_postgres_env') as secrets, \
mock.patch.object(supervisor, 'ManagedSource') as worker, \
mock.patch.object(supervisor, 'connect_postgres') as database:
with self.assertRaisesRegex(ValueError, 'pages must be an integer'):
supervisor.main()
for side_effect in (lifecycle, background, secrets, worker, database):
side_effect.assert_not_called()
def test_scanner_child_validation_precedes_lifecycle_state_network_secrets_and_database(self):
with tempfile.TemporaryDirectory() as directory:
path = self.invalid_config_path(directory)
args = SimpleNamespace(show_state=False, config=path)
with mock.patch.object(console_runner, 'parse_args', return_value=args), \
mock.patch.dict(os.environ, {
'TRUF_MANAGED_POSTGRES_DSN': 'postgresql://managed.invalid/truf',
}, clear=False), \
mock.patch.object(console_runner, 'require_active_supervisor_child') as lifecycle, \
mock.patch.object(console_runner, 'load_state') as state, \
mock.patch.object(console_runner, 'initialize_scanner_runtime') as network, \
mock.patch.object(console_runner, 'load_secrets') as secrets, \
mock.patch.object(console_runner, 'ScannerDB') as database:
with self.assertRaisesRegex(ValueError, 'pages must be an integer'):
console_runner.main()
for side_effect in (lifecycle, state, network, secrets, database):
side_effect.assert_not_called()
class DockerLayerGraphSelectionTests(unittest.TestCase):
def test_selector_hash_binds_executable_source_decisions_and_version(self):
version = depth_config.DOCKER_DEPTH_SELECTOR_VERSION
baseline = depth_config.canonical_selector_hash(version)
self.assertIs(scanner.select_docker_layer_graphs, depth_config.select_docker_layer_graphs)
with mock.patch.object(
depth_config,
'_SELECTOR_LATER_SCORE',
'changed_marginal_layer_rule',
):
self.assertNotEqual(
baseline, depth_config.canonical_selector_hash(version),
)
getsource = depth_config.inspect.getsource
def changed_selector_source(function):
source = getsource(function)
if function is depth_config.select_docker_layer_graphs:
original = 'while len(selected) < limit and distinct:'
self.assertIn(original, source)
return source.replace(
original,
'while len(selected) <= limit and distinct:',
1,
)
return source
with mock.patch.object(
depth_config.inspect,
'getsource',
side_effect=changed_selector_source,
):
self.assertNotEqual(
baseline, depth_config.canonical_selector_hash(version),
)
self.assertNotEqual(
baseline, depth_config.canonical_selector_hash(version + '-changed'),
)
def first_three(self):
return [
graph_candidate('newest', 100, 0, (1, 2)),
graph_candidate('maximum-novelty', 80, 1, (3, 4, 5)),
graph_candidate('oldest', 0, 2, (6,)),
]
def test_first_three_semantics_and_metadata_are_preserved(self):
selected = scanner.select_docker_layer_graphs(self.first_three(), 3)
self.assertEqual(
[record['target'] for record in selected],
['newest', 'maximum-novelty', 'oldest'],
)
self.assertEqual(
[record['reason'] for record in selected],
[
'newest_distinct_graph',
'maximum_marginal_layer_novelty',
'oldest_distinct_graph',
],
)
self.assertEqual([record['rank'] for record in selected], [1, 2, 3])
self.assertEqual(selected[0]['image_rank'], 1)
self.assertEqual(selected[0]['graph'], selected[0]['layers'])
self.assertEqual(selected[0]['graph_hash'], selected[0]['graph_sha256'])
self.assertEqual(selected[0]['layer_count'], 2)
self.assertEqual(
selected[0]['layer_metadata'],
(
{'digest': layer(1), 'position_from_base': 1, 'position_from_top': 2},
{'digest': layer(2), 'position_from_base': 2, 'position_from_top': 1},
),
)
def test_ranks_four_through_ten_are_deterministic_and_skip_bad_or_duplicate_graphs(self):
candidates = self.first_three() + [
graph_candidate(f'candidate-{index}', 75 - index, index, (index + 10,))
for index in range(3, 14)
]
candidates.extend([
graph_candidate('newest-alias', 99, 99, (1, 2)),
{'target': 'empty', 'updated_at': 200, 'source_index': 100, 'layers': ()},
{'target': 'malformed', 'updated_at': 201, 'source_index': 101,
'layers': ('not-a-digest',)},
])
selected = scanner.select_docker_layer_graphs(candidates, 10)
reversed_selected = scanner.select_docker_layer_graphs(list(reversed(candidates)), 10)
self.assertEqual(len(selected), 10)
self.assertEqual([item['rank'] for item in selected], list(range(1, 11)))
self.assertEqual(
[item['target'] for item in selected],
[item['target'] for item in reversed_selected],
)
self.assertEqual(len({item['graph_hash'] for item in selected}), 10)
self.assertNotIn('newest-alias', [item['target'] for item in selected])
self.assertNotIn('empty', [item['target'] for item in selected])
self.assertNotIn('malformed', [item['target'] for item in selected])
def test_later_rank_ties_use_temporal_recency_target_and_graph_hash_order(self):
cases = [
(
[
graph_candidate('temporally-far', 40, 10, (20,)),
graph_candidate('temporally-near', 60, 11, (21,)),
],
'temporally-far',
),
(
[
graph_candidate('older', 30, 10, (22,)),
graph_candidate('newer', 50, 11, (23,)),
],
'newer',
),
(
[
graph_candidate('repo/b', 50, 10, (24,)),
graph_candidate('repo/a', 50, 11, (25,)),
],
'repo/a',
),
]
for candidates, expected_target in cases:
with self.subTest(expected_target=expected_target):
selected = scanner.select_docker_layer_graphs(
self.first_three() + candidates, 4,
)
self.assertEqual(selected[3]['target'], expected_target)
graph_tie = [
graph_candidate('same-target', 50, 10, (26,)),
graph_candidate('same-target', 50, 11, (27,)),
]
expected_hash = min(
depth_config.canonical_docker_layer_graph_hash((layer(26),)),
depth_config.canonical_docker_layer_graph_hash((layer(27),)),
)
selected = scanner.select_docker_layer_graphs(self.first_three() + graph_tie, 4)
self.assertEqual(selected[3]['graph_hash'], expected_hash)
def test_resolution_outcome_metadata_fields_default_for_existing_callers(self):
outcome = scanner.DockerTagResolutionOutcome(
tags=(), status='empty', remote_attempted=False,
)
self.assertEqual(outcome.selection_records, ())
self.assertEqual(outcome.selections, ())
self.assertEqual(outcome.selector_version, '')
self.assertEqual(outcome.selector_hash, '')
if __name__ == '__main__':
unittest.main()