From 9516c35c7f3bd0e500b7a84c60bd22b06b54ea57 Mon Sep 17 00:00:00 2001 From: Madeleine Sydney Date: Wed, 1 Jan 2025 15:45:10 -0700 Subject: [PATCH] feat: Impermanence (system) I had to disable initrd's systemd stuff. I just /couldn't/ get a rollback service working; believe me, not for a lack of effort! I've been working on this on-and-off for a month or two now. --- README.org | 18 +- hosts/nixos-testbed/configuration.nix | 44 +---- hosts/nixos-testbed/disko-config.nix | 3 + modules/nixos/erase-home-darlings.clj | 167 ------------------ modules/nixos/impermanence.nix | 156 +++------------- modules/nixos/impermanence/erase-darlings.clj | 43 ----- modules/nixos/impermanence/rollback.nix | 59 +++++++ 7 files changed, 109 insertions(+), 381 deletions(-) delete mode 100755 modules/nixos/erase-home-darlings.clj delete mode 100755 modules/nixos/impermanence/erase-darlings.clj create mode 100644 modules/nixos/impermanence/rollback.nix diff --git a/README.org b/README.org index e20f446..149bf4c 100755 --- a/README.org +++ b/README.org @@ -11,10 +11,6 @@ A second try at NixOS, now that I have a better idea of what I'm doing. The effo * Inbox -** Refactor: single file per feature - -Modules, but lightweight. - * Principles ** User configuration @@ -75,7 +71,17 @@ Where =default.nix= returns an attrset of form ** Impermanence and persistence -- Persistent files to be linked into ~/~ go under ~/persist/root~ +I use impermanence to wipe most of my filesystem on boot. + +*** Boot process + +What follows is an overview of [[file:modules/nixos/impermanence/rollback.nix][modules/nixos/impermanence/rollback.nix]]. + +On boot, ... + +- The existing subvolume root filesystem will be moved to a 'death row' directory, where it will live for about three days before deletion. Precisely, =«btrfs-filesystem»/«root-subvolume»= is moved to =«btrfs-filesystem»/old-roots/«timestamp»=. The brief grace period allows for easy recovery in the (very common) case where files are unintentionally deleted due to the user's silly human negligence. +- A new, blank subvolume is created in place of the previous. Precisely, the subvolume =«btrfs-filesystem»/«root-subvolume»= is created. +- Any subvolumes under =«btrfs-filesystem»/old-roots= older than three days are deleted. * Tasks @@ -301,6 +307,8 @@ https://discourse.nixos.org/t/what-to-do-with-a-full-boot-partition/2049 - If neither are available, use ~nix run nixpkgs#nvim~ - If this fails, try ~nano~. +- Support ~--pager~ + *** TODO =rage-edit= *** TODO ~forget-host HOST~ diff --git a/hosts/nixos-testbed/configuration.nix b/hosts/nixos-testbed/configuration.nix index 21231f1..92b2a8e 100755 --- a/hosts/nixos-testbed/configuration.nix +++ b/hosts/nixos-testbed/configuration.nix @@ -17,7 +17,7 @@ ]; impermanence = { - enable = false; + enable = true; directories = [ # "Warning: Neither /var/lib/nixos nor any of its parents are persisted. # This means all users/groups without specified uids/gids will have them @@ -28,25 +28,15 @@ # just think it's annoying to edit ~/.ssh/known_hosts all the time. "/etc/ssh" ]; - device = "placeholderrrr"; - archiveLimit = 3; + rollback = { + enable = true; + device = "/dev/sda2"; + subvolume = "rootfs"; + }; }; - - # niri.enable = true; }; boot = { - initrd = { - enable = true; - systemd.enable = true; - - # systemd.initrdBin = with pkgs; [ - # zfs - # coreutils - # babashka - # ]; - }; - loader = { systemd-boot.enable = true; efi.canTouchEfiVariables = false; @@ -60,28 +50,6 @@ mountPoint = "/persist/dots"; }; - # HACK: This is here temporarily so I can use `/persist` without - # Impermanence. This can be removed once impermanence is enabled. - # Create a group called `cfg.persistGroupName` - users.groups.persist = { - name = "persist"; - }; - - # HACK: This is here temporarily so I can use `/persist` without - # Impermanence. This can be removed once impermanence is enabled. - systemd.tmpfiles.settings = { - "10-persist" = { - # Permit members of `cfg.persistGroupName` to read, write, and execute - # /persist. - "/persist" = { - z = { - group = "persist"; - mode = "2775"; - }; - }; - }; - }; - # networking.hostName = "nixos-testbed"; networking.hostId = "238e9b1e"; # head -c 8 /etc/machine-id diff --git a/hosts/nixos-testbed/disko-config.nix b/hosts/nixos-testbed/disko-config.nix index 47d0cda..9e94aac 100755 --- a/hosts/nixos-testbed/disko-config.nix +++ b/hosts/nixos-testbed/disko-config.nix @@ -6,6 +6,9 @@ boot.initrd.supportedFilesystems.btrfs = true; boot.supportedFilesystems.btrfs = true; + # From Impermanence's README: "Important note: Make sure your persistent + # volumes are marked with neededForBoot, otherwise you will run into + # problems." fileSystems."/persist".neededForBoot = true; disko.devices = { diff --git a/modules/nixos/erase-home-darlings.clj b/modules/nixos/erase-home-darlings.clj deleted file mode 100755 index 8da6f17..0000000 --- a/modules/nixos/erase-home-darlings.clj +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env bb - -;; TODO: rewrite with fewer assumptions about the filesystem structure. Perhaps -;; we can achieve near-atomicity by doing to bulk of the work with a function -;; `erase-home-darlings : FileSystem -> Maybe FileSystem`, which will not do any -;; IO. - -;; TODO: option to either move OR copy - -(require '[clojure.core.match :refer [match]] - '[babashka.cli :as cli] - '[clojure.pprint :as pp] - '[clojure.tools.logging :as l] - '[babashka.process :refer [shell check process] :as p]) - -(defn get-files [{:keys [rollback-to dataset]}] - (let [snapshot (str dataset "@" rollback-to) - diff (:out (shell {:out :string} - "zfs diff -HF" - snapshot - dataset))] - ;; See zfs-diff(8) to understand what we're parsing here. - (->> diff - str/split-lines - (map #(str/split % #"\s")) - (filter #(and - ;; We only care to preserve /new/ content. - (contains? #{"+" "M"} (first %)) - ;; We only bother with plain old files. No directories, - ;; symlinks, etc. - (= (second %) "F"))) - (map #(nth % 2))))) - -(defn move-out-of-my-way [file] - ;; No TCO. }:< - (let [maximum-attempts 50] - (loop [n 0] - (let [file' (format "%s-%d" file n)] - (if (fs/exists? file') - (do (printf (str "Failed to rename `%s' to `%s', " - "because the latter already exists.\n") - file file') - (if (< n maximum-attempts) - (recur (inc n)) - (do (printf (str "We've tried to rename `%s' %d " - "without success. This should " - "never happen! Abort!") - file (inc n)) - (System/exit 127)))) - (do (fs/move file file') - file')))))) - -(defn archive-files [{:keys [archive-to]} files] - (let [new-archive (fs/path archive-to "new-archive")] - (when (fs/exists? new-archive) - (println "Warning: `new-archive' already exists... we'll rename it for you?") - (move-out-of-my-way new-archive)) - (doseq [file files] - (let [destination (fs/path - new-archive - (fs/relativize "/home" (fs/parent file)))] - (fs/create-dirs destination) - (fs/move file destination))))) - -;; FIXME: This code could be a lot easier on the eyes. }:\ -(defn cycle-archives [{:keys [archive-to archive-limit]}] - "Delete the oldest archive path, and increment each previous path by one. -More precisely, - - Delete the archive path labeled `n` (the oldest allowed). - - For each remaining path labeled 'i', relabel to 'i + 1'. - - Lastly, we delete the path labeled `new-archive`, if it exists." - (let [gp (memoize #(fs/path archive-to (str %)))] - (when (fs/exists? (gp archive-limit)) - (fs/delete-tree (gp archive-limit))) - (doseq [i (range (dec archive-limit) 0 -1)] - (when (fs/exists? (gp i)) - (fs/move (gp i) (gp (inc i))))) - (when (fs/exists? (gp "new-archive")) - (fs/move (gp "new-archive") (gp 1))))) - -(defn do-rollback [{:keys [dataset rollback-to]}] - (let [proc (shell "zfs" "rollback" "-r" (str dataset "@" rollback-to))] - (if (= (:exit proc) 0) - (println (str "Successfully rolled back /home. " - "Enjoy that fresh filesystem smell! }:D")) - (println "Something went wrong rolling back /home... D:{")))) - -(def zfs-dataset? - ;; We memoise an anonymous procedure because we want the command to be run on - ;; the first invocation of `zfs-dataset?`, *not* when this file's top-level is - ;; first evaluated. Naïvely, it's reasonable to think we should instead do - ;; something like - ;; (def zfs-dataset? - ;; (let [datasets ] - ;; (fn [x] (contains? datasets x)))) - ;; but that would call zfs (and potentially throw) far too early to make sense. - (let [get-datasets (memoize - (fn [] - (->> (:out (shell {:out :string} "ls" "-la")) - str/split-lines - (map #(first (str/split % #"\s"))) - set)))] - (fn [x] - (contains? (get-datasets) x)))) - -(def cli-spec - {:spec - {:archive-limit {:coerce :int - :alias :n - :validate #(pos? %) - :default 3 - :desc "Number of archives to save at a time."} - :dataset {:coerce :string - ;; :validate zfs-dataset? - :require true - :desc "Dataset to be archived and rolled back."} - :rollback-to {:coerce :string - ;; TODO: Validate snapshot. - :require true - :desc "Snapshot to rollback to."} - :archive-to {:coerce :string - :default "/persist/previous/home" - :desc "The path under which archives will be stored."} - :error-fn - (fn [{:keys [spec type cause msg option] :as data}] - (when (= :org.babashka/cli type) - (case cause - :require - (println - (format "Missing required argument: %s\n" option)))) - (System/exit 1))}}) - -(defmacro with-echoed-shell-commands [& body] - (let [print-cmd #(println (str "+ " (str/join (:cmd %))))] - `(binding [p/*defaults* {:pre-start-fn ~print-cmd}] - ~@body))) - -(defmacro with-echoed-shell-commands [& body] - `(do ~@body)) - -(defn -main [opts] - (pp/pprint opts) - (with-echoed-shell-commands - (shell "mount" (:dataset opts) "/home") - (let [files (get-files opts)] - (archive-files opts files) - (cycle-archives opts) - (do-rollback opts)))) - -#_ -(def fs-ops - {:zfs {:get-files zfs-get-files - :rollback zfs-rollback}}) - -#_ -(defn -main [opts] - (let [test-bin (fn [x] - (printf "%s: %s\n" - x (map str (fs/which-all x))))] - (test-bin "mount") - (test-bin "findmnt") - (test-bin "zfs")) - - (shell "mount") - (shell "ls -la /home")) - -(-main (cli/parse-opts *command-line-args* cli-spec)) diff --git a/modules/nixos/impermanence.nix b/modules/nixos/impermanence.nix index 72cb33f..446a994 100755 --- a/modules/nixos/impermanence.nix +++ b/modules/nixos/impermanence.nix @@ -1,4 +1,3 @@ -# Requires boot.initrd.enable = true and boot.initrd.systemd.enable = true! { config, lib, pkgs, ... }: with lib; @@ -6,41 +5,38 @@ with lib; let cfg = config.sydnix.impermanence; in { + imports = [ + ./impermanence/rollback.nix + ]; + options = { sydnix.impermanence = { - enable = mkOption { - description = "Enable Impermanence"; - type = types.bool; - default = false; - }; + enable = mkEnableOption "Impermanence"; directories = mkOption { - description = ""; type = with types; listOf anything; default = []; }; + files = mkOption { + type = with types; listOf anything; + default = []; + }; + + persistDirectory = mkOption { + default = "/persist"; + type = types.str; + description = '' + The directory in which persistent files live. + ''; + }; + persistGroupName = mkOption { default = "persist"; type = types.str; - }; - - files = mkOption { - description = ""; - - type = with types; listOf anything; - default = []; - }; - - device = mkOption { - description = "Device to be wiped"; - type = types.str; - }; - - archiveLimit = mkOption { - type = types.ints.positive; - description = "The number of previous roots to preserve."; - default = 3; + description = '' + Name of the group whose members have access to the persist directory. + ''; }; }; }; @@ -51,115 +47,19 @@ in { name = cfg.persistGroupName; }; - systemd.tmpfiles.settings = { - "10-persist" = { - # Permit members of `cfg.persistGroupName` to read, write, and execute - # /persist. - "/persist" = { - z = { - group = cfg.persistGroupName; - mode = "2775"; - }; - }; + # Permit members of `cfg.persistGroupName` to read, write, and execute + # /persist. + systemd.tmpfiles.settings."10-persist".${cfg.persistDirectory} = { + z = { + group = cfg.persistGroupName; + mode = "2775"; }; }; # TODO: Move this somewhere else. programs.fuse.userAllowOther = true; - # sudo mount -o ro -t virtiofs mount-dots /persist/dots - boot.initrd.systemd = { - # `pkgs.babashka` calls `pkgs.babashka-unwrapped`, and will explode if it - # cannot find it. - storePaths = [ pkgs.babashka-unwrapped ]; - - initrdBin = with pkgs; [ - babashka - util-linux - ]; - }; - - boot.initrd.systemd.services.erase-darlings = - let service = { - description = "Rollback filesystem to a blank state on boot"; - wantedBy = [ - "initrd.target" - ]; - before = [ - "sysroot.mount" - ]; - path = with pkgs; [ util-linux babashka ]; - unitConfig.DefaultDependencies = "no"; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - script = - let bb-script = "/sysroot" + ./impermanence/erase-darlings.clj; - in ''${pkgs.babashka}/bin/bb "${bb-script}" -n "${toString cfg.archiveLimit}" --device "${cfg.device}"''; - }; - in if config.boot.initrd.systemd.enable - then service - else throw "sydnix.impermanence currently requires config.boot.initrd.systemd.enable'!"; - - # TODO: Remove this. - # boot.initrd.systemd.services.erase-darlings-old = - # let service = { - # description = "Rollback filesystem to a blank state on boot"; - # wantedBy = [ - # "initrd.target" - # ]; - # after = [ - # # "zfs-import.service" - # "zfs-import-rpool.service" - # ]; - # before = [ - # "sysroot.mount" - # ]; - # path = [ pkgs.zfs ]; - # unitConfig.DefaultDependencies = "no"; - # serviceConfig = { - # Type = "oneshot"; - # RemainAfterExit = true; - # }; - # script = /* bash */ '' - # zfs rollback -r rpool/local/root@blank \ - # && echo ">> >> rollback complete << <<" - # ''; - # }; - # in if config.boot.initrd.systemd.enable - # then service - # else throw "sydnix.impermanence currently requires config.boot.initrd.systemd.enable'!"; - - # systemd.services = - # let erase-home-darlings = { - # description = "Rollback home to a blank state on boot"; - # wantedBy = [ - # "local-fs-pre.target" - # "zfs-mount.service" - # ]; - # before = [ - # "local-fs.target" - # "local-fs-pre.target" - # "zfs-mount.service" - # ]; - # path = [ pkgs.zfs pkgs.babashka pkgs.util-linux ]; - # unitConfig.DefaultDependencies = "no"; - # serviceConfig = { - # Type = "oneshot"; - # RemainAfterExit = true; - # ExecStart = - # let script = ./erase-home-darlings.clj; - # in ''${pkgs.babashka}/bin/bb "${script}" -n "${toString cfg.archiveLimit}" --dataset "${cfg.dataset}" --rollback-to "${cfg.rollbackTo}"''; - # }; - # stopIfChanged = false; - # restartIfChanged = false; - # }; - # in { - # # inherit erase-home-darlings; - # }; - - environment.persistence."/persist/root" = { + environment.persistence."${cfg.persistDirectory}/root" = { directories = cfg.directories; files = cfg.files; }; diff --git a/modules/nixos/impermanence/erase-darlings.clj b/modules/nixos/impermanence/erase-darlings.clj deleted file mode 100755 index 3aad43a..0000000 --- a/modules/nixos/impermanence/erase-darlings.clj +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bb - -(require '[clojure.core.match :refer [match]] - '[babashka.cli :as cli] - '[clojure.pprint :as pp] - '[clojure.tools.logging :as l] - '[babashka.process :refer [shell check process] :as p]) - -(def cli-spec - {:spec - {:archive-limit {:coerce :int - :alias :n - :validate #(pos? %) - :default 3 - :desc "Number of archives to save at a time."} - :device {:coerce :string - ;; :validate fs/exists? - :require true - :desc "Dataset to be archived and rolled back."} - :previous-roots - {:coerce :string - :default "/persist/previous" - :desc "The path under which previous roots will be stored."} - :error-fn - (fn [{:keys [spec type cause msg option] :as data}] - (when (= :org.babashka/cli type) - (case cause - :require - (println - (format "Missing required argument: %s\n" option)))) - (System/exit 1))}}) - -(defmacro with-echoed-shell-commands [& body] - (let [print-cmd #(println (str "+ " (str/join (:cmd %))))] - `(binding [p/*defaults* {:pre-start-fn ~print-cmd}] - ~@body))) - -(defn -main [opts] - (pp/pprint opts) - (with-echoed-shell-commands - (shell "mount"))) - -(-main (cli/parse-opts *command-line-args* cli-spec)) diff --git a/modules/nixos/impermanence/rollback.nix b/modules/nixos/impermanence/rollback.nix new file mode 100644 index 0000000..6f16806 --- /dev/null +++ b/modules/nixos/impermanence/rollback.nix @@ -0,0 +1,59 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let cfg = config.impermanence.rollback; +in { + options = { + impermanence.rollback = { + enable = mkEnableOption "rollback of the root filesystem"; + + device = mkOption { + description = "Device to be wiped"; + type = types.str; + }; + + subvolume = mkOption { + description = '' + The subvolume to be rolled back. Usually, this should be where your + root filesystem resides. + ''; + type = types.str; + }; + }; + }; + + config = mkIf cfg.enable { + boot.initrd.postDeviceCommands = '' + # Mount the btrfs filesystem. + mkdir -p /btrfs-tmp + mount -t btrfs "${cfg.device}" /btrfs-tmp + + # If the moribound subvolume exists, send it do 'death row' (old-roots), + # where live for about three days before its eventual deletion. + if [[ -e "/btrfs-tmp/${cfg.subvolume}" ]]; then + mkdir -p /btrfs-tmp/old-roots + timestamp=$(date --date="@$(stat -c %Y "/btrfs-tmp/${cfg.subvolume}")" "+%Y-%m-%-d_%H:%M:%S") + mv "/btrfs-tmp/${cfg.subvolume}" "/btrfs-tmp/old-roots/$timestamp" + fi + + delete_subvolume_recursively() { + IFS=$'\n' + for i in $(btrfs subvolume list -o "$1" | cut -f 9- -d ' '); do + delete_subvolume_recursively "/btrfs-tmp/$i" + done + btrfs subvolume delete "$1" + } + + # Delete previous roots older than three days. + # TODO: I would prefer archiving the last N previous roots, rather than + # time. + for i in $(find /btrfs-tmp/old-roots/ -maxdepth 1 -mtime +3); do + delete_subvolume_recursively "$i" + done + + btrfs subvolume create "/btrfs-tmp/${cfg.subvolume}" + umount /btrfs-tmp + ''; + }; +}