Skip to content

Commit a54d1d5

Browse files
jardondiegoDiego Jardon
authored andcommitted
Merge branch 'master' into swarming-service-remote-task
2 parents da2d5ed + 9f0c774 commit a54d1d5

8 files changed

Lines changed: 268 additions & 11 deletions

File tree

src/appengine/handlers/fuzzers.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
from libs import helpers
3535

3636
ARCHIVE_READ_SIZE_LIMIT = 16 * 1024 * 1024
37+
FUZZER_FIELDS_EXCLUDED_FROM_LOG = [
38+
'result', 'result_timestamp', 'console_output', 'return_code',
39+
'sample_testcase', 'stats_columns', 'stats_column_descriptions'
40+
]
3741

3842

3943
class Handler(base_handler.Handler):
@@ -139,6 +143,10 @@ def _get_integer_value(self, key):
139143

140144
return value
141145

146+
def _get_fuzzer_state_str(self, fuzzer: data_types.Fuzzer) -> str:
147+
fuzzer_dict = fuzzer.to_dict(exclude=FUZZER_FIELDS_EXCLUDED_FROM_LOG)
148+
return '\n'.join(f"{key}: {val}" for key, val in fuzzer_dict.items())
149+
142150
def apply_fuzzer_changes(self, fuzzer, upload_info):
143151
"""Apply changes to a fuzzer."""
144152
if upload_info and not archive.is_archive(upload_info.filename):
@@ -160,6 +168,8 @@ def apply_fuzzer_changes(self, fuzzer, upload_info):
160168
'uploaded is less than 16MB, ensure that the executable file has '
161169
'"run" in its name.', 400)
162170

171+
existing_fuzzer_info = self._get_fuzzer_state_str(fuzzer)
172+
163173
jobs = request.get('jobs', [])
164174
timeout = self._get_integer_value('timeout')
165175
max_testcases = self._get_integer_value('max_testcases')
@@ -201,7 +211,14 @@ def apply_fuzzer_changes(self, fuzzer, upload_info):
201211

202212
fuzzer_selection.update_mappings_for_fuzzer(fuzzer)
203213

204-
helpers.log('Uploaded fuzzer %s.' % fuzzer.name, helpers.MODIFY_OPERATION)
214+
new_fuzzer_info = self._get_fuzzer_state_str(fuzzer)
215+
fuzzer_diff = helpers.diff(existing_fuzzer_info, new_fuzzer_info)
216+
fuzzer_update_message = (f"\n--- Updated fuzzer {fuzzer.name} ---\n"
217+
f"{new_fuzzer_info}\n"
218+
f"--- Changes (Diff) ---\n"
219+
f"{fuzzer_diff}")
220+
helpers.log(fuzzer_update_message, helpers.MODIFY_OPERATION)
221+
205222
return self.redirect('/fuzzers')
206223

207224

src/appengine/libs/helpers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""helper.py is a kitchen sink. It contains static methods that are used by
1515
multiple handlers."""
1616

17+
import difflib
1718
import logging
1819
import sys
1920
import traceback
@@ -165,3 +166,17 @@ def log(message, operation_type):
165166
"""Logs operation being carried by current logged-in user."""
166167
logging.info('ClusterFuzz: %s (%s): %s.', operation_type, get_user_email(),
167168
message)
169+
170+
171+
def diff(old_str: str, new_str: str) -> str:
172+
"""Generates the diff between the two provided strings."""
173+
old_lines = old_str.splitlines(keepends=True)
174+
new_lines = new_str.splitlines(keepends=True)
175+
176+
diff_generator = difflib.ndiff(old_lines, new_lines)
177+
clean_diff = [
178+
line for line in diff_generator
179+
if line.startswith('- ') or line.startswith('+ ')
180+
]
181+
182+
return "".join(clean_diff)

src/clusterfuzz/_internal/remote_task/remote_task_adapters.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from clusterfuzz._internal.k8s import service as k8s_service
2121
from clusterfuzz._internal.swarming.remote_task_service import RemoteTaskSwarmingService as SwarmingService
2222

23+
2324
class RemoteTaskAdapters(Enum):
2425
"""Defines the supported remote task execution backends.
2526
@@ -37,7 +38,8 @@ class RemoteTaskAdapters(Enum):
3738
feature_flags.FeatureFlags.K8S_JOBS_FREQUENCY, 0.0)
3839
GCP_BATCH = ('gcp_batch', batch_service.GcpBatchService,
3940
feature_flags.FeatureFlags.GCP_BATCH_JOBS_FREQUENCY, 1.0)
40-
SWARMING = ('swarming', SwarmingService, feature_flags.FeatureFlags.SWARMING_REMOTE_EXECUTION, 0.0)
41+
SWARMING = ('swarming', SwarmingService,
42+
feature_flags.FeatureFlags.SWARMING_REMOTE_EXECUTION, 0.0)
4143

