# --------------------------------------------------------------------
# Copyright © 2014-2015 Canonical Ltd.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
# --------------------------------------------------------------------

# --------------------------------------------------------------------
# TODO:
#
# - Stop services, unpack, restart services?
# -   XXX: *DO* unpack device files once above handled.
# - Fix bug triggered by restarting systemd which causes
#   Upgrader.systemd.connection* to hang for 30 seconds even when
#   recreated.
# --------------------------------------------------------------------

'''
Apply an Ubuntu Core system image update.

WARNING : If running an in-place upgrade, should be run within a new
          mount namespace to avoid disturbing the existing (read-only)
          root filesystem.

NOTE    : The signature files associated with each image file is *NOT*
          re-verified, since this must already have been handled by
          system-image-cli(1).
'''

import sys
import os
import logging
import shutil
import subprocess
import tempfile
import tarfile
import argparse

script_name = os.path.basename(__file__)

log = logging.getLogger()

DEFAULT_ROOT = '/'

# The tar file contains a 'system/' directory with the new files
# to apply to the real system. It may also contain a top-level
# file called 'removed' that lists files relative to the system
# mount for files on the system that should be removed before
# unpacking the rest of the archive.
#
# Examples:
#
# - if the file '/etc/foo' should be removed, it
#   would be specified in the removed_file as 'system/etc/foo'.
#
# - if the file '/etc/bar' should be modified, it
#   would be specified in the tar file as member 'system/etc/bar'.
TAR_FILE_SYSTEM_PREFIX = 'system/'

# boot assets (kernel, initrd, .dtb's)
TAR_FILE_ASSETS_PREFIX = 'assets/'

# the file that describes the boot assets
TAR_FILE_HARDWARE_YAML = 'hardware.yaml'

TAR_FILE_REMOVED_FILE = 'removed'

SYSTEM_IMAGE_CHANNEL_CONFIG = '/etc/system-image/channel.ini'


def parse_args(args):
    '''
    Handle command-line options.

    Returns: dict of command-line options.
    '''

    parser = argparse.ArgumentParser(description='System image Upgrader')

    parser.add_argument(
        '--debug',
        nargs='?', const=1, default=0, type=int,
        help='Dump debug info (specify numeric value to increase verbosity)')

    parser.add_argument(
        '-n', '--dry-run',
        action='store_true',
        help='''
        Simulate an update including showing processes that have locks
        on files
        ''')

    parser.add_argument(
        '--leave-files',
        action='store_true',
        help='Do not remove the downloaded system image files after upgrade')

    parser.add_argument(
        '--root-dir',
        default=DEFAULT_ROOT,
        help='Specify an alternative root directory (for testing ONLY)')

    parser.add_argument(
        '-t', '--tmpdir',
        help='Specify name for pre-existing temporary directory to use')

    parser.add_argument(
        'cmdfile', action="store",
        nargs='?',
        help='Name of file containing commands to execute')

    return parser.parse_args(args=args)


def remove_prefix(path, prefix=TAR_FILE_SYSTEM_PREFIX):
    '''
    Remove specified prefix from path and return the result.

    Prefix must end with a slash.

    If @prefix is not a prefix of @path, returns None.
    '''

    assert(prefix.endswith('/'))

    i = path.find(prefix)

    if i < 0:
        return None

    # ensure that the returned path has a leading slash
    return path[len(prefix)-1:]


def fsck(device):
    '''
    Run fsck(8) on specified device.
    '''
    assert (os.path.exists(device))

    failed = False

    cmd = '/sbin/fsck'
    args = []

    args.append(cmd)

    # Paranoia - don't fsck if already mounted
    args.append('-M')

    args.append('-av')
    args.append(device)

    log.debug('running: {}'.format(args))

    proc = subprocess.Popen(args,
                            stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE,
                            universal_newlines=True)

    ret = proc.wait()

    if ret == 0:
        return

    stdout, stderr = proc.communicate()

    # fsck's return code 1 means: "Filesystem errors corrected"
    # (aka a warning - FS is consistent [now]).
    failed = False if ret == 1 else True

    log.error('{} returned {} ({}): {}, {}'
              .format(args,
                      ret,
                      "failed" if failed else "warning",
                      stdout,
                      stderr))

    if failed:
        sys.exit(1)


