Skip to content

Commit 5b7f6aa

Browse files
authored
feat(batch): Add dynamic OS image selection for jobs (#4993)
## What this PR does This PR introduces the core logic in ClusterFuzz to support running jobs on different base OS images, as outlined in the Ubuntu 24.04 migration plan. It enables the platform to dynamically schedule jobs on different OS versions based on project-level configuration. ## Why this PR is important This change is a prerequisite for migrating ClusterFuzz jobs from the EOL Ubuntu 20.04 to Ubuntu 24.04. By making the platform OS-aware, we can perform a gradual, safe migration without disrupting existing fuzzing jobs. This implementation specifically addresses the requirements of **b/441792600**. ## How the changes were implemented This PR includes three main code changes: 1. **`data_types.py`**: The `OssFuzzProject` model is extended with a `base_os_version` string property. This allows the Datastore to persist the desired OS version for each OSS-Fuzz project. 2. **`cron/project_setup.py`**: The project synchronization cron job is updated. It now reads the `base_os_version` field from a project's `project.yaml` file (in the `oss-fuzz` repository) and saves it to the corresponding `OssFuzzProject` entity in the Datastore. 3. **`google_cloud_utils/batch.py`**: The batch job creation logic is refactored to be backward-compatible and OS-aware: * It now fetches the `base_os_version` for the project associated with a task. * It attempts to find a corresponding image in a new `versioned_docker_images` map in the batch configuration (`batch.yaml`). * If a versioned image is not found (for legacy projects or during the transition), it safely falls back to using the existing `docker_image` key, ensuring no disruption. ## Related Task * **Task:** [b/441792600](b/441792600)
1 parent 5dcfd3e commit 5b7f6aa

File tree

6 files changed

+105
-6
lines changed

6 files changed

+105
-6
lines changed

.gemini/settings.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
{
2-
"contextFileName": "AGENTS.md"
3-
}
2+
"context": {
3+
"fileName": "AGENTS.md"
4+
}
5+
}

src/clusterfuzz/_internal/cron/project_setup.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,7 @@ def create_project_settings(project, info, service_account):
543543

544544
ccs = ccs_from_info(info)
545545
language = info.get('language')
546+
base_os_version = info.get('base_os_version')
546547

547548
if oss_fuzz_project:
548549
if oss_fuzz_project.service_account != service_account['email']:
@@ -556,6 +557,10 @@ def create_project_settings(project, info, service_account):
556557
if oss_fuzz_project.ccs != ccs:
557558
oss_fuzz_project.ccs = ccs
558559
oss_fuzz_project.put()
560+
561+
if oss_fuzz_project.base_os_version != base_os_version:
562+
oss_fuzz_project.base_os_version = base_os_version
563+
oss_fuzz_project.put()
559564
else:
560565
if language in MEMORY_SAFE_LANGUAGES:
561566
cpu_weight = OSS_FUZZ_MEMORY_SAFE_LANGUAGE_PROJECT_WEIGHT
@@ -568,7 +573,8 @@ def create_project_settings(project, info, service_account):
568573
high_end=is_high_end,
569574
cpu_weight=cpu_weight,
570575
service_account=service_account['email'],
571-
ccs=ccs).put()
576+
ccs=ccs,
577+
base_os_version=base_os_version).put()
572578

573579

574580
def _create_pubsub_topic(name, client):

src/clusterfuzz/_internal/datastore/data_types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,9 @@ class Job(Model):
948948
# The platform that this job can run on.
949949
platform = ndb.StringProperty()
950950

951+
# Base OS version for the job.
952+
base_os_version = ndb.StringProperty()
953+
951954
# Blobstore key of the custom binary for this job.
952955
custom_binary_key = ndb.StringProperty()
953956

@@ -1467,6 +1470,9 @@ class OssFuzzProject(Model):
14671470
# CCs for the project.
14681471
ccs = ndb.StringProperty(repeated=True)
14691472

1473+
# Base OS version for the project.
1474+
base_os_version = ndb.StringProperty()
1475+
14701476