4244
def __init__(self, adapter_id, service, feature_flag, default_weight):
4345
self.id = adapter_id

src/clusterfuzz/_internal/swarming/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from clusterfuzz._internal.google_cloud_utils import credentials
2626
from clusterfuzz._internal.protos import swarming_pb2
2727

28+
2829
def is_swarming_task(command: str, job_name: str):
2930
"""Returns True if the task is supposed to run on swarming."""
3031
if not FeatureFlags.SWARMING_REMOTE_EXECUTION.enabled:

src/clusterfuzz/_internal/swarming/remote_task_service.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,37 @@
1717
import clusterfuzz._internal.swarming as swarming
1818
from clusterfuzz._internal.base.tasks import task_utils
1919

20+
2021
class RemoteTaskSwarmingService(remote_task_types.RemoteTaskInterface):
22+
2123
def create_utask_main_job(self, module: str, job_type: str,
2224
input_download_url: str):
2325
"""Creates a single swarming task for a uworker main task."""
2426
command = task_utils.get_command_from_module(module)
25-
if not swarming.is_swarming_task(command, job_type):
26-
return
27-
28-
swarming.push_swarming_task(command, input_download_url, job_type)
29-
30-
def create_utask_main_jobs(
31-
self, remote_tasks: list[remote_task_types.RemoteTask]) -> list[remote_task_types.RemoteTask]:
27+
swarming_task = remote_task_types.RemoteTask(command, job_type,
28+
input_download_url)
29+
result = self.create_utask_main_jobs([swarming_task])
30+
31+
if not result:
32+
return None
33+
34+
return result[0]
35+
36+
def create_utask_main_jobs(self,
37+
remote_tasks: list[remote_task_types.RemoteTask]
38+
) -> list[remote_task_types.RemoteTask]:
3239
"""Creates many remote tasks for uworker main tasks.
3340
Returns the tasks that couldn't be created.
3441
"""
3542
unscheduled_tasks = []
3643
for task in remote_tasks:
3744
try:
38-
self.create_utask_main_job(task.command, task.job_type, task.input_download_url)
45+
if not swarming.is_swarming_task(task.command, task.job_type):
46+
unscheduled_tasks.append(task)
47+
continue
48+
49+
swarming.push_swarming_task(task.command, task.input_download_url,
50+
task.job_type)
3951
except Exception: # pylint: disable=broad-except
4052
unscheduled_tasks.append(task)
41-
return unscheduled_tasks
53+
return unscheduled_tasks
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Tests for fuzzers handler."""
15+
# pylint: disable=protected-access
16+
17+
import datetime
18+
import unittest
19+
20+
from clusterfuzz._internal.datastore import data_types
21+
from handlers import fuzzers
22+
23+
24+
class BaseEditHandlerTest(unittest.TestCase):
25+
"""Test BaseEditHandler."""
26+
27+
def setUp(self):
28+
self.handler = fuzzers.BaseEditHandler()
29+
30+
def test_get_fuzzer_state_str(self):
31+
"""Test that fuzzer state str excludes specific fields."""
32+
fuzzer = data_types.Fuzzer(
33+
name='test_fuzzer',
34+
revision=1,
35+
timeout=10,
36+
result='bad',
37+
console_output='some output',
38+
result_timestamp=datetime.datetime(2021, 1, 1),
39+
return_code=1,
40+
sample_testcase='testcase',
41+
stats_columns='cols',
42+
stats_column_descriptions='desc',
43+
)
44+
45+
state_str = self.handler._get_fuzzer_state_str(fuzzer)
46+
47+
self.assertIn('name: test_fuzzer', state_str)
48+
self.assertIn('revision: 1', state_str)
49+
self.assertIn('timeout: 10', state_str)
50+
51+
# Explicitly excluded fields
52+
self.assertNotIn('result:', state_str)
53+
self.assertNotIn('result_timestamp', state_str)
54+
self.assertNotIn('console_output:', state_str)
55+
self.assertNotIn('return_code:', state_str)
56+
self.assertNotIn('sample_testcase:', state_str)
57+
self.assertNotIn('stats_columns:', state_str)
58+
self.assertNotIn('stats_column_descriptions:', state_str)

src/clusterfuzz/_internal/tests/appengine/libs/helpers_test.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,27 @@ def test_view(self):
159159
helpers.log('message', helpers.VIEW_OPERATION)
160160
self.mock.info.assert_called_once_with(
161161
'ClusterFuzz: %s (%s): %s.', helpers.VIEW_OPERATION, 'email', 'message')
162+
163+
164+
class DiffTest(unittest.TestCase):
165+
"""Test diff."""
166+
167+
def test_diff_empty(self):
168+
"""Test diff with empty strings."""
169+
self.assertEqual(helpers.diff('', ''), '')
170+
171+
def test_diff_no_change(self):
172+
"""Test diff with no changes."""
173+
self.assertEqual(helpers.diff('a\nb\n', 'a\nb\n'), '')
174+
175+
def test_diff_addition(self):
176+
"""Test diff with addition."""
177+
self.assertEqual(helpers.diff('a\nb\n', 'a\nb\nc\n'), '+ c\n')
178+
179+
def test_diff_deletion(self):
180+
"""Test diff with deletion."""
181+
self.assertEqual(helpers.diff('a\nb\nc\n', 'a\nc\n'), '- b\n')
182+
183+
def test_diff_modification(self):
184+
"""Test diff with modification."""
185+
self.assertEqual(helpers.diff('a\nb\nc\n', 'a\nd\nc\n'), '- b\n+ d\n')
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Tests for RemoteTaskSwarmingService."""
15+
16+
import unittest
17+
from unittest import mock
18+
19+
from clusterfuzz._internal.remote_task import remote_task_types
20+
from clusterfuzz._internal.swarming import remote_task_service
21+
from clusterfuzz._internal.tests.test_libs import helpers
22+
23+
24+
class RemoteTaskSwarmingServiceTest(unittest.TestCase):
25+
"""Tests for RemoteTaskSwarmingService."""
26+
27+
def setUp(self):
28+
helpers.patch(self, [
29+
'clusterfuzz._internal.swarming.is_swarming_task',
30+
'clusterfuzz._internal.swarming.push_swarming_task',
31+
'clusterfuzz._internal.base.tasks.task_utils.get_command_from_module',
32+
])
33+
self.service = remote_task_service.RemoteTaskSwarmingService()
34+
35+
def test_create_utask_main_job_success(self):
36+
"""Test creating a single task successfully."""
37+
self.mock.get_command_from_module.return_value = 'fuzz'
38+
self.mock.is_swarming_task.return_value = True
39+
40+
result = self.service.create_utask_main_job('fuzz_task', 'job_type',
41+
'http://url')
42+
43+
# Success returns None in this interface (consistent with GcpBatchService)
44+
self.assertIsNone(result)
45+
46+
self.mock.push_swarming_task.assert_called_once_with(
47+
'fuzz', 'http://url', 'job_type')
48+
49+
def test_create_utask_main_job_failure(self):
50+
"""Test creating a single task that is not a swarming task."""
51+
self.mock.get_command_from_module.return_value = 'fuzz'
52+
self.mock.is_swarming_task.return_value = False
53+
54+
result = self.service.create_utask_main_job('fuzz_task', 'job_type',
55+
'http://url')
56+
57+
# Failure returns the task itself
58+
self.assertIsInstance(result, remote_task_types.RemoteTask)
59+
self.assertEqual(result.command, 'fuzz')
60+
self.mock.push_swarming_task.assert_not_called()
61+
62+
def test_create_utask_main_jobs_mixed_results(self):
63+
"""Test creating multiple tasks with mixed success/failure."""
64+
tasks = [
65+
remote_task_types.RemoteTask('fuzz', 'job1', 'url1'),
66+
remote_task_types.RemoteTask('fuzz', 'job2', 'url2'),
67+
remote_task_types.RemoteTask('fuzz', 'job3', 'url3'),
68+
]
69+
70+
# job1 succeeds, job2 fails (not a swarming task), job3 succeeds
71+
self.mock.is_swarming_task.side_effect = [True, False, True]
72+
73+
unscheduled = self.service.create_utask_main_jobs(tasks)
74+
75+
self.assertEqual(len(unscheduled), 1)
76+
self.assertEqual(unscheduled[0].job_type, 'job2')
77+
78+
self.assertEqual(self.mock.push_swarming_task.call_count, 2)
79+
self.mock.push_swarming_task.assert_has_calls([
80+
mock.call('fuzz', 'url1', 'job1'),
81+
mock.call('fuzz', 'url3', 'job3'),
82+
])
83+
84+
def test_create_utask_main_jobs_all_success(self):
85+
"""Test creating multiple tasks where all succeed."""
86+
tasks = [
87+
remote_task_types.RemoteTask('fuzz', 'job1', 'url1'),
88+
remote_task_types.RemoteTask('fuzz', 'job2', 'url2'),
89+
]
90+
self.mock.is_swarming_task.return_value = True
91+
92+
unscheduled = self.service.create_utask_main_jobs(tasks)
93+
94+
self.assertEqual(unscheduled, [])
95+
self.assertEqual(self.mock.push_swarming_task.call_count, 2)
96+
97+
def test_create_utask_main_jobs_all_fail(self):
98+
"""Test creating multiple tasks where all fail."""
99+
tasks = [
100+
remote_task_types.RemoteTask('fuzz', 'job1', 'url1'),
101+
remote_task_types.RemoteTask('fuzz', 'job2', 'url2'),
102+
]
103+
self.mock.is_swarming_task.return_value = False
104+
105+
unscheduled = self.service.create_utask_main_jobs(tasks)
106+
107+
self.assertEqual(unscheduled, tasks)
108+
self.mock.push_swarming_task.assert_not_called()
109+
110+
def test_create_utask_main_jobs_empty(self):
111+
"""Test creating tasks with an empty list."""
112+
unscheduled = self.service.create_utask_main_jobs([])
113+
self.assertEqual(unscheduled, [])
114+
self.mock.push_swarming_task.assert_not_called()
115+
116+
def test_create_utask_main_jobs_exception(self):
117+
"""Test creating tasks when push_swarming_task raises an exception."""
118+
tasks = [
119+
remote_task_types.RemoteTask('fuzz', 'job1', 'url1'),
120+
]
121+
122+
self.mock.is_swarming_task.return_value = True
123+
self.mock.push_swarming_task.side_effect = Exception('error')
124+
125+
unscheduled = self.service.create_utask_main_jobs(tasks)
126+
127+
self.assertEqual(len(unscheduled), 1)
128+
self.assertEqual(unscheduled[0].job_type, 'job1')

0 commit comments

Comments
 (0)