Repository: google/clusterfuzz

Issue:
Add fuzzer-to-job configurability on /jobs page and auto-associate inprocess fuzzers.
In libFuzzer / AFL / builtin fuzzer world, our more common usecase is to associate fuzzer with a new job. so, move checkbox from fuzzers page to jobs page.
Add fuzzer-to-job configurability on /jobs page and auto-associate inprocess fuzzers.
In libFuzzer / AFL / builtin fuzzer world, our more common usecase is to associate fuzzer with a new job. so, move checkbox from fuzzers page to jobs page.


Discussion:
I don't think we should move, just adding another one to /jobs can help.
See also https://github.com/google/clusterfuzz/issues/331
I don't think we should move, just adding another one to /jobs can help.
See also https://github.com/google/clusterfuzz/issues/331

Resolving patch:
diff --git a/src/appengine/handlers/jobs.py b/src/appengine/handlers/jobs.py
--- a/src/appengine/handlers/jobs.py
+++ b/src/appengine/handlers/jobs.py
@@ -53,6 +53,16 @@ def get_queues():
   return queues
 
 
+def _job_to_dict(job):
+  """Return a dict of job items along with associated fuzzers."""
+  result = job.to_dict()
+  result['id'] = job.key.id()
+  # Adding all associated fuzzers with each job.
+  fuzzers = data_types.Fuzzer.query().filter(data_types.Fuzzer.jobs == job.name)
+  result['fuzzers'] = [fuzzer.name for fuzzer in fuzzers]
+  return result
+
+
 def get_results(this):
   """Get results for the jobs page."""
 
