Ejemplo n.º 1
0
 def test_fstab_get_uuid(self):
     fstab = Fstab(fstab=os.path.join(self.testdir, "data", "fstab"))
     self.assertEqual(fstab.uuid_for_mountpoint("/"), "UUID=fe63f598-1906-478e-acc7-f74740e78d1f")
class AptBtrfsSnapshot(object):
    """ the high level object that interacts with the snapshot system """

    def __init__(self, fstab="/etc/fstab", sandbox=None):
        self.fstab = Fstab(fstab)
        self.commands = LowLevelCommands()
        # if we haven't been given a testing ground to play in, mount the real
        # root volume
        self.test = sandbox is not None
        self.mp = sandbox
        if self.mp is None:
            uuid = self.fstab.uuid_for_mountpoint("/")
            mountpoint = tempfile.mkdtemp(prefix="apt-btrfs-snapshot-mp-")
            if not self.commands.mount(uuid, mountpoint):
                os.rmdir(mountpoint)
                raise Exception("Unable to mount root volume")
            self.mp = mountpoint
        snapshots.setup(self.mp)

    def __del__(self):
        """ unmount root volume if necessary """
        # This will probably not get run if there are cyclic references.
        # check thoroughly because we get called even if __init__ fails
        if not self.test and self.mp is not None:
            res = self.commands.umount(self.mp)
            os.rmdir(self.mp)
            self.mp = None

    def _get_now_str(self):
        return datetime.datetime.now().replace(microsecond=0).isoformat(
            str('_'))

    def _parse_older_than_to_datetime(self, timefmt):
        if isinstance(timefmt, datetime.datetime):
            return timefmt
        now = datetime.datetime.now()
        if not timefmt.endswith("d"):
            raise Exception("Please specify time in days (e.g. 10d)")
        days = int(timefmt[:-1])
        return now - datetime.timedelta(days)
        
    def _get_last_snapshot_time(self):
        last_snapshot = datetime.datetime.fromtimestamp(0.0)
        if self.test:
            last_snapshot_file = '/tmp/apt_last_snapshot'
        else:
            last_snapshot_file = '/run/apt_last_snapshot'

        if os.path.exists(last_snapshot_file):
            try:
                t = open(last_snapshot_file)
                last_snapshot = \
                datetime.datetime.fromtimestamp(float(t.readline()))
            except:
                # If we fail to read the timestamp for some reason, just return
                # the default value silently
                pass
            finally:
                t.close()
        return last_snapshot

    def _save_last_snapshot_time(self):
        if self.test:
            last_snapshot_file = '/tmp/apt_last_snapshot'
        else:
            last_snapshot_file = '/run/apt_last_snapshot'
        f = open(last_snapshot_file, 'w')
        f.write(str(time.time()))
        f.close()

    def _get_status(self):
        
        parent = Snapshot("@").parent
        if parent is not None:
            date_parent = parent.date
        else:
            date_parent = None
        if self.test:
            testdir = os.path.dirname(os.path.abspath(__file__))
            if not testdir.endswith("test"):
                testdir = os.path.join(testdir, "test")
            var_location = os.path.join(testdir, "data/var")
            history = DpkgHistory(since = date_parent, 
                var_location = var_location)
        else:
            history = DpkgHistory(since = date_parent)
        return parent, history

    def _prettify_changes(self, history, i_indent="- ", s_indent="    "):
        if history == None or history == NO_HISTORY:
            return [i_indent + "No packages operations recorded"]
        output = []
        for op in ("install", "auto-install", "upgrade", "remove", "purge"):
            if len(history[op]) > 0:
                output.append("%s%ss (%d):" % (i_indent, op, len(history[op])))
                packages = []
                for p, v in history[op]:
                    packages.append(p)
                packages = ", ".join(packages)
                if sys.stdout.isatty():
                    # if we are in a terminal, wrap text to match its width
                    rows, columns = os.popen('stty size', 'r').read().split()
                    packages = textwrap.wrap(packages, width=int(columns), 
                        initial_indent=s_indent, subsequent_indent=s_indent,
                        break_on_hyphens=False)
                output.extend(packages)
        return output
    
    def status(self):
        """ show current root's parent and recent changes """
        return self.show("@")
    
    def show(self, snapshot, compact=False):
        """ show details pertaining to given snapshot """
        snapshot = Snapshot(snapshot)
        if snapshot.name == "@":
            parent, changes = self._get_status()
        else:
            parent, changes = snapshot.parent, snapshot.changes
        
        fca = snapshots.first_common_ancestor("@", snapshot)
        mainline = (fca == snapshot) and 'Is' or "Isn't"
        mainline = "%s an ancestor of @" % mainline
        
        pretty_history = self._prettify_changes(changes)

        if parent == None:
            parent = "unknown"
        else:
            parent = parent.name
        
        if not compact:
            title = "Snapshot %s" % snapshot.name
            print(title)
            if snapshot.name != "@":
                print(mainline)
            print("Parent: %s" % parent)
            if parent == "unknown" and snapshot.name == "@":
                print("dpkg history shown for the last 30 days")
            print("dpkg history:")
        else:
            print("dpkg history for %s" % snapshot.name)
        print("\n".join(pretty_history))
        
        return True
    
    def create(self, tag=""):
        """ create a new apt-snapshot of @, tagging it if a tag is given """
        if 'APT_NO_SNAPSHOTS' in os.environ and tag == "":
            print("Shell variable APT_NO_SNAPSHOTS found, skipping creation")
            return True
        elif 'APT_NO_SNAPSHOTS' in os.environ and tag != "":
            print("Shell variable APT_NO_SNAPSHOTS found, but tag supplied, "
                "creating snapshot")
        last = self._get_last_snapshot_time()

        # If there is a recent snapshot and no tag supplied, skip creation
        if tag == "" \
        and last > datetime.datetime.now() - datetime.timedelta(seconds=60):
            print("A recent snapshot already exists: %s" % last)
            return True
        
        # make snapshot
        snap_id = SNAP_PREFIX + self._get_now_str() + tag
        res = self.commands.btrfs_subvolume_snapshot(
            os.path.join(self.mp, "@"),
            os.path.join(self.mp, snap_id))
        
        # set root's new parent
        Snapshot("@").parent = snap_id
        
        # find and store dpkg changes
        parent, history = self._get_status()
        Snapshot(snap_id).changes = history
        
        self._save_last_snapshot_time()
        return res
    
    def tag(self, snapshot, tag):
        """ Adds/replaces the tag for the given snapshot """
        children = Snapshot(snapshot).children

        pos = len(SNAP_PREFIX)
        new_name = snapshot[:pos + 19] + tag
        old_snap = os.path.join(self.mp, snapshot)
        new_snap = os.path.join(self.mp, new_name)
        os.rename(old_snap, new_snap)
        
        tagged = Snapshot(new_name)
        for child in children:
            child.parent = tagged
        return True

    def list(self):
        # The function name will not clash with reserved keywords. It is only
        # accessible via self.list()
        print("Available snapshots:")
        print("  \n".join(snapshots.get_list()))
        return True

    def list_older_than(self, timefmt):
        older_than = self._parse_older_than_to_datetime(timefmt)
        print("Available snapshots older than '%s':" % timefmt)
        print("  \n".join(snapshots.get_list(older_than=older_than)))
        return True

    def _prompt_for_tag(self):
        print("You haven't specified a tag for the snapshot that will be created from the current state.")
        tag = raw_input("Please enter a tag: ")
        if tag:
            tag = "-" + tag
        return tag
    
    def set_default(self, snapshot, tag=""):
        """ backup @ and replace @ with a copy of given snapshot """
        if not tag:
            tag = self._prompt_for_tag()

        snapshot = Snapshot(snapshot)
        new_root = os.path.join(self.mp, snapshot.name)
        if (
                os.path.isdir(new_root) and
                snapshot.name.startswith(SNAP_PREFIX)):
            default_root = os.path.join(self.mp, "@")
            staging = os.path.join(self.mp, "@apt-btrfs-staging")
            if os.path.lexists(staging):
                raise Exception("Reserved directory @apt-btrfs-staging "
                    "exists\nPlease remove from btrfs volume root before "
                    "trying again")
            
            # find and store dpkg changes
            date, history = self._get_status()
            Snapshot("@").changes = history
                
            # snapshot the requested default so as not to remove it
            res = self.commands.btrfs_subvolume_snapshot(new_root, staging)
            if not res:
                raise Exception("Could not create snapshot")

            # make backup name
            backup = os.path.join(self.mp, 
                SNAP_PREFIX + self._get_now_str()) + tag
            # if backup name is already in use, wait a sec and try again
            if os.path.exists(backup):
                time.sleep(1)
                backup = os.path.join(self.mp, 
                    SNAP_PREFIX + self._get_now_str())

            # move everything into place
            os.rename(default_root, backup)
            os.rename(staging, default_root)
            
            # remove @/etc/apt-btrfs-changes & set root's new parent
            new_default = Snapshot("@")
            new_default.changes = None
            new_default.parent = snapshot.name
            
            print("Default changed to %s, please reboot for changes to take "
                  "effect." % snapshot.name)
        else:
            print("You have selected an invalid snapshot. Please make sure "
                  "that it exists, and that its name starts with "
                  "\"%s\"" % SNAP_PREFIX)
        return True

    def rollback(self, number=1, tag=""):
        back_to = Snapshot("@")
        for i in range(number):
            back_to = back_to.parent
            if back_to == None:
                raise Exception("Can't rollback that far")
                return False
        return self.set_default(back_to, tag)

    def delete(self, snapshot):
        snapshot = Snapshot(snapshot)
        to_delete = os.path.join(self.mp, snapshot.name)
        res = True
        if (
                os.path.isdir(to_delete) and
                snapshot.name.startswith(SNAP_PREFIX)):
            
            # correct parent links and combine change info
            parent = snapshot.parent
            children = snapshot.children
            old_history = snapshot.changes
            
            # clean-ups in the global vars of 
            snapshots.list_of.remove(snapshot)
            if parent != None and parent.name in snapshots.children:
                snapshots.children[parent.name].remove(snapshot)
            
            for child in children:
                child.parent = parent
                
                # and do the same again in the global vars of snapshots
                # messy but necessary for delete_older_than to work
                snapshots.parents[child.name] = parent
                if parent != None:
                    snapshots.children[parent.name].append(child) # necessary
                
                newer_history = child.changes
                if old_history == None:
                    combined = newer_history
                elif newer_history == None:
                    combined = None
                else:
                    combined = old_history + newer_history
                child.changes = combined
            
            res = self.commands.btrfs_delete_snapshot(to_delete)
        else:
            print("You have selected an invalid snapshot. Please make sure "
                  "that it exists, and that its name starts with "
                  "\"%s\"" % SNAP_PREFIX)
        return res
    
    def delete_older_than(self, timefmt):
        older_than = self._parse_older_than_to_datetime(timefmt)
        res = True
        list_of = snapshots.get_list(older_than=older_than)
        list_of.sort(key = lambda x: x.date, reverse = True)
        for snap in list_of:
            if len(snap.children) < 2 and snap.tag == "":
                res &= self.delete(snap)
        return res
    
    def prune(self, snapshot):
        snapshot = Snapshot(snapshot)
        res = True
        if len(snapshot.children) != 0:
            raise Exception("Snapshot is not the end of a branch")
        while True:
            parent = snapshot.parent
            res &= self.delete(snapshot)
            snapshot = parent
            if snapshot == None or len(snapshot.children) != 0:
                break
        return res
    
    def tree(self):
        date_parent, history = self._get_status()
        tree = TreeView(history)
        tree.print()
    
    def recent(self, number, snapshot):
        print("%s and its predecessors. Showing %d snapshots.\n" % (snapshot, 
            number))
        snapshot = Snapshot(snapshot)
        for i in range(number):
            self.show(snapshot, compact=True)
            snapshot = snapshot.parent
            if snapshot == None or i == number - 1:
                break
            else:
                print()
        return True
    
    def clean(self, what="apt-cache"):
        snapshot_list = snapshots.get_list()
        for snapshot in snapshot_list:
            path = os.path.join(self.mp, snapshot.name)
            if what == "apt-cache":
                path = os.path.join(path, "var/cache/apt/archives")
                if not os.path.exists(path):
                    continue
                dirlist = os.listdir(path)
                for f in dirlist:
                    fpath = os.path.join(path, f)
                    if f.endswith(".deb") and os.path.lexists(fpath):
                        os.remove(fpath)