def mkfs(device, fs_type, label):
    '''
    Run mkfs(8) on specified device.
    '''
    assert (os.path.exists(device))

    args = ['/sbin/mkfs',
            '-v',
            '-t', fs_type,
            '-L', label,
            device]

    log.debug('running: {}'.format(args))

    proc = subprocess.Popen(args,
                            stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE,
                            universal_newlines=True)

    ret = proc.wait()

    if ret == 0:
        return

    stdout, stderr = proc.communicate()

    log.error('{} returned {}: {}, {}'
              .format(args,
                      ret,
                      stdout,
                      stderr))
    sys.exit(1)


def touch_file(path):
    '''
    Create an empty file specified by path.
    '''
    open(path, 'w').close()


def get_mount_details(target):
    '''
    Returns a list comprising device, filesystem type and filesystem
    label for the mount target specified.
    '''
    args = ['findmnt', '-o', 'SOURCE,FSTYPE,LABEL', '-n', target]
    output = subprocess.check_output(args, universal_newlines=True)
    output = output.strip().split()

    if len(output) != 3:
        sys.exit('Failed to determine mount details for {}'
                 .format(target))

    return output


def remount(mountpoint, options):
    """
    Remount mountpoint using the specified options string (which is
    passed direct to the mount command and which must not contain
    spaces).
    """
    cmd = 'mount'

    args = []

    args.append(cmd)
    args.append('-oremount,{}'.format(options))
    args.append(mountpoint)

    log.debug('running: {}'.format(args))

    proc = subprocess.Popen(args,
                            stderr=subprocess.PIPE,
                            universal_newlines=True)
    if proc.wait() != 0:
        stderr = proc.communicate()[1]
        log.error('failed to remount ({}): {}'
                  .format(args, stderr))
        sys.exit(1)


def mount(source, target, options=None):
    '''
    Mount @source on @target using optional specified
    options string (which must not contain spaces).
    '''

    cmd = 'mount'

    args = []

    args.append(cmd)
    if options:
        args += ['-o', options]
    args.append(source)
    args.append(target)

    log.debug('running: {}'.format(args))

    proc = subprocess.Popen(args,
                            stderr=subprocess.PIPE,
                            universal_newlines=True)
    if proc.wait() != 0:
        stderr = proc.communicate()[1]
        log.error('failed to mount ({}): {}'
                  .format(args, stderr))
        sys.exit(1)


def bind_mount(source, target):
    '''
    Bind mount @source to @target.
    '''
    log.debug('bind mounting existing root')
    mount(source, target, 'bind')


def unmount(target, options=None):
    '''
    Unmount the specified mount target, using the specified list of
    options.
    '''

    log.debug('unmounting {}'.format(target))

    args = []
    args.append('umount')
    if options:
        args.extend(options)
    args.append(target)

    log.debug('running: {}'.format(args))

    proc = subprocess.Popen(args,
                            stderr=subprocess.PIPE,
                            universal_newlines=True)

    if proc.wait() != 0:
        stderr = proc.communicate()[1]
        log.error('failed to unmount ({}): {}'
                  .format(args, stderr))

        # its a lazy umount, do not fail hard just now.
        #
        # FIXME: entry in mount table for /etc/writable/hostname really
        # does not seem to have been mounted by systemd. No problem when
        # booting under upstart as mountall does bind mount that path.
        #
        # sys.exit(1)


