<reponame>eunchong/infra
#!/usr/bin/env python
# Copyright 2015 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""This script rebuilds Python & Go universes of infra.git multiverse and
invokes CIPD client to package and upload chunks of it to the CIPD repository as
individual packages.

See build/packages/*.yaml for definition of packages.
"""

import argparse
import glob
import json
import os
import platform
import shutil
import subprocess
import sys
import tempfile


# Root of infra.git repository.
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

# Root of infra gclient solution.
GCLIENT_ROOT = os.path.dirname(ROOT)

# Where to upload packages to by default.
PACKAGE_REPO_SERVICE = 'https://chrome-infra-packages.appspot.com'

# .exe on Windows.
EXE_SUFFIX = '.exe' if sys.platform == 'win32' else ''


class BuildException(Exception):
  """Raised on errors during package build step."""


class UploadException(Exception):
  """Raised on errors during package upload step."""


def run_python(script, args):
  """Invokes a python script.

  Raises:
    subprocess.CalledProcessError on non zero exit code.
  """
  print 'Running %s %s' % (script, ' '.join(args))
  subprocess.check_call(
      args=['python', '-u', script] + list(args), executable=sys.executable)


def run_cipd(go_workspace, cmd, args):
  """Invokes CIPD, parsing -json-output result.

  Args:
    go_workspace: path to 'infra/go' or 'infra_internal/go'.
    cmd: cipd subcommand to run.
    args: list of command line arguments to pass to the subcommand.

  Returns:
    (Process exit code, parsed JSON output or None).
  """
  temp_file = None
  try:
    fd, temp_file = tempfile.mkstemp(suffix='.json', prefix='cipd_%s' % cmd)
    os.close(fd)

    cmd_line = [
      os.path.join(go_workspace, 'bin', 'cipd' + EXE_SUFFIX),
      cmd, '-json-output', temp_file,
    ] + list(args)

    print 'Running %s' % ' '.join(cmd_line)
    exit_code = subprocess.call(args=cmd_line, executable=cmd_line[0])
    try:
      with open(temp_file, 'r') as f:
        json_output = json.load(f)
    except (IOError, ValueError):
      json_output = None

    return exit_code, json_output
  finally:
    try:
      if temp_file:
        os.remove(temp_file)
    except OSError:
      pass


def print_title(title):
  """Pretty prints a banner to stdout."""
  sys.stdout.flush()
  sys.stderr.flush()
  print
  print '-' * 80
  print title
  print '-' * 80


def build_go(go_workspace, packages):
  """Bootstraps go environment and rebuilds (and installs) Go packages.

  Compiles and installs packages into default GOBIN, which is <path>/go/bin/
  (it is setup by go/env.py and depends on what workspace is used).

  Args:
    go_workspace: path to 'infra/go' or 'infra_internal/go'.
    packages: list of packages to build (can include '...' patterns).
  """
  print_title('Compiling Go code: %s' % ', '.join(packages))

  # Go toolchain embeds absolute paths to *.go files into the executable. Use
  # symlink with stable path to make executables independent of checkout path.
  new_root = None
  new_workspace = go_workspace
  if sys.platform != 'win32':
    new_root = '/tmp/_chrome_infra_build'
    if os.path.exists(new_root):
      assert os.path.islink(new_root)
      os.remove(new_root)
    os.symlink(GCLIENT_ROOT, new_root)
    rel = os.path.relpath(go_workspace, GCLIENT_ROOT)
    assert not rel.startswith('..'), rel
    new_workspace = os.path.join(new_root, rel)

  # Remove any stale binaries and libraries.
  shutil.rmtree(os.path.join(new_workspace, 'bin'), ignore_errors=True)
  shutil.rmtree(os.path.join(new_workspace, 'pkg'), ignore_errors=True)

  # Recompile ('-a').
  try:
    subprocess.check_call(
        args=[
          'python', '-u', os.path.join(new_workspace, 'env.py'),
          'go', 'install', '-a', '-v',
        ] + list(packages),
        executable=sys.executable,
        stderr=subprocess.STDOUT)
  finally:
    if new_root:
      os.remove(new_root)


def enumerate_packages_to_build(package_def_dir, package_def_files=None):
  """Returns a list of absolute paths to files in build/packages/*.yaml.

  Args:
    package_def_dir: path to build/packages dir to search for *.yaml.
    package_def_files: optional list of filenames to limit results to.

  Returns:
    List of absolute paths to *.yaml files under packages_dir.
  """
  # All existing package by default.
  if not package_def_files:
    return sorted(glob.glob(os.path.join(package_def_dir, '*.yaml')))
  paths = []
  for name in package_def_files:
    abs_path = os.path.join(package_def_dir, name)
    if not os.path.isfile(abs_path):
      raise BuildException('No such package definition file: %s' % name)
    paths.append(abs_path)
  return sorted(paths)


def read_yaml(py_venv, path):
  """Returns content of YAML file as python dict."""
  # YAML lib is in venv, not activated here. Go through hoops.
  oneliner = (
      'import json, sys, yaml; '
      'json.dump(yaml.safe_load(sys.stdin), sys.stdout)')
  if sys.platform == 'win32':
    python_venv_path = ('Scripts', 'python.exe')
  else:
    python_venv_path = ('bin', 'python')
  executable = os.path.join(py_venv, *python_venv_path)
  env = os.environ.copy()
  env.pop('PYTHONPATH', None)
  proc = subprocess.Popen(
      [executable, '-c', oneliner],
      executable=executable,
      stdin=subprocess.PIPE,
      stdout=subprocess.PIPE,
      env=env)
  with open(path, 'r') as f:
    out, _ = proc.communicate(f.read())
  if proc.returncode:
    raise BuildException('Failed to parse YAML at %s' % path)
  return json.loads(out)


def should_process_on_builder(pkg_def_file, py_venv, builder):
  """Returns True if package should be processed by current CI builder."""
  if not builder:
    return True
  builders = read_yaml(py_venv, pkg_def_file).get('builders')
  return not builders or builder in builders


def get_package_vars():
  """Returns a dict with variables that describe the current environment.

  Variables can be referenced in the package definition YAML as
  ${variable_name}. It allows to reuse exact same definition file for similar
  packages (e.g. packages with same cross platform binary, but for different
  platforms).
  """
  # linux, mac or windows.
  platform_variant = {
    'darwin': 'mac',
    'linux2': 'linux',
    'win32': 'windows',
  }.get(sys.platform)
  if not platform_variant:
    raise ValueError('Unknown OS: %s' % sys.platform)

  if sys.platform == 'darwin':
    # platform.mac_ver()[0] is '10.9.5'.
    dist = platform.mac_ver()[0].split('.')
    os_ver = 'mac%s_%s' % (dist[0], dist[1])
  elif sys.platform == 'linux2':
    # platform.linux_distribution() is ('Ubuntu', '14.04', ...).
    dist = platform.linux_distribution()
    os_ver = '%s%s' % (dist[0].lower(), dist[1].replace('.', '_'))
  elif sys.platform == 'win32':
    # platform.version() is '6.1.7601'.
    dist = platform.version().split('.')
    os_ver = 'win%s_%s' % (dist[0], dist[1])
  else:
    raise ValueError('Unknown OS: %s' % sys.platform)

  # amd64, 386, etc.
  platform_arch = {
    'amd64': 'amd64',
    'i386': '386',
    'i686': '386',
    'x86': '386',
    'x86_64': 'amd64',
  }.get(platform.machine().lower())
  if not platform_arch:
    raise ValueError('Unknown machine arch: %s' % platform.machine())

  return {
    # e.g. '.exe' or ''.
    'exe_suffix': EXE_SUFFIX,
    # e.g. 'ubuntu14_04' or 'mac10_9' or 'win6_1'.
    'os_ver': os_ver,
    # e.g. 'linux-amd64'
    'platform': '%s-%s' % (platform_variant, platform_arch),
    # e.g. '27' (dots are not allowed in package names).
    'python_version': '%s%s' % sys.version_info[:2],
  }


def build_pkg(go_workspace, pkg_def_file, out_file, package_vars):
  """Invokes CIPD client to build a package.

  Args:
    go_workspace: path to 'infra/go' or 'infra_internal/go'.
    pkg_def_file: path to *.yaml file with package definition.
    out_file: where to store the built package.
    package_vars: dict with variables to pass as -pkg-var to cipd.

  Returns:
    {'package': <name>, 'instance_id': <sha1>}

  Raises:
    BuildException on error.
  """
  print_title('Building: %s' % os.path.basename(pkg_def_file))

  # Make sure not stale output remains.
  if os.path.isfile(out_file):
    os.remove(out_file)

  # Build the package.
  args = ['-pkg-def', pkg_def_file]
  for k, v in sorted(package_vars.items()):
    args.extend(['-pkg-var', '%s:%s' % (k, v)])
  args.extend(['-out', out_file])
  exit_code, json_output = run_cipd(go_workspace, 'pkg-build', args)
  if exit_code:
    print
    print >> sys.stderr, 'FAILED! ' * 10
    raise BuildException('Failed to build the CIPD package, see logs')

  # Expected result is {'package': 'name', 'instance_id': 'sha1'}
  info = json_output['result']
  print '%s %s' % (info['package'], info['instance_id'])
  return info


def upload_pkg(go_workspace, pkg_file, service_url, tags, service_account):
  """Uploads existing *.cipd file to the storage and tags it.

  Args:
    go_workspace: path to 'infra/go' or 'infra_internal/go'.
    pkg_file: path to *.cipd file to upload.
    service_url: URL of a package repository service.
    tags: a list of tags to attach to uploaded package instance.
    service_account: path to *.json file with service account to use.

  Returns:
    {'package': <name>, 'instance_id': <sha1>}

  Raises:
    UploadException on error.
  """
  print_title('Uploading: %s' % os.path.basename(pkg_file))

  args = ['-service-url', service_url]
  for tag in sorted(tags):
    args.extend(['-tag', tag])
  args.extend(['-ref', 'latest'])
  if service_account:
    args.extend(['-service-account-json', service_account])
  args.append(pkg_file)
  exit_code, json_output = run_cipd(go_workspace, 'pkg-register', args)
  if exit_code:
    print
    print >> sys.stderr, 'FAILED! ' * 10
    raise UploadException('Failed to upload the CIPD package, see logs')
  info = json_output['result']
  print '%s %s' % (info['package'], info['instance_id'])
  return info


def run(
    py_venv,
    go_workspace,
    build_callback,
    builder,
    package_def_dir,
    package_out_dir,
    package_def_files,
    build,
    upload,
    service_url,
    tags,
    service_account_json,
    json_output):
  """Rebuild python and Go universes and CIPD packages.

  Args:
    py_venv: path to 'infra/ENV' or 'infra_internal/ENV'.
    go_workspace: path to 'infra/go' or 'infra_internal/go'.
    build_callback: called to build binaries, virtual environment, etc.
    builder: name of CI buildbot builder that invoked the script.
    package_def_dir: path to build/packages dir to search for *.yaml.
    package_out_dir: where to put built packages.
    package_def_files: names of *.yaml files in package_def_dir or [] for all.
    build: False to skip building packages (valid only when upload==True).
    upload: True to also upload built packages, False just to build them.
    service_url: URL of a package repository service.
    tags: a list of tags to attach to uploaded package instances.
    service_account_json: path to *.json service account credential.
    json_output: path to *.json file to write info about built packages to.

  Returns:
    0 on success, 1 or error.
  """
  assert build or upload, 'Both build and upload are False, nothing to do'

  # Remove stale output so that test_packages.py do not test old files when
  # invoked without arguments.
  if build:
    for path in glob.glob(os.path.join(package_out_dir, '*.cipd')):
      os.remove(path)

  packages_to_build = [
    p for p in enumerate_packages_to_build(package_def_dir, package_def_files)
    if should_process_on_builder(p, py_venv, builder)
  ]

  print_title('Overview')
  print 'Service URL: %s' % service_url
  print
  if builder:
    print 'Package definition files to process on %s:' % builder
  else:
    print 'Package definition files to process:'
  for pkg_def_file in packages_to_build:
    print '  %s' % os.path.basename(pkg_def_file)
  if not packages_to_build:
    print '  <none>'
  print
  print 'Variables to pass to CIPD:'
  package_vars = get_package_vars()
  for k, v in sorted(package_vars.items()):
    print '  %s = %s' % (k, v)
  if tags:
    print
    print 'Tags to attach to uploaded packages:'
    for tag in sorted(tags):
      print '  %s' % tag
  if not packages_to_build:
    print
    print 'Nothing to do.'
    return 0

  # Build the world.
  if build:
    build_callback()

  # Package it.
  failed = []
  succeeded = []
  for pkg_def_file in packages_to_build:
    # path/name.yaml -> out/name.cipd.
    name = os.path.splitext(os.path.basename(pkg_def_file))[0]
    out_file = os.path.join(package_out_dir, name + '.cipd')
    try:
      info = None
      if build:
        info = build_pkg(go_workspace, pkg_def_file, out_file, package_vars)
      if upload:
        info = upload_pkg(
            go_workspace,
            out_file,
            service_url,
            tags,
            service_account_json)
      assert info is not None
      succeeded.append({'pkg_def_name': name, 'info': info})
    except (BuildException, UploadException) as e:
      failed.append({'pkg_def_name': name, 'error': str(e)})

  print_title('Summary')
  for d in failed:
    print 'FAILED %s, see log above' % d['pkg_def_name']
  for d in succeeded:
    print '%s %s' % (d['info']['package'], d['info']['instance_id'])

  if json_output:
    with open(json_output, 'w') as f:
      summary = {
        'failed': failed,
        'succeeded': succeeded,
        'tags': sorted(tags),
        'vars': package_vars,
      }
      json.dump(summary, f, sort_keys=True, indent=2, separators=(',', ': '))

  return 1 if failed else 0


def build_infra():
  """Builds infra.git multiverse."""
  # Python side.
  print_title('Making sure python virtual environment is fresh')
  run_python(
      script=os.path.join(ROOT, 'bootstrap', 'bootstrap.py'),
      args=[
        '--deps_file',
        os.path.join(ROOT, 'bootstrap', 'deps.pyl'),
        os.path.join(ROOT, 'ENV'),
      ])
  # Go side.
  build_go(os.path.join(ROOT, 'go'), [
    'infra/...',
    'github.com/luci/luci-go/client/...',
    'github.com/luci/luci-go/tools/...',
    ])


def main(
    args,
    build_callback=build_infra,
    py_venv=os.path.join(ROOT, 'ENV'),
    go_workspace=os.path.join(ROOT, 'go'),
    package_def_dir=os.path.join(ROOT, 'build', 'packages'),
    package_out_dir=os.path.join(ROOT, 'build', 'out')):
  parser = argparse.ArgumentParser(description='Builds infra CIPD packages')
  parser.add_argument(
      'yamls', metavar='YAML', type=str, nargs='*',
      help='name of a file in build/packages/* with the package definition')
  parser.add_argument(
      '--upload',  action='store_true', dest='upload', default=False,
      help='upload packages into the repository')
  parser.add_argument(
      '--no-rebuild',  action='store_false', dest='build', default=True,
      help='when used with --upload means upload existing *.cipd files')
  parser.add_argument(
      '--builder', metavar='NAME', type=str,
      help='Name of the CI buildbot builder that invokes this script.')
  parser.add_argument(
      '--service-url', metavar='URL', dest='service_url',
      default=PACKAGE_REPO_SERVICE,
      help='URL of the package repository service to use')
  parser.add_argument(
      '--service-account-json', metavar='PATH', dest='service_account_json',
      help='path to credentials for service account to use')
  parser.add_argument(
      '--json-output', metavar='PATH', dest='json_output',
      help='where to dump info about built package instances')
  parser.add_argument(
      '--tags', metavar='KEY:VALUE', type=str, dest='tags', nargs='*',
      help='tags to attach to uploaded package instances')
  args = parser.parse_args(args)
  if not args.build and not args.upload:
    parser.error('--no-rebuild doesn\'t make sense without --upload')
  return run(
      py_venv,
      go_workspace,
      build_callback,
      args.builder,
      package_def_dir,
      package_out_dir,
      [n + '.yaml' if not n.endswith('.yaml') else n for n in args.yamls],
      args.build,
      args.upload,
      args.service_url,
      args.tags or [],
      args.service_account_json,
      args.json_output)


if __name__ == '__main__':
  sys.exit(main(sys.argv[1:]))

#!/usr/bin/env python
# Copyright 2015 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""This script rebuilds Python & Go universes of infra.git multiverse and
invokes CIPD client to package and upload chunks of it to the CIPD repository as
individual packages.

See build/packages/*.yaml for definition of packages.
"""

import argparse
import glob
import json
import os
import platform
import shutil
import subprocess
import sys
import tempfile


# Root of infra.git repository.
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

# Root of infra gclient solution.
GCLIENT_ROOT = os.path.dirname(ROOT)

# Where to upload packages to by default.
PACKAGE_REPO_SERVICE = 'https://chrome-infra-packages.appspot.com'

# .exe on Windows.
EXE_SUFFIX = '.exe' if sys.platform == 'win32' else ''


class BuildException(Exception):
  """Raised on errors during package build step."""


class UploadException(Exception):
  """Raised on errors during package upload step."""


def run_python(script, args):
  """Invokes a python script.

  Raises:
    subprocess.CalledProcessError on non zero exit code.
  """
  print 'Running %s %s' % (script, ' '.join(args))
  subprocess.check_call(
      args=['python', '-u', script] + list(args), executable=sys.executable)


def run_cipd(go_workspace, cmd, args):
  """Invokes CIPD, parsing -json-output result.

  Args:
    go_workspace: path to 'infra/go' or 'infra_internal/go'.
    cmd: cipd subcommand to run.
    args: list of command line arguments to pass to the subcommand.

  Returns:
    (Process exit code, parsed JSON output or None).
  """
  temp_file = None
  try:
    fd, temp_file = tempfile.mkstemp(suffix='.json', prefix='cipd_%s' % cmd)
    os.close(fd)

    cmd_line = [
      os.path.join(go_workspace, 'bin', 'cipd' + EXE_SUFFIX),
      cmd, '-json-output', temp_file,
    ] + list(args)

    print 'Running %s' % ' '.join(cmd_line)
    exit_code = subprocess.call(args=cmd_line, executable=cmd_line[0])
    try:
      with open(temp_file, 'r') as f:
        json_output = json.load(f)
    except (IOError, ValueError):
      json_output = None

    return exit_code, json_output
  finally:
    try:
      if temp_file:
        os.remove(temp_file)
    except OSError:
      pass


def print_title(title):
  """Pretty prints a banner to stdout."""
  sys.stdout.flush()
  sys.stderr.flush()
  print
  print '-' * 80
  print title
  print '-' * 80


def build_go(go_workspace, packages):
  """Bootstraps go environment and rebuilds (and installs) Go packages.

  Compiles and installs packages into default GOBIN, which is <path>/go/bin/
  (it is setup by go/env.py and depends on what workspace is used).

  Args:
    go_workspace: path to 'infra/go' or 'infra_internal/go'.
    packages: list of packages to build (can include '...' patterns).
  """
  print_title('Compiling Go code: %s' % ', '.join(packages))

  # Go toolchain embeds absolute paths to *.go files into the executable. Use
  # symlink with stable path to make executables independent of checkout path.
  new_root = None
  new_workspace = go_workspace
  if sys.platform != 'win32':
    new_root = '/tmp/_chrome_infra_build'
    if os.path.exists(new_root):
      assert os.path.islink(new_root)
      os.remove(new_root)
    os.symlink(GCLIENT_ROOT, new_root)
    rel = os.path.relpath(go_workspace, GCLIENT_ROOT)
    assert not rel.startswith('..'), rel
    new_workspace = os.path.join(new_root, rel)

  # Remove any stale binaries and libraries.
  shutil.rmtree(os.path.join(new_workspace, 'bin'), ignore_errors=True)
  shutil.rmtree(os.path.join(new_workspace, 'pkg'), ignore_errors=True)

  # Recompile ('-a').
  try:
    subprocess.check_call(
        args=[
          'python', '-u', os.path.join(new_workspace, 'env.py'),
          'go', 'install', '-a', '-v',
        ] + list(packages),
        executable=sys.executable,
        stderr=subprocess.STDOUT)
  finally:
    if new_root:
      os.remove(new_root)


def enumerate_packages_to_build(package_def_dir, package_def_files=None):
  """Returns a list of absolute paths to files in build/packages/*.yaml.

  Args:
    package_def_dir: path to build/packages dir to search for *.yaml.
    package_def_files: optional list of filenames to limit results to.

  Returns:
    List of absolute paths to *.yaml files under packages_dir.
  """
  # All existing package by default.
  if not package_def_files:
    return sorted(glob.glob(os.path.join(package_def_dir, '*.yaml')))
  paths = []
  for name in package_def_files:
    abs_path = os.path.join(package_def_dir, name)
    if not os.path.isfile(abs_path):
      raise BuildException('No such package definition file: %s' % name)
    paths.append(abs_path)
  return sorted(paths)


def read_yaml(py_venv, path):
  """Returns content of YAML file as python dict."""
  # YAML lib is in venv, not activated here. Go through hoops.
  oneliner = (
      'import json, sys, yaml; '
      'json.dump(yaml.safe_load(sys.stdin), sys.stdout)')
  if sys.platform == 'win32':
    python_venv_path = ('Scripts', 'python.exe')
  else:
    python_venv_path = ('bin', 'python')
  executable = os.path.join(py_venv, *python_venv_path)
  env = os.environ.copy()
  env.pop('PYTHONPATH', None)
  proc = subprocess.Popen(
      [executable, '-c', oneliner],
      executable=executable,
      stdin=subprocess.PIPE,
      stdout=subprocess.PIPE,
      env=env)
  with open(path, 'r') as f:
    out, _ = proc.communicate(f.read())
  if proc.returncode:
    raise BuildException('Failed to parse YAML at %s' % path)
  return json.loads(out)


def should_process_on_builder(pkg_def_file, py_venv, builder):
  """Returns True if package should be processed by current CI builder."""
  if not builder:
    return True
  builders = read_yaml(py_venv, pkg_def_file).get('builders')
  return not builders or builder in builders


def get_package_vars():
  """Returns a dict with variables that describe the current environment.

  Variables can be referenced in the package definition YAML as
  ${variable_name}. It allows to reuse exact same definition file for similar
  packages (e.g. packages with same cross platform binary, but for different
  platforms).
  """
  # linux, mac or windows.
  platform_variant = {
    'darwin': 'mac',
    'linux2': 'linux',
    'win32': 'windows',
  }.get(sys.platform)
  if not platform_variant:
    raise ValueError('Unknown OS: %s' % sys.platform)

  if sys.platform == 'darwin':
    # platform.mac_ver()[0] is '10.9.5'.
    dist = platform.mac_ver()[0].split('.')
    os_ver = 'mac%s_%s' % (dist[0], dist[1])
  elif sys.platform == 'linux2':
    # platform.linux_distribution() is ('Ubuntu', '14.04', ...).
    dist = platform.linux_distribution()
    os_ver = '%s%s' % (dist[0].lower(), dist[1].replace('.', '_'))
  elif sys.platform == 'win32':
    # platform.version() is '6.1.7601'.
    dist = platform.version().split('.')
    os_ver = 'win%s_%s' % (dist[0], dist[1])
  else:
    raise ValueError('Unknown OS: %s' % sys.platform)

  # amd64, 386, etc.
  platform_arch = {
    'amd64': 'amd64',
    'i386': '386',
    'i686': '386',
    'x86': '386',
    'x86_64': 'amd64',
  }.get(platform.machine().lower())
  if not platform_arch:
    raise ValueError('Unknown machine arch: %s' % platform.machine())

  return {
    # e.g. '.exe' or ''.
    'exe_suffix': EXE_SUFFIX,
    # e.g. 'ubuntu14_04' or 'mac10_9' or 'win6_1'.
    'os_ver': os_ver,
    # e.g. 'linux-amd64'
    'platform': '%s-%s' % (platform_variant, platform_arch),
    # e.g. '27' (dots are not allowed in package names).
    'python_version': '%s%s' % sys.version_info[:2],
  }


def build_pkg(go_workspace, pkg_def_file, out_file, package_vars):
  """Invokes CIPD client to build a package.

  Args:
    go_workspace: path to 'infra/go' or 'infra_internal/go'.
    pkg_def_file: path to *.yaml file with package definition.
    out_file: where to store the built package.
    package_vars: dict with variables to pass as -pkg-var to cipd.

  Returns:
    {'package': <name>, 'instance_id': <sha1>}

  Raises:
    BuildException on error.
  """
  print_title('Building: %s' % os.path.basename(pkg_def_file))

  # Make sure not stale output remains.
  if os.path.isfile(out_file):
    os.remove(out_file)

  # Build the package.
  args = ['-pkg-def', pkg_def_file]
  for k, v in sorted(package_vars.items()):
    args.extend(['-pkg-var', '%s:%s' % (k, v)])
  args.extend(['-out', out_file])
  exit_code, json_output = run_cipd(go_workspace, 'pkg-build', args)
  if exit_code:
    print
    print >> sys.stderr, 'FAILED! ' * 10
    raise BuildException('Failed to build the CIPD package, see logs')

  # Expected result is {'package': 'name', 'instance_id': 'sha1'}
  info = json_output['result']
  print '%s %s' % (info['package'], info['instance_id'])
  return info


def upload_pkg(go_workspace, pkg_file, service_url, tags, service_account):
  """Uploads existing *.cipd file to the storage and tags it.

  Args:
    go_workspace: path to 'infra/go' or 'infra_internal/go'.
    pkg_file: path to *.cipd file to upload.
    service_url: URL of a package repository service.
    tags: a list of tags to attach to uploaded package instance.
    service_account: path to *.json file with service account to use.

  Returns:
    {'package': <name>, 'instance_id': <sha1>}

  Raises:
    UploadException on error.
  """
  print_title('Uploading: %s' % os.path.basename(pkg_file))

  args = ['-service-url', service_url]
  for tag in sorted(tags):
    args.extend(['-tag', tag])
  args.extend(['-ref', 'latest'])
  if service_account:
    args.extend(['-service-account-json', service_account])
  args.append(pkg_file)
  exit_code, json_output = run_cipd(go_workspace, 'pkg-register', args)
  if exit_code:
    print
    print >> sys.stderr, 'FAILED! ' * 10
    raise UploadException('Failed to upload the CIPD package, see logs')
  info = json_output['result']
  print '%s %s' % (info['package'], info['instance_id'])
  return info


def run(
    py_venv,
    go_workspace,
    build_callback,
    builder,
    package_def_dir,
    package_out_dir,
    package_def_files,
    build,
    upload,
    service_url,
    tags,
    service_account_json,
    json_output):
  """Rebuild python and Go universes and CIPD packages.

  Args:
    py_venv: path to 'infra/ENV' or 'infra_internal/ENV'.
    go_workspace: path to 'infra/go' or 'infra_internal/go'.
    build_callback: called to build binaries, virtual environment, etc.
    builder: name of CI buildbot builder that invoked the script.
    package_def_dir: path to build/packages dir to search for *.yaml.
    package_out_dir: where to put built packages.
    package_def_files: names of *.yaml files in package_def_dir or [] for all.
    build: False to skip building packages (valid only when upload==True).
    upload: True to also upload built packages, False just to build them.
    service_url: URL of a package repository service.
    tags: a list of tags to attach to uploaded package instances.
    service_account_json: path to *.json service account credential.
    json_output: path to *.json file to write info about built packages to.

  Returns:
    0 on success, 1 or error.
  """
  assert build or upload, 'Both build and upload are False, nothing to do'

  # Remove stale output so that test_packages.py do not test old files when
  # invoked without arguments.
  if build:
    for path in glob.glob(os.path.join(package_out_dir, '*.cipd')):
      os.remove(path)

  packages_to_build = [
    p for p in enumerate_packages_to_build(package_def_dir, package_def_files)
    if should_process_on_builder(p, py_venv, builder)
  ]

  print_title('Overview')
  print 'Service URL: %s' % service_url
  print
  if builder:
    print 'Package definition files to process on %s:' % builder
  else:
    print 'Package definition files to process:'
  for pkg_def_file in packages_to_build:
    print '  %s' % os.path.basename(pkg_def_file)
  if not packages_to_build:
    print '  <none>'
  print
  print 'Variables to pass to CIPD:'
  package_vars = get_package_vars()
  for k, v in sorted(package_vars.items()):
    print '  %s = %s' % (k, v)
  if tags:
    print
    print 'Tags to attach to uploaded packages:'
    for tag in sorted(tags):
      print '  %s' % tag
  if not packages_to_build:
    print
    print 'Nothing to do.'
    return 0

  # Build the world.
  if build:
    build_callback()

  # Package it.
  failed = []
  succeeded = []
  for pkg_def_file in packages_to_build:
    # path/name.yaml -> out/name.cipd.
    name = os.path.splitext(os.path.basename(pkg_def_file))[0]
    out_file = os.path.join(package_out_dir, name + '.cipd')
    try:
      info = None
      if build:
        info = build_pkg(go_workspace, pkg_def_file, out_file, package_vars)
      if upload:
        info = upload_pkg(
            go_workspace,
            out_file,
            service_url,
            tags,
            service_account_json)
      assert info is not None
      succeeded.append({'pkg_def_name': name, 'info': info})
    except (BuildException, UploadException) as e:
      failed.append({'pkg_def_name': name, 'error': str(e)})

  print_title('Summary')
  for d in failed:
    print 'FAILED %s, see log above' % d['pkg_def_name']
  for d in succeeded:
    print '%s %s' % (d['info']['package'], d['info']['instance_id'])

  if json_output:
    with open(json_output, 'w') as f:
      summary = {
        'failed': failed,
        'succeeded': succeeded,
        'tags': sorted(tags),
        'vars': package_vars,
      }
      json.dump(summary, f, sort_keys=True, indent=2, separators=(',', ': '))

  return 1 if failed else 0


def build_infra():
  """Builds infra.git multiverse."""
  # Python side.
  print_title('Making sure python virtual environment is fresh')
  run_python(
      script=os.path.join(ROOT, 'bootstrap', 'bootstrap.py'),
      args=[
        '--deps_file',
        os.path.join(ROOT, 'bootstrap', 'deps.pyl'),
        os.path.join(ROOT, 'ENV'),
      ])
  # Go side.
  build_go(os.path.join(ROOT, 'go'), [
    'infra/...',
    'github.com/luci/luci-go/client/...',
    'github.com/luci/luci-go/tools/...',
    ])


def main(
    args,
    build_callback=build_infra,
    py_venv=os.path.join(ROOT, 'ENV'),
    go_workspace=os.path.join(ROOT, 'go'),
    package_def_dir=os.path.join(ROOT, 'build', 'packages'),
    package_out_dir=os.path.join(ROOT, 'build', 'out')):
  parser = argparse.ArgumentParser(description='Builds infra CIPD packages')
  parser.add_argument(
      'yamls', metavar='YAML', type=str, nargs='*',
      help='name of a file in build/packages/* with the package definition')
  parser.add_argument(
      '--upload',  action='store_true', dest='upload', default=False,
      help='upload packages into the repository')
  parser.add_argument(
      '--no-rebuild',  action='store_false', dest='build', default=True,
      help='when used with --upload means upload existing *.cipd files')
  parser.add_argument(
      '--builder', metavar='NAME', type=str,
      help='Name of the CI buildbot builder that invokes this script.')
  parser.add_argument(
      '--service-url', metavar='URL', dest='service_url',
      default=PACKAGE_REPO_SERVICE,
      help='URL of the package repository service to use')
  parser.add_argument(
      '--service-account-json', metavar='PATH', dest='service_account_json',
      help='path to credentials for service account to use')
  parser.add_argument(
      '--json-output', metavar='PATH', dest='json_output',
      help='where to dump info about built package instances')
  parser.add_argument(
      '--tags', metavar='KEY:VALUE', type=str, dest='tags', nargs='*',
      help='tags to attach to uploaded package instances')
  args = parser.parse_args(args)
  if not args.build and not args.upload:
    parser.error('--no-rebuild doesn\'t make sense without --upload')
  return run(
      py_venv,
      go_workspace,
      build_callback,
      args.builder,
      package_def_dir,
      package_out_dir,
      [n + '.yaml' if not n.endswith('.yaml') else n for n in args.yamls],
      args.build,
      args.upload,
      args.service_url,
      args.tags or [],
      args.service_account_json,
      args.json_output)


if __name__ == '__main__':
  sys.exit(main(sys.argv[1:]))
#!/usr/bin/env python # Copyright 2015 The Chromium Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. This script rebuilds Python & Go universes of infra.git multiverse and invokes CIPD client to package and upload chunks of it to the CIPD repository as individual packages.  See build/packages/*.yaml for definition of packages. # Root of infra.git repository. # Root of infra gclient solution. # Where to upload packages to by default. # .exe on Windows. Raised on errors during package build step. Raised on errors during package upload step. Invokes a python script.    Raises:     subprocess.CalledProcessError on non zero exit code. Invokes CIPD, parsing -json-output result.    Args:     go_workspace: path to 'infra/go' or 'infra_internal/go'.     cmd: cipd subcommand to run.     args: list of command line arguments to pass to the subcommand.    Returns:     (Process exit code, parsed JSON output or None). Pretty prints a banner to stdout. Bootstraps go environment and rebuilds (and installs) Go packages.    Compiles and installs packages into default GOBIN, which is <path>/go/bin/   (it is setup by go/env.py and depends on what workspace is used).    Args:     go_workspace: path to 'infra/go' or 'infra_internal/go'.     packages: list of packages to build (can include '...' patterns). # Go toolchain embeds absolute paths to *.go files into the executable. Use # symlink with stable path to make executables independent of checkout path. # Remove any stale binaries and libraries. # Recompile ('-a'). Returns a list of absolute paths to files in build/packages/*.yaml.    Args:     package_def_dir: path to build/packages dir to search for *.yaml.     package_def_files: optional list of filenames to limit results to.    Returns:     List of absolute paths to *.yaml files under packages_dir. # All existing package by default. Returns content of YAML file as python dict. # YAML lib is in venv, not activated here. Go through hoops. Returns True if package should be processed by current CI builder. Returns a dict with variables that describe the current environment.    Variables can be referenced in the package definition YAML as   ${variable_name}. It allows to reuse exact same definition file for similar   packages (e.g. packages with same cross platform binary, but for different   platforms). # linux, mac or windows. # platform.mac_ver()[0] is '10.9.5'. # platform.linux_distribution() is ('Ubuntu', '14.04', ...). # platform.version() is '6.1.7601'. # amd64, 386, etc. # e.g. '.exe' or ''. # e.g. 'ubuntu14_04' or 'mac10_9' or 'win6_1'. # e.g. 'linux-amd64' # e.g. '27' (dots are not allowed in package names). Invokes CIPD client to build a package.    Args:     go_workspace: path to 'infra/go' or 'infra_internal/go'.     pkg_def_file: path to *.yaml file with package definition.     out_file: where to store the built package.     package_vars: dict with variables to pass as -pkg-var to cipd.    Returns:     {'package': <name>, 'instance_id': <sha1>}    Raises:     BuildException on error. # Make sure not stale output remains. # Build the package. # Expected result is {'package': 'name', 'instance_id': 'sha1'} Uploads existing *.cipd file to the storage and tags it.    Args:     go_workspace: path to 'infra/go' or 'infra_internal/go'.     pkg_file: path to *.cipd file to upload.     service_url: URL of a package repository service.     tags: a list of tags to attach to uploaded package instance.     service_account: path to *.json file with service account to use.    Returns:     {'package': <name>, 'instance_id': <sha1>}    Raises:     UploadException on error. Rebuild python and Go universes and CIPD packages.    Args:     py_venv: path to 'infra/ENV' or 'infra_internal/ENV'.     go_workspace: path to 'infra/go' or 'infra_internal/go'.     build_callback: called to build binaries, virtual environment, etc.     builder: name of CI buildbot builder that invoked the script.     package_def_dir: path to build/packages dir to search for *.yaml.     package_out_dir: where to put built packages.     package_def_files: names of *.yaml files in package_def_dir or [] for all.     build: False to skip building packages (valid only when upload==True).     upload: True to also upload built packages, False just to build them.     service_url: URL of a package repository service.     tags: a list of tags to attach to uploaded package instances.     service_account_json: path to *.json service account credential.     json_output: path to *.json file to write info about built packages to.    Returns:     0 on success, 1 or error. # Remove stale output so that test_packages.py do not test old files when # invoked without arguments. # Build the world. # Package it. # path/name.yaml -> out/name.cipd. Builds infra.git multiverse. # Python side. # Go side.