blob: 4569f6cafde16f5a5f3b37fab6e37dd7140e7087 [file] [log] [blame]
#!/usr/bin/python
#
# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import argparse
import glob
import logging
import os
import pipes
import re
import shutil
import subprocess
import sys
import time
import urlparse
import yaml
import factory_common # pylint: disable=W0611
from cros.factory.test import utils
from cros.factory.tools.make_update_bundle import MakeUpdateBundle
from cros.factory.tools.mount_partition import MountPartition
from cros.factory.utils.file_utils import UnopenedTemporaryFile
from cros.factory.utils.process_utils import Spawn
GSUTIL_CACHE_DIR = os.path.join(os.environ['HOME'], 'gsutil_cache')
REQUIRED_GSUTIL_VERSION = [3, 18] # 3.18
def CheckDictHasOnlyKeys(dict_to_check, keys):
"""Makes sure that a dictionary's keys are valid.
Args:
dict_to_check: A dictionary.
keys: The set of allowed keys in the dictionary.
"""
if not isinstance(dict_to_check, dict):
raise TypeError('Expected dict but found %s' % type(dict_to_check))
extra_keys = set(dict_to_check) - set(keys)
if extra_keys:
raise ValueError('Found extra keys: %s' % list(extra_keys))
def GSDownload(url):
"""Downloads a file from Google storage, returning the path to the file.
Downloads are cached in GSUTIL_CACHE_DIR.
Args:
url: URL to download.
Returns:
Path to the downloaded file. The returned path may have an arbitrary
filename.
"""
utils.TryMakeDirs(os.path.dirname(GSUTIL_CACHE_DIR))
cached_path = os.path.join(GSUTIL_CACHE_DIR, url.replace('/', '!'))
if os.path.exists(cached_path):
logging.info('Using cached %s (%.1f MiB)',
url, os.path.getsize(cached_path) / (1024.*1024.))
return cached_path
in_progress_path = cached_path + '.INPROGRESS'
Spawn(['gsutil', '-m', 'cp', url, 'file://' + in_progress_path],
check_call=True, log=True)
shutil.move(in_progress_path, cached_path)
return cached_path
def GetReleaseVersion(mount_point):
"""Returns the release version of an image mounted at mount_point."""
match = re.search(
'^CHROMEOS_RELEASE_VERSION=(.+)$',
open(os.path.join(mount_point, 'etc', 'lsb-release')).read(),
re.MULTILINE)
if not match:
sys.exit('Unable to read lsb-release from %s' % mount_point)
return match.group(1)
def GetFirmwareVersions(updater):
"""Returns the firmware versions in an updater.
Args:
updater: Path to a firmware updater.
Returns:
A tuple (bios_version, ec_version)
"""
stdout = Spawn(
[updater, '-V'], log=True, check_output=True).stdout_data
versions = []
for label in ['BIOS version', 'EC version']:
match = re.search(
'^' + label + ':\s+(.+)$', stdout, re.MULTILINE)
if not match:
sys.exit(
'Unable to read %s from chromeos-firmwareupdater output %r' % (
label, stdout))
versions.append(match.group(1))
return tuple(versions)
USAGE = """
Finalizes a factory bundle. This script checks to make sure that the
bundle is valid, outputs version information into the README file, and
tars up the bundle.
The bundle directory (the DIR argument) must have a MANIFEST.yaml file
like the following:
board: link
self.bundle_name: 20121115_pvt
mini_omaha_ip: 192.168.4.1
# Files to download and add to the bundle.
add_files:
- install_into: release
source: "gs://.../chromeos_recovery_image.bin"
- install_into: firmware
extract_files: [ec.bin, nv_image-link.bin]
source: 'gs://.../ChromeOS-firmware-...tar.bz2'
# Files to delete if present.
delete_files:
- install_shim/factory_install_shim.bin
# Files that are expected to be in the bundle.
files:
- MANIFEST.yaml # This file!
- README
- ...
The bundle must be in a directory named
factory_bundle_${board}_${self.bundle_name} (where board and self.bundle_name
are the same as above).
"""
class FinalizeBundle(object):
"""Finalizes a factory bundle (see USAGE).
Properties:
args: Command-line arguments from argparse.
bundle_dir: Path to the bundle directory.
bundle_name: Name of the bundle (e.g., 20121115_proto).
factory_image_path: Path to the factory image in the bundle.
board: Board name (e.g., link).
simple_board: For board name like "base_variant", simple_board is "variant".
simple_board == board if board is not a variant board.
This name is used in firmware and hwid.
manifest: Parsed YAML manifest.
expected_files: List of files expected to be in the bundle (relative paths).
all_files: Set of files actually present in the bundle (relative paths).
readme_path: Path to the README file within the bundle.
factory_image_base_version: Build of the factory image (e.g., 3004.100.0)
release_image_path: Path to the release image.
mini_omaha_script_path: Path to the script used to start the mini-Omaha
server.
"""
args = None
bundle_dir = None
bundle_name = None
factory_image_path = None
board = None
simple_board = None
manifest = None
expected_files = None
all_files = None
readme_path = None
factory_image_base_version = None
release_image_path = None
mini_omaha_script_path = None
def Main(self):
if not utils.in_chroot():
sys.exit('Please run this script from within the chroot.')
self.ParseArgs()
self.LoadManifest()
self.Download()
self.DeleteFiles()
self.MakeUpdateBundle()
self.UpdateMiniOmahaURL()
self.MakeFactoryPackages()
self.FixFactoryParSymlinks()
self.CheckFiles()
self.UpdateReadme()
self.Archive()
def ParseArgs(self):
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description=USAGE)
parser.add_argument(
'--no-download', dest='download', action='store_false',
help="Don't download files from Google Storage (for testing only)")
parser.add_argument(
'--no-updater', dest='updater', action='store_false',
help="Don't make an update bundle (for testing only)")
parser.add_argument(
'--no-archive', dest='archive', action='store_false',
help="Don't make a tarball (for testing only)")
parser.add_argument(
'--no-make-factory-packages', dest='make_factory_package',
action='store_false',
help="Don't call make_factory_package (for testing only)")
parser.add_argument(
'dir', metavar='DIR',
help="Directory containing the bundle")
self.args = parser.parse_args()
logging.basicConfig(level=logging.INFO)
self.bundle_dir = os.path.realpath(self.args.dir)
def LoadManifest(self):
self.manifest = yaml.load(open(
os.path.join(self.args.dir, 'MANIFEST.yaml')))
CheckDictHasOnlyKeys(
self.manifest, ['board', 'bundle_name', 'add_files', 'delete_files',
'files', 'mini_omaha_url'])
self.board = self.manifest['board']
self.simple_board = self.board.split('_')[-1]
self.bundle_name = self.manifest['bundle_name']
if not re.match(r'^\d{8}_', self.bundle_name):
sys.exit("The self.bundle_name (currently %r) should be today's date, "
"plus an underscore, plus a description of the build, e.g.: %r" %
(self.bundle_name, time.strftime("%Y%m%d_proto")))
expected_dir_name = 'factory_bundle_' + self.board + '_' + self.bundle_name
if expected_dir_name != os.path.basename(self.bundle_dir):
sys.exit(
'bundle_name in manifest is %s, so directory name should be %s, '
'but it is %s' % (
self.bundle_name, expected_dir_name,
os.path.basename(self.bundle_dir)))
self.expected_files = set(map(self._SubstVars, self.manifest['files']))
self.factory_image_path = os.path.join(
self.bundle_dir, 'factory_test', 'chromiumos_factory_image.bin')
with MountPartition(self.factory_image_path, 3) as mount:
self.factory_image_base_version = GetReleaseVersion(mount)
self.readme_path = os.path.join(self.bundle_dir, 'README')
def CheckGSUtilVersion(self):
# Check for gsutil >= 3.18.
process = Spawn(['gsutil', 'version'],
read_stderr=True, read_stdout=True)
if ("No such file or directory: '/usr/lib64/gsutil/CHECKSUM'" in
process.stderr_data):
# Sigh... workaround install bug
version = open('/usr/lib/gsutil/VERSION').read()
else:
match = re.match('gsutil version (.+)', process.stderr_data)
assert match, ('Unable to parse "gsutil version" output: %r' %
process.stdout_data)
version = match.group(1)
version_split = [int(x) for x in version.split('.')]
if version_split < REQUIRED_GSUTIL_VERSION:
sys.exit(
'gsutil version >=%s is required; you seem to have %s.\n'
'Please download and install gsutil ('
'https://developers.google.com/storage/docs/gsutil_install), and '
'make sure this is in your PATH before the system gsutil.' % (
'.'.join(str(x) for x in REQUIRED_GSUTIL_VERSION), version))
def Download(self):
# Make sure gsutil is up to date; older versions are pretty broken.
self.CheckGSUtilVersion()
for f in self.manifest['add_files']:
CheckDictHasOnlyKeys(f, ['install_into', 'source', 'extract_files'])
dest_dir = os.path.join(self.bundle_dir, f['install_into'])
utils.TryMakeDirs(dest_dir)
source = self._SubstVars(f['source'])
if self.args.download:
cached_file = GSDownload(source)
if f.get('extract_files'):
install_into = os.path.join(self.bundle_dir, f['install_into'])
if self.args.download:
if cached_file.endswith('.zip'):
Spawn(['unzip', '-o', cached_file,
'-d', install_into] +
f['extract_files'],
log=True, check_call=True)
else:
Spawn(['tar', '-xvvf', cached_file,
'-C', install_into] +
f['extract_files'],
log=True, check_call=True)
for f in f['extract_files']:
self.expected_files.add(os.path.relpath(os.path.join(install_into, f),
self.bundle_dir))
else:
dest_path = os.path.join(dest_dir, os.path.basename(source))
if self.args.download:
shutil.copyfile(cached_file, dest_path)
self.expected_files.add(os.path.relpath(dest_path, self.bundle_dir))
def DeleteFiles(self):
for f in self.manifest['delete_files']:
path = os.path.join(self.bundle_dir, f)
if os.path.exists(path):
os.unlink(path)
def MakeUpdateBundle(self):
# Make the factory update bundle
if self.args.updater:
updater_path = os.path.join(
self.bundle_dir, 'shopfloor', 'shopfloor_data', 'update',
'factory.tar.bz2')
utils.TryMakeDirs(os.path.dirname(updater_path))
MakeUpdateBundle(self.factory_image_path, updater_path)
def UpdateMiniOmahaURL(self):
mini_omaha_url = self.manifest.get('mini_omaha_url')
if not mini_omaha_url:
return
def PatchLSBFactory(mount):
"""Patches lsb-factory in an image.
Returns:
True if there were any changes.
"""
lsb_factory_path = os.path.join(
mount, 'dev_image', 'etc', 'lsb-factory')
logging.info('Patching URLs in %s', lsb_factory_path)
orig_lsb_factory = open(lsb_factory_path).read()
lsb_factory, number_of_subs = re.subn(
'(?m)^(CHROMEOS_(AU|DEV)SERVER=).+$', r'\1' + mini_omaha_url,
orig_lsb_factory)
if number_of_subs != 2:
sys.exit('Unable to set mini-Omaha server in %s' % lsb_factory_path)
if lsb_factory == orig_lsb_factory:
return False # No changes
# Write with sudo, since only root can write this.
process = Spawn('cat > %s' % pipes.quote(lsb_factory_path),
sudo=True, stdin=subprocess.PIPE, shell=True)
process.stdin.write(lsb_factory)
process.stdin.close()
if process.wait():
sys.exit('Unable to write %s' % lsb_factory_path)
return True
# Patch in the install shim, if present.
shims = glob.glob(os.path.join(self.bundle_dir, 'factory_shim',
'chromeos_*_factory*.bin'))
if len(shims) > 1:
sys.exit('Expected to find 1 shim but found %d' % len(shims))
elif len(shims) == 1:
with MountPartition(shims[0], 1, rw=True) as mount:
PatchLSBFactory(mount)
else:
logging.warning('There is no install shim in the bundle.')
# Take care of the netboot initrd as well, if present.
netboot_image = os.path.join(self.bundle_dir, 'factory_shim',
'netboot', 'initrd.uimg')
if os.path.exists(netboot_image):
with UnopenedTemporaryFile(prefix='rootfs.') as rootfs:
with open(netboot_image) as netboot_image_in:
with open(rootfs, 'w') as rootfs_out:
logging.info('Unpacking initrd rootfs')
netboot_image_in.seek(64)
Spawn(
['gunzip', '-c'],
stdin=netboot_image_in, stdout=rootfs_out, check_call=True)
with MountPartition(rootfs, rw=True) as mount:
lsb_factory_changed = PatchLSBFactory(
os.path.join(mount, 'mnt', 'stateful_partition'))
if lsb_factory_changed:
# Success! Zip it back up.
with UnopenedTemporaryFile(prefix='rootfs.') as rootfs_gz:
with open(rootfs_gz, 'w') as out:
Spawn(['pigz', '-9c', rootfs], stdout=out, log=True, call=True)
new_netboot_image = netboot_image + '.INPROGRESS'
Spawn(['mkimage', '-A', 'x86', '-O', 'linux', '-T', 'ramdisk',
'-a', '0x12008000', '-n', 'Factory Install RootFS',
'-C', 'gzip', '-d', rootfs_gz, new_netboot_image],
check_call=True, log=True)
shutil.move(new_netboot_image, netboot_image)
# Take care of netboot firmware, if present.
netboot_firmware_image = os.path.join(self.bundle_dir, 'netboot_firmware',
'nv_image-%s.bin' % self.simple_board)
if os.path.exists(netboot_firmware_image):
update_firmware_vars = os.path.join(self.bundle_dir, 'factory_setup',
'update_firmware_vars.py')
new_netboot_firmware_image = netboot_firmware_image + '.INPROGRESS'
Spawn([update_firmware_vars,
'--force',
'-i', netboot_firmware_image,
'-o', new_netboot_firmware_image,
'--omahaserver=%s' % mini_omaha_url,
'--tftpserverip=%s' % urlparse.urlparse(mini_omaha_url).hostname],
check_call=True, log=True)
shutil.move(new_netboot_firmware_image, netboot_firmware_image)
def MakeFactoryPackages(self):
release_images = glob.glob(os.path.join(self.bundle_dir, 'release/*.bin'))
if len(release_images) != 1:
sys.exit("Expected one release image but found %d" % len(release_images))
self.release_image_path = release_images[0]
factory_setup_dir = os.path.join(self.bundle_dir, 'factory_setup')
make_factory_package = [
'./make_factory_package.sh',
'--board', self.board,
'--complete_script', 'complete_script.sh',
'--release', os.path.relpath(self.release_image_path,
factory_setup_dir),
'--factory', '../factory_test/chromiumos_factory_image.bin',
'--hwid_updater', '../hwid/hwid_v3_bundle_%s.sh' %
self.simple_board.upper()]
firmware_updater = os.path.join(
self.bundle_dir, 'firmware', 'chromeos-firmwareupdate')
if os.path.exists(firmware_updater):
make_factory_package += [
'--firmware_updater', os.path.relpath(
firmware_updater, factory_setup_dir)]
if self.args.make_factory_package:
Spawn(make_factory_package, cwd=factory_setup_dir,
check_call=True, log=True)
# Build the mini-Omaha startup script.
self.mini_omaha_script_path = os.path.join(
self.bundle_dir, 'start_download_server.sh')
if os.path.exists(self.mini_omaha_script_path):
os.unlink(self.mini_omaha_script_path)
with open(self.mini_omaha_script_path, 'w') as f:
f.write('\n'.join([
'#!/bin/bash',
'set -e', # Fail on error
'cd $(dirname $(readlink -f "$0"))/factory_setup',
'cat miniomaha.conf',
'echo Miniomaha configuration MD5SUM: $(md5sum miniomaha.conf)',
'echo Validating configuration...',
('python miniomaha.py --validate_factory_config'),
'echo Starting download server.',
'python miniomaha.py',
'' # Add newline at EOF
]))
os.fchmod(f.fileno(), 0555)
def FixFactoryParSymlinks(self):
"""Fix symlinks to factory.par.
These may have been turning into real files by the buildbots.
"""
factory_par_path = os.path.join(self.bundle_dir,
'shopfloor', 'factory.par')
with open(factory_par_path) as f:
factory_par_data = f.read()
# Look for files that are identical copies of factory.par.
for root, dummy_dirs, files in os.walk(self.bundle_dir):
for f in files:
path = os.path.join(root, f)
if path == factory_par_path:
# Don't replace it with itself!
continue
if (os.path.islink(path) or
os.path.getsize(path) != len(factory_par_data)):
# It's not a real file, or not the right size. Skip.
continue
with open(path) as fobj:
data = fobj.read()
if data != factory_par_data:
# Data isn't the same. Skip.
continue
# Replace the file with a symlink.
logging.info('Replacing %s with a symlink', path)
os.unlink(path)
os.symlink(os.path.relpath(factory_par_path,
os.path.dirname(path)),
path)
def CheckFiles(self):
# Check that the set of files is correct
self.all_files = set()
for root, dirs, files in os.walk(self.bundle_dir):
for f in files:
# Remove backup files and compiled Python files.
if f.endswith('~') or f.endswith('.pyc'):
os.unlink(os.path.join(root, f))
continue
self.all_files.add(
os.path.relpath(os.path.join(root, f), self.bundle_dir))
for d in dirs:
# Remove any empty directories
try:
os.rmdir(d)
except OSError:
pass
missing_files = self.expected_files - self.all_files
extra_files = self.all_files - self.expected_files
if missing_files:
logging.error('Missing files in bundle: %s',
' '.join(sorted(missing_files)))
logging.error("If the files really shouldn't be there, remove them from "
'the "files" section in MANIFEST.yaml')
if extra_files:
logging.error('Unexpected extra files in bundle: %s',
' '.join(sorted(extra_files)))
logging.error('If the files are really expected, '
'add them to the "files" section of MANIFEST.yaml')
if missing_files or extra_files:
sys.exit('Incorrect file set; terminating')
def UpdateReadme(self):
# Grok the README file; we'll be modifying it.
readme_sections = re.findall(
# Section header
r'(\*\*\*\n\*\n\* (.+?)\n\*\n\*\*\*\n)'
# Anything up to (but not including) the next section header
r'((?:(?!\*\*\*).)+)', open(self.readme_path).read(), re.DOTALL)
# This results in a list of tuples (a, b, c), where a is the whole
# section header string; b is the name of the section; and c is the
# contents of the section. Turn each tuple into a list; we'll be
# modifying some of them.
readme_sections = [list(x) for x in readme_sections]
readme_section_index = {} # Map of section name to index
for i, s in enumerate(readme_sections):
readme_section_index[s[1]] = i
for k in ['VITAL INFORMATION', 'CHANGES']:
if k not in readme_section_index:
sys.exit("README is missing %s section" % k)
# Make sure that the CHANGES section contains this version.
expected_str = '%s changes:' % self.bundle_name
if expected_str not in readme_sections[readme_section_index['CHANGES']][2]:
sys.exit('The string %r was not found in the CHANGES section. '
'Please add a section for it (if this is the first '
'version, just say "initial release").' % expected_str)
# Get some vital information
vitals = [
('Board', self.board),
('Bundle', '%s (created by %s, %s)' % (
self.bundle_name, os.environ['USER'],
time.strftime('%a %Y-%m-%d %H:%M:%S %z')))]
vitals.append(('Factory image base', self.factory_image_base_version))
with MountPartition(self.factory_image_path, 1) as f:
vitals.append(('Factory updater MD5SUM', open(
os.path.join(f, 'dev_image/factory/MD5SUM')).read().strip()))
with MountPartition(self.release_image_path, 3) as f:
vitals.append(('Release (FSI)', GetReleaseVersion(f)))
bios_version, ec_version = GetFirmwareVersions(
os.path.join(f, 'usr/sbin/chromeos-firmwareupdate'))
vitals.append(('Release (FSI) BIOS', bios_version))
vitals.append(('Release (FSI) EC', ec_version))
# If we have any firmware in the tree, add them to the vitals.
firmwareupdates = []
for f in self.all_files:
path = os.path.join(self.bundle_dir, f)
if os.path.basename(f) == 'chromeos-firmwareupdate':
firmwareupdates.append(path)
bios_version, ec_version = GetFirmwareVersions(path)
vitals.append(('%s BIOS' % f, bios_version))
vitals.append(('%s EC' % f, ec_version))
elif os.path.basename(f) == 'ec.bin':
# This is tricky, but it's the best we can do for now. Look for a
# line like "link_v1.2.34-56789a 2012-10-01 12:34:56 @build70-m2"
strings = Spawn(['strings', path], check_output=True).stdout_data
match = re.search('^(' + self.simple_board + '.+@.+)$',
strings, re.MULTILINE)
if not match:
sys.exit('Unable to find EC version in %s' % path)
vitals.append((f, match.group(1)))
elif os.path.basename(f).startswith('nv_image'):
strings = Spawn(['strings', path], check_output=True).stdout_data
match = re.search('^(Google_' + self.simple_board +
r'\.\d+\.\d+\.\d+)$',
strings, re.MULTILINE | re.IGNORECASE)
if not match:
sys.exit('Unable to find BIOS version in %s' % path)
vitals.append((f, match.group(1)))
vital_lines = []
max_key_length = max(len(k) for k, v in vitals)
for k, v in vitals:
vital_lines.append("%s:%s %s" % (k, ' ' * (max_key_length - len(k)), v))
vital_contents = '\n'.join(vital_lines)
readme_sections[readme_section_index['VITAL INFORMATION']][2] = (
vital_contents + '\n\n')
index = readme_section_index.get('MINI-OMAHA SERVER')
if index is not None:
instructions = [
'To start a mini-Omaha server:',
'',
' ./start_download_server.sh'
]
readme_sections[index][2] = (
'\n'.join(instructions) + '\n\n')
with open(self.readme_path, 'w') as f:
for header, _, contents in readme_sections:
f.write(header)
f.write(contents)
logging.info('\n\nUpdated %s; vital information:\n%s\n',
self.readme_path, vital_contents)
def Archive(self):
if self.args.archive:
# Done! tar it up, and encourage the poor shmuck who has to build
# the bundle to take a little break.
logging.info('Just works! Creating the tarball. '
'This will take a while... meanwhile, go get %s. '
'You deserve it.',
(['some rest'] * 5 +
['a cup of coffee'] * 7 +
['some lunch', 'some fruit'] +
['an afternoon snack'] * 2 +
['a beer'] * 8)[time.localtime().tm_hour])
for mini in [True, False]:
output_file = self.bundle_dir + ('.mini' if mini else '') + '.tar.bz2'
Spawn(['tar', '-cf', output_file,
'-I', 'pbzip2',
'-C', os.path.dirname(self.bundle_dir)] +
(['--exclude', '*.bin', '--exclude', '*.uimg'] if mini else []) +
[os.path.basename(self.bundle_dir)],
log=True, check_call=True)
logging.info(
'Created %s (%.1f GiB).',
output_file, os.path.getsize(output_file) / (1024.*1024.*1024.))
logging.info('The README file (%s) has been updated. Make sure to check '
'that it is correct!', self.readme_path)
logging.info(
"IMPORTANT: If you modified the README or MANIFEST.yaml, don't forget "
"to check your changes into %s.",
os.path.join(os.environ['CROS_WORKON_SRCROOT'],
'src', 'private-overlays', 'overlay-link-private',
'chromeos-base', 'chromeos-factory-board',
'files', 'bundle'))
def _SubstVars(self, input_str):
"""Substitutes variables into a string.
The following substitutions are made:
${BOARD} -> the simple board name (in uppercase)
${FACTORY_IMAGE_BASE_VERSION} -> the factory image version
"""
subst_vars = {
'BOARD': self.simple_board.upper(),
'FACTORY_IMAGE_BASE_VERSION': self.factory_image_base_version
}
return re.sub(r'\$\{(\w+)\}', lambda match: subst_vars[match.group(1)],
input_str)
if __name__ == '__main__':
FinalizeBundle().Main()