def tar_generator(tar, cache_dir, removed_files, root_dir):
    '''
    Generator function to handle extracting members from the system
    image tar files.
    '''
    for member in tar:

        # Restrictions:
        #
        # - Don't unpack the removed file.
        # - Don't unpack device files *iff* they already exist.
        # - Don't unpack files that are not below
        #   TAR_FILE_SYSTEM_PREFIX or TAR_FILE_ASSETS_PREFIX.
        #
        # - XXX: This is a temporary function to work-around for
        #   LP: #1381121: we shouldn't need to filter which files are
        #   extracted!
        device_prefix = '{}dev/'.format(TAR_FILE_SYSTEM_PREFIX)

        # The partition to update is mounted at "cache_dir/system".
        # However, the unpack occurs in directory cache_dir (since the
        # tar files prefixes all system files with
        # TAR_FILE_SYSTEM_PREFIX). For example, member.name may be
        # something like "system/dev/null".
        mount_path = '{}/{}'.format(cache_dir, member.name)

        unpack = True

        if member.name == removed_files:
            # already handled
            unpack = False

        if member.name.startswith(device_prefix) and \
                os.path.exists(mount_path):
            # device already exists
            unpack = False

        member_prefix = None

        if member.name.startswith(TAR_FILE_SYSTEM_PREFIX):
            member_prefix = TAR_FILE_SYSTEM_PREFIX
        elif member.name.startswith(TAR_FILE_ASSETS_PREFIX):
            member_prefix = TAR_FILE_ASSETS_PREFIX

        if not member_prefix:
            # unrecognised prefix directory
            unpack = False

        if member.name == TAR_FILE_HARDWARE_YAML:
            unpack = True

        if not unpack:
            log.debug('not unpacking file {}'.format(member.name))
            continue

        # A modified root directory requires convering
        # absolute paths to be located below the modified root
        # directory.
        if root_dir != '/':
            if member_prefix == TAR_FILE_SYSTEM_PREFIX:
                base = remove_prefix(member.name, prefix=member_prefix)
                member.name = os.path.normpath('{}/{}'.format(root_dir,
                                                              base))
            else:
                member.name = os.path.normpath('{}/{}'.format(root_dir,
                                                              member.name))

            if member.type in (tarfile.LNKTYPE, tarfile.SYMTYPE) \
                    and member.linkname.startswith('/'):
                # Hard and symbolic links also need their
                # 'source' updated to take account of the root
                # directory.
                #
                # But rather than remove the prefix, we add the
                # root directory as a prefix to contain the link
                # within that root.
                base = os.path.join(root_dir, member.linkname)

                member.linkname = '{}{}'.format(root_dir, base)

            path = member.name
        else:
            path = mount_path

        log.debug('unpacking file {} with path {}'
                  .format(member.name, path))

        # If the file is a binary and that binary is currently
        # being executed by a process, attempting to unpack it
        # will result in ETXTBSY (OSError: [Errno 26] Text file
        # busy). The simplest way around this issue is to unlink
        # the file just before unpacking it (ideally, we'd catch
        # the exception and handle it separately). This allows
        # the unpack to continue, and the running process to
        # continue to use it's (old) version of the binary until
        # it's corresponding service is restarted.
        #
        # Note that at this point, we already have another copy
        # of the inode below /lost+found/.
        if not member.isdir() and os.path.lexists(path):
            log.debug('removing file {}'.format(path))
            os.unlink(path)

        yield member


