Arch Package Transaction Log Fun

Those of you following me on twitter have probably seen me kibitzing about my development environment having been utterly destroyed by an Arch Linux package upgrade in the last ~24h.

It’s been really fun.

Arch Linux unfortunately doesn’t provide a tool for resetting your computer’s package state to whatever it was previously following a bad update. However, the Arch package manager pacman does helpfully write a log file which records everything that it’s done as a good old fashioned append only commit log.

This means that getting my computer back to a sane state was obnoxious, but a pretty trivial hack. Write a program that takes a date stamp, scan the transaction log for everything that happened at and after that date stamp and roll it back.

While my well polished emacs install was unusable, due to the aforementioned awesomeness, I still remember enough vim to be dangerous.

And so, the hack:

#!/usr/bin/env python3

import os
import re
import sys


def main(opts, args):
    """Usage: python rollback.py date

    Parse /var/log/pacman.log, enumerating package transactions since the
    specified date and building a plan for restoring the state of your system to
    what it was at the specified date.

    Assumes:
    - /var/log/pacman.log has not been truncated

    - /var/cache/pacman/pkg has not been flushed and still contains all required
      packages

    - The above paths are Arch default and have not been customized

    - That it is not necessary to remove any "installed" packages

    Note: no attempt is made to inspect the dependency graph of packages to be
    downgraded to detect when a package is already transitively listed for
    downgrading. This can create some annoying errors where eg. systemd will be
    downgraded, meaning libsystemd will also be downgraded, but pacman considers
    explicitly listing the downgrade of libsystemd when it will already be
    transitively downgraded an error.

    """

    date, = args

    print("Attempting to roll back package state to that of {0}...\n"
          .format(date))

    # These patterns can't be collapsed because we want to select different
    # version identifying strings depending on which case we're in. Not ideal,
    # but it works.

    # Ex. [2017-04-01 09:51] [ALPM] upgraded filesystem (2016.12-2 -> 2017.03-2)
    upgraded_pattern = re.compile(
        ".*? upgraded (?P<name>\w+) \((?P<from>[^ ]+) -> (?P<to>[^\)]+)\)")

    # Ex: [2018-02-23 21:18] [ALPM] downgraded emacs (25.3-3 -> 25.3-2)
    downgraded_pattern = re.compile(
        ".*? downgraded (?P<name>\w+) \((?P<to>[^ ]+) -> (?P<from>[^\)]+)\)")

    # Ex: [2017-03-31 07:05] [ALPM] removed gdm (3.22.3-1)
    removed_pattern = re.compile(
        ".*? removed (?P<name>\w+) \((?P<from>[^ ]+)\)")

    checkpoint = {}
    flag = False

    with open("/var/log/pacman.log") as logfile:
        for line in logfile:
            if date in line:
                flag = True
            elif not flag:
                continue

            match = re.match(upgraded_pattern, line)\
                or re.match(downgraded_pattern, line)\
                or re.match(removed_pattern, line)

            if match:
                package = match.group("name")
                from_rev = match.group("from")
                if package not in checkpoint:
                    checkpoint[package] = from_rev
                continue

    print("Checkpoint state:")
    for k in checkpoint.keys():
        print("{0} -> {1}".format(k, checkpoint[k]))

    pkgcache = "/var/cache/pacman/pkg"
    pkgs = os.listdir(pkgcache)
    pkgnames = ["{0}-{1}".format(k, v) for k, v in checkpoint.items()]

    selected_pkgs = [os.path.join(pkgcache, p)
                     for n in pkgnames
                     for p in pkgs
                     if n in p]

    print("\n\nSuggested incantation:\nsudo pacman -U {}"
          .format("\\\n  ".join(selected_pkgs)))


if __name__ == "__main__":
    main(None, sys.argv[1:])

Over and out from my recovered emacs setup 😊

^d