• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2021 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"""Implementations for various CI systems."""
15
16import os
17import collections
18import sys
19import logging
20
21# pylint: disable=wrong-import-position,import-error
22sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23import build_specified_commit
24import docker
25import helper
26import repo_manager
27import retry
28import utils
29import workspace_utils
30
31# pylint: disable=too-few-public-methods
32
33BuildPreparationResult = collections.namedtuple(
34    'BuildPreparationResult', ['success', 'image_repo_path', 'repo_manager'])
35
36_IMAGE_BUILD_TRIES = 3
37_IMAGE_BUILD_BACKOFF = 2
38
39
40def fix_git_repo_for_diff(repo_manager_obj):
41  """Fixes git repos cloned by the "checkout" action so that diffing works on
42  them."""
43  command = [
44      'git', 'symbolic-ref', 'refs/remotes/origin/HEAD',
45      'refs/remotes/origin/master'
46  ]
47  return utils.execute(command, location=repo_manager_obj.repo_dir)
48
49
50class BaseCi:
51  """Class representing common CI functionality."""
52
53  def __init__(self, config):
54    self.config = config
55    self.workspace = workspace_utils.Workspace(config)
56
57  def repo_dir(self):
58    """Returns the source repo path, if it has been checked out. None is
59    returned otherwise."""
60    if not os.path.exists(self.workspace.repo_storage):
61      return None
62
63    # Note: this assumes there is only one repo checked out here.
64    listing = os.listdir(self.workspace.repo_storage)
65    if len(listing) != 1:
66      raise RuntimeError('Invalid repo storage.')
67
68    repo_path = os.path.join(self.workspace.repo_storage, listing[0])
69    if not os.path.isdir(repo_path):
70      raise RuntimeError('Repo is not a directory.')
71
72    return repo_path
73
74  def prepare_for_fuzzer_build(self):
75    """Builds the fuzzer builder image and gets the source code we need to
76    fuzz."""
77    raise NotImplementedError('Child class must implement method.')
78
79  def get_diff_base(self):
80    """Returns the base to diff against with git to get the change under
81    test."""
82    raise NotImplementedError('Child class must implement method.')
83
84  def get_changed_code_under_test(self, repo_manager_obj):
85    """Returns the changed files that need to be tested."""
86    base = self.get_diff_base()
87    fix_git_repo_for_diff(repo_manager_obj)
88    logging.info('Diffing against %s.', base)
89    return repo_manager_obj.get_git_diff(base)
90
91  def get_build_command(self, host_repo_path, image_repo_path):
92    """Returns the command for building the project that is run inside the
93    project builder container."""
94    raise NotImplementedError('Child class must implement method.')
95
96
97def get_build_command():
98  """Returns the command to build the project inside the project builder
99  container."""
100  return 'compile'
101
102
103def get_replace_repo_and_build_command(host_repo_path, image_repo_path):
104  """Returns the command to replace the repo located at |image_repo_path| with
105  |host_repo_path| and build the project inside the project builder
106  container."""
107  rm_path = os.path.join(image_repo_path, '*')
108  image_src_path = os.path.dirname(image_repo_path)
109  build_command = get_build_command()
110  command = (f'cd / && rm -rf {rm_path} && cp -r {host_repo_path} '
111             f'{image_src_path} && cd - && {build_command}')
112  return command
113
114
115def get_ci(config):
116  """Determines what kind of CI is being used and returns the object
117  representing that system."""
118
119  if config.platform == config.Platform.EXTERNAL_GENERIC_CI:
120    # Non-OSS-Fuzz projects must bring their own source and their own build
121    # integration (which is relative to that source).
122    return ExternalGeneric(config)
123  if config.platform == config.Platform.EXTERNAL_GITHUB:
124    # Non-OSS-Fuzz projects must bring their own source and their own build
125    # integration (which is relative to that source).
126    return ExternalGithub(config)
127
128  if config.platform == config.Platform.INTERNAL_GENERIC_CI:
129    # Builds of OSS-Fuzz projects not hosted on Github must bring their own
130    # source since the checkout logic CIFuzz implements is github-specific.
131    # TODO(metzman): Consider moving Github-actions builds of OSS-Fuzz projects
132    # to this system to reduce implementation complexity.
133    return InternalGeneric(config)
134
135  return InternalGithub(config)
136
137
138def checkout_specified_commit(repo_manager_obj, pr_ref, commit_sha):
139  """Checks out the specified commit or pull request using
140  |repo_manager_obj|."""
141  try:
142    if pr_ref:
143      repo_manager_obj.checkout_pr(pr_ref)
144    else:
145      repo_manager_obj.checkout_commit(commit_sha)
146  except (RuntimeError, ValueError):
147    logging.error(
148        'Can not check out requested state %s. '
149        'Using current repo state', pr_ref or commit_sha)
150
151
152class GithubCiMixin:
153  """Mixin for Github based CI systems."""
154
155  def get_diff_base(self):
156    """Returns the base to diff against with git to get the change under
157    test."""
158    if self.config.base_ref:
159      logging.debug('Diffing against base_ref: %s.', self.config.base_ref)
160      return self.config.base_ref
161    logging.debug('Diffing against base_commit: %s.', self.config.base_commit)
162    return self.config.base_commit
163
164  def get_changed_code_under_test(self, repo_manager_obj):
165    """Returns the changed files that need to be tested."""
166    if self.config.base_ref:
167      repo_manager_obj.fetch_branch(self.config.base_ref)
168    return super().get_changed_code_under_test(repo_manager_obj)
169
170
171class InternalGithub(GithubCiMixin, BaseCi):
172  """Class representing CI for an OSS-Fuzz project on Github Actions."""
173
174  def prepare_for_fuzzer_build(self):
175    """Builds the fuzzer builder image, checks out the pull request/commit and
176    returns the BuildPreparationResult."""
177    logging.info('Building OSS-Fuzz project on Github Actions.')
178    assert self.config.pr_ref or self.config.commit_sha
179    # detect_main_repo builds the image as a side effect.
180    inferred_url, image_repo_path = (build_specified_commit.detect_main_repo(
181        self.config.oss_fuzz_project_name,
182        repo_name=self.config.project_repo_name))
183
184    if not inferred_url or not image_repo_path:
185      logging.error('Could not detect repo.')
186      return BuildPreparationResult(success=False,
187                                    image_repo_path=None,
188                                    repo_manager=None)
189
190    os.makedirs(self.workspace.repo_storage, exist_ok=True)
191
192    # Use the same name used in the docker image so we can overwrite it.
193    image_repo_name = os.path.basename(image_repo_path)
194
195    # Checkout project's repo in the shared volume.
196    manager = repo_manager.clone_repo_and_get_manager(
197        inferred_url, self.workspace.repo_storage, repo_name=image_repo_name)
198    checkout_specified_commit(manager, self.config.pr_ref,
199                              self.config.commit_sha)
200
201    return BuildPreparationResult(success=True,
202                                  image_repo_path=image_repo_path,
203                                  repo_manager=manager)
204
205  def get_build_command(self, host_repo_path, image_repo_path):  # pylint: disable=no-self-use
206    """Returns the command for building the project that is run inside the
207    project builder container. Command also replaces |image_repo_path| with
208    |host_repo_path|."""
209    return get_replace_repo_and_build_command(host_repo_path, image_repo_path)
210
211
212class InternalGeneric(BaseCi):
213  """Class representing CI for an OSS-Fuzz project on a CI other than Github
214  actions."""
215
216  def prepare_for_fuzzer_build(self):
217    """Builds the project builder image for an OSS-Fuzz project outside of
218    GitHub actions. Returns the repo_manager. Does not checkout source code
219    since external projects are expected to bring their own source code to
220    CIFuzz."""
221    logging.info('Building OSS-Fuzz project.')
222    # detect_main_repo builds the image as a side effect.
223    _, image_repo_path = (build_specified_commit.detect_main_repo(
224        self.config.oss_fuzz_project_name,
225        repo_name=self.config.project_repo_name))
226
227    if not image_repo_path:
228      logging.error('Could not detect repo.')
229      return BuildPreparationResult(success=False,
230                                    image_repo_path=None,
231                                    repo_manager=None)
232
233    manager = repo_manager.RepoManager(self.config.project_src_path)
234    return BuildPreparationResult(success=True,
235                                  image_repo_path=image_repo_path,
236                                  repo_manager=manager)
237
238  def get_diff_base(self):
239    return 'origin...'
240
241  def get_build_command(self, host_repo_path, image_repo_path):  # pylint: disable=no-self-use
242    """Returns the command for building the project that is run inside the
243    project builder container. Command also replaces |image_repo_path| with
244    |host_repo_path|."""
245    return get_replace_repo_and_build_command(host_repo_path, image_repo_path)
246
247
248@retry.wrap(_IMAGE_BUILD_TRIES, _IMAGE_BUILD_BACKOFF)
249def build_external_project_docker_image(project_src, build_integration_path):
250  """Builds the project builder image for an external (non-OSS-Fuzz) project.
251  Returns True on success."""
252  dockerfile_path = os.path.join(build_integration_path, 'Dockerfile')
253  command = [
254      '-t', docker.EXTERNAL_PROJECT_IMAGE, '-f', dockerfile_path, project_src
255  ]
256  return helper.docker_build(command)
257
258
259class ExternalGeneric(BaseCi):
260  """CI implementation for generic CI for external (non-OSS-Fuzz) projects."""
261
262  def get_diff_base(self):
263    return 'origin...'
264
265  def prepare_for_fuzzer_build(self):
266    logging.info('ExternalGeneric: preparing for fuzzer build.')
267    manager = repo_manager.RepoManager(self.config.project_src_path)
268    build_integration_abs_path = os.path.join(
269        manager.repo_dir, self.config.build_integration_path)
270    if not build_external_project_docker_image(manager.repo_dir,
271                                               build_integration_abs_path):
272      logging.error('Failed to build external project: %s.',
273                    self.config.oss_fuzz_project_name)
274      return BuildPreparationResult(success=False,
275                                    image_repo_path=None,
276                                    repo_manager=None)
277
278    image_repo_path = os.path.join('/src', self.config.project_repo_name)
279    return BuildPreparationResult(success=True,
280                                  image_repo_path=image_repo_path,
281                                  repo_manager=manager)
282
283  def get_build_command(self, host_repo_path, image_repo_path):  # pylint: disable=no-self-use
284    """Returns the command for building the project that is run inside the
285    project builder container."""
286    return get_build_command()
287
288
289class ExternalGithub(GithubCiMixin, BaseCi):
290  """Class representing CI for a non-OSS-Fuzz project on Github Actions."""
291
292  def prepare_for_fuzzer_build(self):
293    """Builds the project builder image for a non-OSS-Fuzz project on GitHub
294    actions. Sets the repo manager. Does not checkout source code since external
295    projects are expected to bring their own source code to CIFuzz. Returns True
296    on success."""
297    logging.info('Building external project.')
298    os.makedirs(self.workspace.repo_storage, exist_ok=True)
299    # Checkout before building, so we don't need to rely on copying the source
300    # into the image.
301    # TODO(metzman): Figure out if we want second copy at all.
302    manager = repo_manager.clone_repo_and_get_manager(
303        self.config.git_url,
304        self.workspace.repo_storage,
305        repo_name=self.config.project_repo_name)
306    checkout_specified_commit(manager, self.config.pr_ref,
307                              self.config.commit_sha)
308
309    build_integration_abs_path = os.path.join(
310        manager.repo_dir, self.config.build_integration_path)
311    if not build_external_project_docker_image(manager.repo_dir,
312                                               build_integration_abs_path):
313      logging.error('Failed to build external project.')
314      return BuildPreparationResult(success=False,
315                                    image_repo_path=None,
316                                    repo_manager=None)
317
318    image_repo_path = os.path.join('/src', self.config.project_repo_name)
319    return BuildPreparationResult(success=True,
320                                  image_repo_path=image_repo_path,
321                                  repo_manager=manager)
322
323  def get_build_command(self, host_repo_path, image_repo_path):  # pylint: disable=no-self-use
324    """Returns the command for building the project that is run inside the
325    project builder container."""
326    return get_build_command()
327