14711477
class OssFuzzProjectInfo(Model):
14721478
"""Set up information for a project (cpu allocation, instance groups, service

src/clusterfuzz/_internal/google_cloud_utils/batch.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,19 @@ def _get_config_names(
268268
platform = job.platform if not utils.is_oss_fuzz() else 'LINUX'
269269
disk_size_gb = environment.get_value(
270270
'DISK_SIZE_GB', env=job.get_environment())
271+
# Get the OS version from the job, this is the least specific version.
272+
base_os_version = job.base_os_version
273+
274+
# If we are running in the oss-fuzz context, the project-specific config
275+
# is more specific and overrides the job-level one.
276+
if environment.get_value('PROJECT_NAME') == 'oss-fuzz':
277+
project_name = job.project
278+
oss_fuzz_project = data_types.OssFuzzProject.get_by_id(project_name)
279+
if oss_fuzz_project and oss_fuzz_project.base_os_version:
280+
base_os_version = oss_fuzz_project.base_os_version
281+
271282
config_map[(task.command, task.job_type)] = (f'{platform}{suffix}',
272-
disk_size_gb)
283+
disk_size_gb, base_os_version)
273284
# TODO(metzman): Come up with a more systematic way for configs to
274285
# be overridden by jobs.
275286
return config_map
@@ -308,11 +319,23 @@ def _get_specs_from_config(batch_tasks) -> Dict:
308319
if (task.command, task.job_type) in specs:
309320
# Don't repeat work for no reason.
310321
continue
311-
config_name, disk_size_gb = config_map[(task.command, task.job_type)]
322+
config_name, disk_size_gb, base_os_version = config_map[(task.command,
323+
task.job_type)]
312324

313325
instance_spec = batch_config.get('mapping').get(config_name)
314326
if instance_spec is None:
315327
raise ValueError(f'No mapping for {config_name}')
328+
329+
# Decide which docker image to use.
330+
versioned_images_map = instance_spec.get('versioned_docker_images')
331+
if (base_os_version and versioned_images_map and
332+
base_os_version in versioned_images_map):
333+
# New path: Use the versioned image if specified and available.
334+
docker_image_uri = versioned_images_map[base_os_version]
335+
else:
336+
# Fallback/legacy path: Use the original docker_image key.
337+
docker_image_uri = instance_spec['docker_image']
338+
316339
project_name = batch_config.get('project')
317340
clusterfuzz_release = instance_spec.get('clusterfuzz_release', 'prod')
318341
# Lower numbers are a lower priority, meaning less likely to run From:
@@ -332,7 +355,7 @@ def _get_specs_from_config(batch_tasks) -> Dict:
332355
disk_size_gb = (disk_size_gb or instance_spec['disk_size_gb'])
333356
subconfig = subconfig_map[config_name]
334357
spec = BatchWorkloadSpec(
335-
docker_image=instance_spec['docker_image'],
358+
docker_image=docker_image_uri,
336359
disk_size_gb=disk_size_gb,
337360
disk_type=instance_spec['disk_type'],
338361
user_data=instance_spec['user_data'],

src/clusterfuzz/_internal/tests/appengine/handlers/cron/project_setup_test.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,8 @@ def test_execute(self):
630630
'ccs': [
631631
'primary@example.com', 'user@example.com', 'user2@googlemail.com'
632632
],
633+
'base_os_version':
634+
None,
633635
}, lib1_settings.to_dict())
634636

635637
lib2_settings = ndb.Key(data_types.OssFuzzProject, 'lib2').get()
@@ -644,6 +646,7 @@ def test_execute(self):
644646
'service_account': 'lib3@serviceaccount.com',
645647
'high_end': False,
646648
'ccs': ['user@example.com'],
649+
'base_os_version': None,
647650
}, lib3_settings.to_dict())
648651

649652
lib4_settings = ndb.Key(data_types.OssFuzzProject, 'lib4').get()
@@ -655,6 +658,7 @@ def test_execute(self):
655658
'service_account': 'lib4@serviceaccount.com',
656659
'high_end': True,
657660
'ccs': ['user@example.com'],
661+
'base_os_version': None,
658662
}, lib4_settings.to_dict())
659663

660664
old_lib_settings = ndb.Key(data_types.OssFuzzProject, 'old_lib').get()

src/clusterfuzz/_internal/tests/core/google_cloud_utils/batch_test.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""Batch tests."""
1515