class Upgrader():

    # FIXME: Should query system-image-cli (see bug LP:#1380574).
    DEFAULT_CACHE_DIR = '/writable/cache'

    # Directory to mount writable root filesystem below the cache
    # diretory.
    MOUNT_TARGET = 'system'

    # Magic file that records time last system image was successfully applied.
    TIMESTAMP_FILE = '.last_update'

    DIR_MODE = 0o750

    MOUNTPOINT_CMD = "mountpoint"

    def __init__(self, options, commands, remove_list):
        """
        @options list of command-line options.
        @commands: array of declarative commands to run to apply the new
         s-i rootfs update.
        @remove_list: list of files to remove before applying the new
         image.

        """
        self.dispatcher = {
            'format': self._cmd_format,
            'load_keyring': self._cmd_load_keyring,
            'mount': self._cmd_mount,
            'unmount': self._cmd_unmount,
            'update': self._cmd_update,
        }

        self.options = options

        assert('root_dir' in self.options)

        # array of imperative commands to run, as generated by
        # system-image-cli(1).
        self.commands = commands

        # files to remove before unpacking new image.
        self.remove_list = remove_list
        self.full_image = False

        self.lost_found = '/lost+found'

        self.removed_file = TAR_FILE_REMOVED_FILE

        # Identify the directory the files the command file refers to
        # live in.
        if self.options.cmdfile:
            self.file_dir = os.path.dirname(self.options.cmdfile)

        # list of systemd unit D-Bus paths (such as
        # '/org/freedesktop/systemd1/unit/cron_2eservice') that need to
        # be restarted since they have running processes that are
        # holding open files that the upgrader needs to replace.
        self.services = []

        # If True, the other partition is considered empty. In this
        # scenario, prior to unpacking the latest image, the _current_
        # rootfs image is copied to the other partition.
        self.other_is_empty = False

        # True if the upgrader has reformatted the "other" partition.
        self.other_has_been_formatted = False

        # cache_dir is used as a scratch pad, for downloading new images
        # to and bind mounting the rootfs.
        if self.options.tmpdir:
            self.cache_dir = self.options.tmpdir
        else:
            self.cache_dir = self.DEFAULT_CACHE_DIR

    def update_timestamp(self):
        '''
        Update the timestamp file to record the time the last upgrade
        completed successfully.
        '''
        file = os.path.join(self.cache_dir, self.TIMESTAMP_FILE)
        touch_file(file)

    def get_mount_target(self):
        '''
        Get the full path to the mount target directory.
        '''
        return os.path.join(self.cache_dir, self.MOUNT_TARGET)

    def prepare(self):
        '''
        Required setup.
        '''

        target = self.get_mount_target()
        if subprocess.call([self.MOUNTPOINT_CMD, "-q", target]) != 0:
            raise Exception(
                "The {} directory is not a mountpoint".format(target))

        if self.options.dry_run:
            return

        if self.options.root_dir != '/':
            # Don't modify root when running in test mode
            return

    def run(self):
        '''
        Execute the commands in the command file
        '''
        self.prepare()

        for cmdline in self.commands:
            cmdline = cmdline.strip()

            args = cmdline.split()
            cmd = args[0]
            args = args[1:]

            if self.dispatcher[cmd]:
                log.debug('running dispatcher {} ({})'.format(cmd, args))
                self.dispatcher[cmd](args)
            else:
                log.warning('ignoring bogus input line: {}'
                            .format(cmdline))
        self.finish()

    def finish(self):
        '''
        Final tidy-up.
        '''

        if self.options.leave_files:
            log.debug('not removing files')
        else:
            for file in self.remove_list:
                log.debug('removing file {}'.format(file))
                os.remove(file)

        # Don't remount or reboot in test mode.
        if self.options.root_dir != '/':
            return

        self.update_timestamp()

    def _cmd_format(self, args):
        try:
            target = args[0]
        except:
            log.warning('expected target')
            return

        if target == 'system':
            self.full_image = True

        # Don't modify the system state
        if self.options.dry_run or self.options.root_dir != '/':
            return

        other = self.get_mount_target()

        source, fstype, label = get_mount_details(other)

        unmount(other)
        mkfs(source, fstype, label)

        # leave the mount as it was initially.
        mount(source, other, "ro")

        self.other_has_been_formatted = True

    def _cmd_load_keyring(self, args):
        try:
            keyring = args[0]
            signature = args[1]
        except:
            log.warning('expected keyring and signature')
            return

        keyring = os.path.join(self.file_dir, keyring)
        signature = os.path.join(self.file_dir, signature)

        self.remove_list.append(keyring)
        self.remove_list.append(signature)

        # already handled by system-image-cli(1) on image download
        log.info('ignoring keyring {} and signature {}'
                 ' (already handled)'
                 .format(keyring, signature))

    def _cmd_mount(self, args):
        """
        Although the name of this method matches the verb in the command
        file, it actually remounts "other" r/w now since dual partition
        systems have the other partition permanently mounted read-only.
        """

        try:
            target_type = args[0]
        except:
            log.warning('expected target type')
            return

        # the mount command is an imperative one: this script decides
        # what to mount and where :)
        if target_type != 'system':
            log.warning('unknown mount target type: {}'
                        .format(target_type))
            return

        if self.other_considered_empty():
            log.debug('other root partition is empty')
            self.other_is_empty = True

        if self.other_is_empty and not self.other_has_been_formatted:
            # We believe "other" is empty. However, it's possible that a
            # previous attempt at upgrading failed due to a power
            # outage. In that scenario, "other" may contain most of a
            # rootfs image, but just be missing the files used to
            # determine that the partition is empty. As such, if we
            # believe the partition is empty, it should forcibly be made
            # empty since it may contain detritus from a
            # previously-failed unpack (possibly caused by a power
            # outage).
            log.warning('reformatting other (no system image)')
            self._cmd_format("system")

        self.remount_rootfs(writable=True)

        if self.other_is_empty and not self.full_image:
            # Copy current rootfs data to the other rootfs's blank
            # partition.
            log.debug('syncing root partitions')
            self.sync_partitions()

    def _cmd_unmount(self, args):
        """
        Although the name of this method matches the verb in the command
        file, it actually remounts "other" r/o now since dual partition
        systems have the other partition permanently mounted read-only.
        """

        try:
            target_type = args[0]
        except:
            log.warning('expected target type')
            return

        # the unmount command is an imperative one: this script decides
        # what to mount and where :)
        if target_type != 'system':
            log.warning('unknown mount target type: {}'
                        .format(target_type))
            return

        self.remount_rootfs(writable=False)

    def other_considered_empty(self):
        '''
        Returns True if the other rootfs should be considered empty.

        See sync_partitions() for further details.

        '''
        target = self.get_mount_target()
        channel_ini = os.path.normpath('{}/{}'.format(target,
                                       SYSTEM_IMAGE_CHANNEL_CONFIG))

        try:
            st = os.stat(channel_ini)
        except OSError:
            # ENOENT
            return True

        if st.st_size == 0:
            # The config file is zero bytes long. This is used as a
            # marker to denote that the initial "sync system-a to
            # system-b" was started, but was interrupted, either in the
            # copy phase or in the s-i unpack phase. Either way, the
            # partition must be considered empty until the config file
            # has a size > 0.
            return True

        return False

    def remount_rootfs(self, writable=False):
        target = self.get_mount_target()

        if writable:
            root, _, _ = get_mount_details(target)

            # ro->rw so need to fsck first.
            unmount(target)

            # needs to be mounted writable, so check it first!
            fsck(root)

            mount(root, target, "rw")
        else:
            # rw->ro so no fsck required.
            remount(target, "ro")

    def get_file_contents(self, tar, file):
        '''
        @tar: tarfile object.
        @file: full path to file within @tar to extract.

        Returns: contents of @file from within @tar.
        '''
        tmpdir = tempfile.mkdtemp(prefix=script_name)
        tar.extract(path=tmpdir, member=tar.getmember(file))

        path = os.path.join(tmpdir, file)

        lines = []

        with open(path, 'r') as f:
            lines = f.readlines()

        lines = [line.rstrip() for line in lines]

        shutil.rmtree(tmpdir)

        return lines

    def _cmd_update(self, args):
        '''
        Unpack a new system image.

        Note that this method will be called multiple times, once for
        each file that comprises the upgrade.
        '''
        try:
            file = args[0]
            signature = args[1]
        except:
            log.warning('expected file and signature')
            return

        file = os.path.join(self.file_dir, file)
        signature = os.path.join(self.file_dir, signature)

        for f in (file, signature):
            if not os.path.exists(f):
                log.warning('ignoring missing file {}'
                            .format(f))
                return

            self.remove_list.append(f)

        log.info('applying update: {}'.format(file))
        tar = tarfile.open(file)

        found_removed_file = False

        # start with a list of all the files in the tarfile
        all_files = tar.getnames()

        if self.removed_file in all_files:
            # Exclude the removed file from the normal list as it is not
            # to be unpacked on the normal filesystem.
            all_files.remove(self.removed_file)
            found_removed_file = True

        # convert the paths back into real filesystem paths
        # (by dropping the 'system/' prefix).
        all_files = list(map(remove_prefix, all_files))

        # remove entries for files which failed the remove_prefix() check
        all_files = [x for x in all_files if x is not None]

        if found_removed_file:
            to_remove = self.get_file_contents(tar, self.removed_file)
        else:
            to_remove = []

        # by definition, full images don't have 'removed' files.
        if not self.full_image:
            if found_removed_file:
                log.debug('processing {} file'
                          .format(self.removed_file))

                # process backwards to work around bug LP:#1381134.
                for remove in sorted(to_remove, reverse=True):

                    if not remove:
                        # handle invalid removed file entry (see LP:#1437225)
                        continue

                    remove = remove.strip()

                    # don't remove devices
                    if remove.startswith('{}dev/'
                                         .format(TAR_FILE_SYSTEM_PREFIX)):
                        continue

                    # The upgrader runs as root so can modify any
                    # file. However, we should still check to ensure the
                    # paths look "reasonable". Since the server
                    # should only ever specify absolute paths,
                    # ignore anything that isn't.
                    if '../' in remove:
                        continue

                    final = os.path.join(self.cache_dir, remove)

                    if not os.path.exists(final):
                        # This scenario can only mean there is a bug
                        # with system-image generation (or someone made
                        # the image writable and removed some files
                        # manually).
                        log.debug('ignoring non-existent file {}'
                                  .format(final))
                        continue

                    if self.options.dry_run:
                        log.info('DRY-RUN: would remove file {}'.format(final))
                        continue

                    log.debug('removing file {}'.format(final))
                    try:
                        if os.path.isdir(final) and not os.path.islink(final):
                            shutil.rmtree(final)
                        else:
                            os.remove(final)
                    except Exception as e:
                        log.warning('failed to remove {}: {}'
                                    .format(final, e))

        if self.options.dry_run:
            log.info('DRY-RUN: would apply the following files:')
            tar.list(verbose=True)
        else:
            log.debug('starting unpack')
            # see bug #1408579, this forces tarfile to use tarinfo.gid
            # instead of looking up the gid by local name which might
            # be incorrect
            from unittest.mock import patch
            with patch("grp.getgrnam") as m:
                m.side_effect = KeyError()
                tar.extractall(path=self.cache_dir,
                               members=tar_generator(
                                   tar, self.cache_dir,
                                   self.removed_file,
                                   self.options.root_dir))
        tar.close()

        os.sync()

    def sync_partitions(self):
        '''
        Copy all rootfs data from the current partition to the other
        partition.

        XXX: Assumes that the other rootfs is already mounted r/w to
        mountpoint get_mount_target().

        Strategy: The partition sync is only called once (when the "other"
        rootfs is empty), to make the empty partition identical to the
        current rootfs partition, before unpacking the latest s-i updates
        onto it.

        Since there are 2 operations to perform for the initial update
        (copy and unpack) and since the upgrader must be resilient to
        power outages at any point when either of the operations are
        running, we need a way for the upgrader to detect if a previous
        invocation failed. This is achieved by treating the s-i
        configuration file (SYSTEM_IMAGE_CHANNEL_CONFIG) in a special
        way.

        = Background =

        A rootfs is not considered complete until the file
        SYSTEM_IMAGE_CHANNEL_CONFIG has been unpacked onto it. However,
        that implies we cannot copy the original
        SYSTEM_IMAGE_CHANNEL_CONFIG from the current rootfs to "other"
        as the copy is only the first operation and we don't want that
        "partial update" to be considered as valid.

        Ideally, we would perform the copy, excluding
        just SYSTEM_IMAGE_CHANNEL_CONFIG. However, rsync (which has
        excellent support for copying files with exclusions) is not
        available.

        = Approach =

        The current approach is:

            1) Create the directory part of SYSTEM_IMAGE_CHANNEL_CONFIG
               on "other".

            2) Touch SYSTEM_IMAGE_CHANNEL_CONFIG as an empty file on
               "other".

            3) Perform an "update copy" from current to "other". Note
               that this will not modify the pre-existing
               SYSTEM_IMAGE_CHANNEL_CONFIG, but will conveniently correct
               the permissions on /etc/system-image/ to match those on
               "current" (in the case that the original permissions used
               by this function do not match those on "current").

        The second part of the operation is to unpack the s-i update onto
        "other" (the final part of which is to unpack
        SYSTEM_IMAGE_CHANNEL_CONFIG (which will be >0 bytes), thus
        making that rootfs complete and valid).

        = Special treatment of SYSTEM_IMAGE_CHANNEL_CONFIG =

        Using this approach makes it easy to determine if the initial
        update failed since if either SYSTEM_IMAGE_CHANNEL_CONFIG does
        not exist or is only zero bytes, the s-i unpack must have failed
        meaning the rootfs must be considered "empty".
        '''
        target = self.get_mount_target()
        bindmount_rootfs_dir = tempfile.mkdtemp(prefix=script_name,
                                                dir=self.cache_dir)
        bind_mount("/", bindmount_rootfs_dir)

        cwd = os.getcwd()
        os.chdir(bindmount_rootfs_dir)

        channel_ini = os.path.normpath('{}/{}'.format(target,
                                       SYSTEM_IMAGE_CHANNEL_CONFIG))
        si_dir = os.path.dirname(channel_ini)
        os.makedirs(si_dir)
        touch_file(channel_ini)

        args = ['/bin/cp', '-au', '.', target]

        log.debug('running (from directory {}): {}'
                  .format(bindmount_rootfs_dir, args))

        proc = subprocess.Popen(args,
                                stdout=subprocess.DEVNULL,
                                stderr=subprocess.DEVNULL,
                                universal_newlines=True)
        if proc.wait() != 0:
            os.chdir(cwd)
            log.error('failed to sync partitions')
            sys.exit(1)

        os.sync()

        os.chdir(cwd)

        unmount(bindmount_rootfs_dir)
        os.rmdir(bindmount_rootfs_dir)