@@ -69,7 +79,7 @@ def get_results(this):
 
   result = {
       'hasMore': has_more,
-      'items': items,
+      'items': [_job_to_dict(item) for item in items],
       'page': page,
       'pageSize': PAGE_SIZE,
       'totalItems': total_items,
@@ -90,6 +100,9 @@ def get(self):
     templates = list(data_types.JobTemplate.query().order(
         data_types.JobTemplate.name))
     queues = get_queues()
+    fuzzers = [
+        fuzzer.name for fuzzer in data_types.Fuzzer.query(projection=['name'])
+    ]
 
     result, params = get_results(self)
     self.render(
@@ -98,6 +111,7 @@ def get(self):
             'templates': templates,
             'fieldValues': {
                 'csrf_token': form.generate_csrf_token(),
+                'fuzzers': fuzzers,
                 'queues': queues,
                 'update_job_url': '/update-job',
                 'update_job_template_url': '/update-job-template',
@@ -123,6 +137,7 @@ def post(self):
           'Job name can only contain letters, numbers, dashes and underscores.',
           400)
 
+    fuzzers = self.request.get('fuzzers', []).split(',')
     templates = self.request.get('templates', '').splitlines()
     for template in templates:
       if not data_types.JobTemplate.query(
@@ -170,6 +185,7 @@ def post(self):
 
     job.put()
 
+    fuzzer_selection.update_mappings_for_job(job, fuzzers)
     if recreate_fuzzer_mappings:
       fuzzer_selection.update_platform_for_job(name, new_platform)
 
diff --git a/src/python/fuzzing/fuzzer_selection.py b/src/python/fuzzing/fuzzer_selection.py
--- a/src/python/fuzzing/fuzzer_selection.py
+++ b/src/python/fuzzing/fuzzer_selection.py
@@ -21,6 +21,7 @@
 from datastore import data_types
 from datastore import fuzz_target_utils
 from datastore import ndb_utils
+from google.cloud import ndb
 from metrics import logs
 from system import environment
 
@@ -62,6 +63,38 @@ def update_mappings_for_fuzzer(fuzzer, mappings=None):
   ndb_utils.delete_multi([m.key for m in list(old_mappings.values())])
 
 
+def update_mappings_for_job(job, mappings):
+  """Clear existing mappings for a job, and replace them."""
+  existing_fuzzers_query = data_types.Fuzzer.query(
+      data_types.Fuzzer.jobs == job.name)
+  existing_fuzzers = {fuzzer.name: fuzzer for fuzzer in existing_fuzzers_query}
+  modified_fuzzers = []
+
+  for fuzzer_name in mappings:
+    fuzzer = existing_fuzzers.pop(fuzzer_name, None)
+    if fuzzer:
+      continue
+
+    fuzzer = data_types.Fuzzer.query(
+        data_types.Fuzzer.name == fuzzer_name).get()
+    if not fuzzer:
+      logs.log_error('An unknown fuzzer %s was selected for job %s.' %
+                     (fuzzer_name, job.name))
+      continue
+
+    fuzzer.jobs.append(job.name)
+    modified_fuzzers.append(fuzzer)
+    update_mappings_for_fuzzer(fuzzer)
+
+  # Removing the remaining values in exisiting_fuzzers as
+  # they are no longer mapped.
+  for fuzzer in existing_fuzzers.values():
+    fuzzer.jobs.remove(job.name)
+    modified_fuzzers.append(fuzzer)
+    update_mappings_for_fuzzer(fuzzer)
+  ndb.put_multi(modified_fuzzers)
+
+
 def update_platform_for_job(job_name, new_platform):
   """Update platform for all mappings for a particular job."""
   query = data_types.FuzzerJob.query()


Test patch:
diff --git a/src/python/tests/appengine/handlers/jobs_test.py b/src/python/tests/appengine/handlers/jobs_test.py
--- a/src/python/tests/appengine/handlers/jobs_test.py
+++ b/src/python/tests/appengine/handlers/jobs_test.py
@@ -20,8 +20,10 @@
 
 from datastore import data_types
 from handlers import jobs
+from libs import form
 from tests.test_libs import helpers as test_helpers
 from tests.test_libs import test_utils
+from unittest import mock
 
 
 @test_utils.with_cloud_emulators('datastore')
@@ -81,6 +83,21 @@ def test_post(self):
     resp = self.app.post_json('/', {'page': 4})
     self.assertListEqual([], [item['id'] for item in resp.json['items']])
 
+  def test_fuzzers_result(self):
+    """Test fuzzers result obtained in post method."""
+    self.mock.has_access.return_value = True
+
+    job = self._create_job('test_job', 'APP_NAME = launcher.py\n')
+    fuzzer = data_types.Fuzzer()
+    fuzzer.name = 'fuzzer'
+    fuzzer.jobs = ['test_job']
+    fuzzer.put()
+
+    resp = self.app.post_json('/', {'page': 1})
+    self.assertListEqual([job.key.id()],
+                         [item['id'] for item in resp.json['items']])
+    self.assertIn('fuzzer', resp.json['items'][0]['fuzzers'])
+
 
 @test_utils.with_cloud_emulators('datastore')
 class JobsSearchTest(unittest.TestCase):
@@ -149,3 +166,54 @@ def test_post(self):
     self.assertListEqual(
         [job_asan.key.id(), job_ubsan.key.id()],
         [item['id'] for item in resp.json['items']])
+
+
+@test_utils.with_cloud_emulators('datastore')
+class JobsUpdateTest(unittest.TestCase):
+  """Job update tests."""
+
+  def setUp(self):
+    test_helpers.patch(self, [
+        'libs.auth.get_current_user',
+        'libs.access.has_access',
+        'libs.access.get_access',
+        'libs.gcs.prepare_blob_upload',
+        'fuzzing.fuzzer_selection.update_mappings_for_job',
+    ])
+    self.mock.get_current_user().email = 'test@user.com'
+    self.mock.prepare_blob_upload.return_value = (
+        collections.namedtuple('GcsUpload', [])())
+    self.app = webtest.TestApp(webapp2.WSGIApplication([('/', jobs.UpdateJob)]))
+
+  def _create_job(self,
+                  name,
+                  environment_string,
+                  description='',
+                  platform='LINUX'):
+    """Create a test job."""
+    job = data_types.Job()
+    job.name = name
+    if environment_string.strip():
+      job.environment_string = environment_string
+    job.platform = platform
+    job.descripton = description
+    job.put()
+
+    return job
+
+  def test_post(self):
+    """Test post method."""
+    self.mock.has_access.return_value = True
+    job = self._create_job('test_job', 'PROJECT_NAME = proj\n')
+    resp = self.app.post(
+        '/', {
+            'csrf_token': form.generate_csrf_token(),
+            'name': job.name,
+            'desciption': job.description,
+            'platform': job.platform,
+            'fuzzers': ['test_fuzzer']
+        },
+        expect_errors=True)
+    self.assertEqual(200, resp.status_int)
+    self.mock.update_mappings_for_job.assert_called_with(
+        mock.ANY, ['test_fuzzer'])
diff --git a/src/python/tests/core/fuzzing/fuzzer_selection_test.py b/src/python/tests/core/fuzzing/fuzzer_selection_test.py
--- a/src/python/tests/core/fuzzing/fuzzer_selection_test.py
+++ b/src/python/tests/core/fuzzing/fuzzer_selection_test.py
@@ -32,6 +32,12 @@ def _get_job_list_for_fuzzer(fuzzer):
   return [m.job for m in ndb_utils.get_all_from_query(query)]
 
 
+def _get_fuzzer_list_for_job(job):
+  """Helper function to return the mappings for a job as a list."""
+  fuzzers = data_types.Fuzzer.query().filter(data_types.Fuzzer.jobs == job.name)
+  return [fuzzer.name for fuzzer in fuzzers]
+
+
 @test_utils.with_cloud_emulators('datastore')
 class UpdateMappingsForFuzzerTest(unittest.TestCase):
   """Tests for the update_mappings_for_fuzzer function."""
@@ -123,6 +129,92 @@ def test_mapping_removed(self):
     self.assertEqual([], mappings)
 
 
+@test_utils.with_cloud_emulators('datastore')
+class UpdateMappingsForJobTest(unittest.TestCase):
+  """Tests for the update_mappings_for_job function."""
+
+  def setUp(self):
+    self.fuzzer_1 = data_types.Fuzzer()
+    self.fuzzer_1.name = 'fuzzer_1'
+    self.fuzzer_1.put()
+
+    self.fuzzer_2 = data_types.Fuzzer()
+    self.fuzzer_2.name = 'fuzzer_2'
+    self.fuzzer_2.put()
+
+  def test_no_mappings(self):
+    """Ensure that we do nothing if a job has no mappings."""
+    job = data_types.Job()
+    job.name = 'no_mappings'
+    job.put()
+
+    fuzzer_selection.update_mappings_for_job(job, [])
+
+    self.assertEqual(_get_fuzzer_list_for_job(job), [])
+
+  def test_new_addition(self):
+    """Ensure that we add mappings for a new job."""
+    job = data_types.Job()
+    job.name = 'new_addition'
+    job.put()
+
+    fuzzer_selection.update_mappings_for_job(job, ['fuzzer_1', 'fuzzer_2'])
+
+    mappings = _get_fuzzer_list_for_job(job)
+    self.assertIn('fuzzer_1', mappings)
+    self.assertIn('fuzzer_2', mappings)
+
+  def test_mapping_added(self):
+    """Ensure that we properly add mappings for existing jobs."""
+    job = data_types.Job()
+    job.name = 'adding_fuzzers'
+    job.put()
+
+    fuzzer_selection.update_mappings_for_job(job, ['fuzzer_1'])
+
+    mappings = _get_fuzzer_list_for_job(job)
+    self.assertIn('fuzzer_1', mappings)
+    self.assertNotIn('fuzzer_2', mappings)
+
+    fuzzer_selection.update_mappings_for_job(job, ['fuzzer_1', 'fuzzer_2'])
+
+    mappings = _get_fuzzer_list_for_job(job)
+    self.assertIn('fuzzer_1', mappings)
+    self.assertIn('fuzzer_2', mappings)
+
+  def test_mapping_substituted(self):
+    """Ensure that mappings are substituted properly."""
+    job = data_types.Job()
+    job.name = 'substitute_fuzzers'
+    job.put()
+
+    fuzzer_selection.update_mappings_for_job(job, ['fuzzer_1'])
+
+    mappings = _get_fuzzer_list_for_job(job)
+    self.assertIn('fuzzer_1', mappings)
+    self.assertNotIn('fuzzer_2', mappings)
+
+    fuzzer_selection.update_mappings_for_job(job, ['fuzzer_2'])
+
+    mappings = _get_fuzzer_list_for_job(job)
+    self.assertNotIn('fuzzer_1', mappings)
+    self.assertIn('fuzzer_2', mappings)
+
+  def test_mapping_removed(self):
+    """Ensure that mappings are removed properly."""
+    job = data_types.Job()
+    job.name = 'remove_fuzzer'
+    job.put()
+
+    self.fuzzer_1.jobs.append('remove_fuzzer')
+    self.fuzzer_1.put()
+
+    fuzzer_selection.update_mappings_for_job(job, [])
+
+    mappings = _get_fuzzer_list_for_job(job)
+    self.assertEqual([], mappings)
+
+
 def _mock_random_weighted_choice(items, weight_attribute='weight'):  # pylint: disable=unused-argument
   """Mock random_weighted_choice."""
   # Always select the first element rather than a random one for the sake of