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)