#!/usr/bin/env python
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
################################################################################
"""Script for pinning builder images for projects that break on upgrades. Works
with projects that use language builders."""
import argparse
import logging
import os
import re
import sys
import subprocess

ROOT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
PROJECTS_DIR = os.path.join(ROOT_DIR, 'projects')

IMAGE_DIGEST_REGEX = re.compile(r'\[(.+)\]\n')
FROM_LINE_REGEX = re.compile(
    r'FROM (gcr.io\/oss-fuzz-base\/base-builder[\-a-z0-9]*)(\@?.*)')


def get_latest_docker_image_digest(image):
  """Returns a pinnable version of the latest |image|. This version will have a
  SHA."""
  subprocess.run(['docker', 'pull', image], check=True)
  subprocess.run(['docker', 'pull', image], stdout=subprocess.PIPE, check=True)

  command = [
      'docker', 'image', 'inspect', '--format', '{{.RepoDigests}}', image
  ]
  output = subprocess.run(command, check=True,
                          stdout=subprocess.PIPE).stdout.decode('utf-8')
  return IMAGE_DIGEST_REGEX.match(output).groups(1)[0]


def get_args():
  """Returns parsed arguments."""
  parser = argparse.ArgumentParser(sys.argv[0],
                                   description='Hold back builder images.')
  parser.add_argument('projects', help='Projects.', nargs='+')

  parser.add_argument('--hold-image-digest',
                      required=False,
                      nargs='?',
                      default=None,
                      help='Image to hold on to.')

  parser.add_argument('--update-held',
                      action='store_true',
                      default=False,
                      help='Update held images.')

  parser.add_argument('--issue-number',
                      required=False,
                      nargs='?',
                      default=None,
                      help='Issue to reference.')

  args = parser.parse_args()
  return args


def get_hold_image_digest(line, hold_image_digest, update_held):
  """Returns the image digest for the |line| we want to pin. If the image is
  already pinned then it is only updated if |update_held. If |hold_image_digest
  is specified then it is returned, otherwise the latest pinnable version is
  returned."""
  matches = FROM_LINE_REGEX.match(line).groups()
  if matches[1] and not update_held:
    return None, False
  initial_image = matches[0]
  if hold_image_digest:
    return hold_image_digest, True
  return get_latest_docker_image_digest(initial_image), True


def hold_image(project, hold_image_digest, update_held, issue_number):
  """Rewrites the Dockerfile of |project| to pin the base-builder image on
  upgrade."""
  dockerfile_path = os.path.join(PROJECTS_DIR, project, 'Dockerfile')
  with open(dockerfile_path, 'r') as dockerfile_handle:
    dockerfile = dockerfile_handle.readlines()
  for idx, line in enumerate(dockerfile[:]):
    if not line.startswith('FROM gcr.io/oss-fuzz-base/base-builder'):
      continue

    hold_image_digest, should_hold = get_hold_image_digest(
        line.strip(), hold_image_digest, update_held)
    if not should_hold:
      logging.error('Not holding back %s.', project)
      break
    dockerfile[idx] = f'FROM {hold_image_digest}\n'
    if issue_number:
      comment = ('# Held back because of github.com/google/oss-fuzz/pull/'
                 f'{issue_number}\n# Please fix failure and upgrade.\n')
      dockerfile.insert(idx, comment)
    break
  else:
    # This path is taken when we don't break out of the loop.
    assert None, f'Could not find FROM line in {project}'
  dockerfile = ''.join(dockerfile)
  with open(dockerfile_path, 'w') as dockerfile_handle:
    dockerfile_handle.write(dockerfile)


def main():
  """Script for pinning builder images for projects that break on upgrades."""
  args = get_args()
  for project in args.projects:
    hold_image(project, args.hold_image_digest, args.update_held,
               args.issue_number)
  return 0


if __name__ == '__main__':
  sys.exit(main())

Add script for pinning images when updating builders. (#8328)