1616
import unittest
17+
from unittest import mock
1718

1819
from clusterfuzz._internal.datastore import data_types
1920
from clusterfuzz._internal.google_cloud_utils import batch
@@ -148,6 +149,63 @@ def test_get_specs_from_config_with_disk_size_override(self):
148149
[batch.BatchTask('fuzz', job_name, None)])
149150
self.assertEqual(spec['fuzz', job_name].disk_size_gb, overridden_size)
150151

152+
@mock.patch('clusterfuzz._internal.google_cloud_utils.batch.environment')
153+
@mock.patch(
154+
'clusterfuzz._internal.datastore.data_types.OssFuzzProject.get_by_id')
155+
@mock.patch('clusterfuzz._internal.datastore.ndb_utils.get_all_from_query')
156+
def test_get_config_names_os_version(self, mock_get_jobs, mock_get_project,
157+
mock_env):
158+
"""Test the hierarchical logic for determining base_os_version."""
159+
# Test Case 1: Internal project, job-level OS version is used.
160+
mock_env.get_value.return_value = 'internal-project'
161+
job1 = data_types.Job(
162+
name='job1', platform='LINUX', base_os_version='job-os-ubuntu-20')
163+
mock_get_jobs.return_value = [job1]
164+
config_map = batch._get_config_names(
165+
[batch.BatchTask('fuzz', 'job1', None)])
166+
self.assertEqual(config_map[('fuzz', 'job1')][2], 'job-os-ubuntu-20')
167+
168+
# Test Case 2: OSS-Fuzz project, project-level version overrides job-level.
169+
mock_env.get_value.return_value = 'oss-fuzz'
170+
job2 = data_types.Job(
171+
name='job2',
172+
project='my-project',
173+
platform='LINUX',
174+
base_os_version='job-os-ubuntu-20')
175+
project = data_types.OssFuzzProject(
176+
name='my-project', base_os_version='project-os-ubuntu-24')
177+
mock_get_jobs.return_value = [job2]
178+
mock_get_project.return_value = project
179+
config_map = batch._get_config_names(
180+
[batch.BatchTask('fuzz', 'job2', None)])
181+
self.assertEqual(config_map[('fuzz', 'job2')][2], 'project-os-ubuntu-24')
182+
183+
# Test Case 3: OSS-Fuzz project, only project-level version exists.
184+
job3 = data_types.Job(name='job3', project='my-project', platform='LINUX')
185+
mock_get_jobs.return_value = [job3]
186+
config_map = batch._get_config_names(
187+
[batch.BatchTask('fuzz', 'job3', None)])
188+
self.assertEqual(config_map[('fuzz', 'job3')][2], 'project-os-ubuntu-24')
189+
190+
# Test Case 4: Internal project, no version is set, should be None.
191+
mock_env.get_value.return_value = 'internal-project'
192+
job4 = data_types.Job(name='job4', platform='LINUX')
193+
mock_get_jobs.return_value = [job4]
194+
config_map = batch._get_config_names(
195+
[batch.BatchTask('fuzz', 'job4', None)])
196+
self.assertIsNone(config_map[('fuzz', 'job4')][2])
197+
198+
# Test Case 5: OSS-Fuzz project, but no versions are set anywhere.
199+
mock_env.get_value.return_value = 'oss-fuzz'
200+
job5 = data_types.Job(
201+
name='job5', project='my-project-no-version', platform='LINUX')
202+
project_no_version = data_types.OssFuzzProject(name='my-project-no-version')
203+
mock_get_jobs.return_value = [job5]
204+
mock_get_project.return_value = project_no_version
205+
config_map = batch._get_config_names(
206+
[batch.BatchTask('fuzz', 'job5', None)])
207+
self.assertIsNone(config_map[('fuzz', 'job5')][2])
208+
151209

152210
def _get_spec_from_config(command, job_name):
153211
return list(

0 commit comments

Comments
 (0)