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