Commit 9031382

HPCesia <me@hpcesia.com>
2026-08-03 17:15:13
disk: init with disko and preservation
Assisted-by: opencode:deepseek-v4-flash
1 parent cbceccd
modules/core/openssh.nix
@@ -2,7 +2,21 @@
   den,
   lib,
   ...
-}: {
+}: let
+  hostKeys = [
+    {
+      path = "/etc/ssh/ssh_host_ed25519_key";
+      type = "ed25519";
+    }
+    {
+      path = "/etc/ssh/ssh_host_rsa_key";
+      type = "rsa";
+      bit = 4096;
+    }
+  ];
+
+  hostKeyFiles = builtins.concatMap (key: [key.path] ++ ["${key.path}.pub"]) hostKeys;
+in {
   den.aspects.core.includes = [den.aspects.core.openssh];
 
   den.aspects.core.openssh = {
@@ -21,22 +35,33 @@
       };
     };
 
+    persist = {
+      files =
+        map (file: {
+          inherit file;
+          mode = "06${
+            if lib.strings.hasSuffix ".pub" file
+            then "44"
+            else "00"
+          }";
+        })
+        hostKeyFiles;
+    };
+    persistHome = {
+      directories = [
+        {
+          directory = ".ssh";
+          mode = "0700";
+        }
+      ];
+    };
+
     nixos = {host, ...}: {
       services.openssh = {
         enable = host.settings.core.openssh.server.enable;
         ports = [host.settings.core.openssh.server.port];
         generateHostKeys = true;
-        hostKeys = [
-          {
-            path = "/etc/ssh/ssh_host_ed25519_key";
-            type = "ed25519";
-          }
-          {
-            path = "/etc/ssh/ssh_host_rsa_key";
-            type = "rsa";
-            bit = 4096;
-          }
-        ];
+        inherit hostKeys;
         settings = {
           PermitRootLogin = "prohibit-password";
           PasswordAuthentication = false;
@@ -55,8 +80,10 @@
       environment.enableAllTerminfo = host.settings.core.openssh.server.enable;
     };
 
-    user = {user, ...}: {
-      openssh.authorizedKeys.keys = user.identity.sshKeys;
+    provides.to-users = {
+      user = {user, ...}: {
+        openssh.authorizedKeys.keys = user.identity.sshKeys;
+      };
     };
   };
 }
modules/disk/preservation/btrfs.nix
@@ -0,0 +1,94 @@
+{
+  den,
+  lib,
+  ...
+}: {
+  den.aspects.disk.preservation.btrfs = {
+    settings.host = {
+      partId = lib.mkOption {
+        type = lib.types.str;
+        default = "";
+        description = ''
+          Disk part id (e.g., "/dev/disk/by-partuuid/...").
+          If not set, auto-detect when aspects.disk.disko is included.
+        '';
+      };
+      preserveTime = lib.mkOption {
+        type = lib.types.ints.positive;
+        default = 30;
+        description = ''
+          Time old root/home subvolumes preserved.
+        '';
+      };
+    };
+
+    nixos = {host, ...}: let
+      cfg = host.settings.disk.preservation.btrfs;
+
+      partDevice =
+        if cfg.partId != ""
+        then
+          if lib.hasPrefix "/dev/" cfg.partId
+          then cfg.partId
+          else "/dev/disk/by-partuuid/" + cfg.partId
+        else if (host.hasAspect den.aspects.disk.disko)
+        then "/dev/disk/by-partlabel/disk-main-nixos"
+        else abort "Host not included disk.disko aspect, Please set settings.disk.preservation.btrfs.partId.";
+
+      partDeviceUnit =
+        lib.removePrefix "-" (
+          lib.replaceStrings ["-" "/"] ["\\x2d" "-"] partDevice
+        )
+        + ".device";
+
+      script = subvol: ''
+        mkdir -p /mnt
+
+        mount ${partDevice} /mnt
+
+        mkdir -p /mnt/${subvol}_old
+        timestamp=$(date "+%Y-%m-%-d_%H:%M:%S")
+        rm -rf "/mnt/${subvol}_old/$timestamp"
+        mv /mnt/${subvol} /mnt/${subvol}_old/$timestamp
+
+        delete_subvolume_recursively() {
+            IFS=$'\n'
+            for i in $(btrfs subvolume list -o "$1" | cut -f 9- -d ' '); do
+                delete_subvolume_recursively "/mnt/$i"
+            done
+            btrfs subvolume delete "$1"
+        }
+
+        for i in $(find /mnt/${subvol}_old -maxdepth 1 -mtime +${toString cfg.preserveTime}); do
+            delete_subvolume_recursively "$i"
+        done
+
+        btrfs subvolume create /mnt/${subvol}
+        umount /mnt
+      '';
+    in {
+      boot.initrd.systemd.services = lib.mkIf (host.hasAspect den.aspects.disk.btrfs) {
+        rollback-btrfs-root = {
+          description = "Rollback and clean old btrfs root subvolumes";
+          wantedBy = ["initrd.target"];
+          before = ["sysroot.mount"];
+          after = [partDeviceUnit];
+          requires = [partDeviceUnit];
+          unitConfig.DefaultDependencies = false;
+          serviceConfig.Type = "oneshot";
+          script = script "root";
+        };
+        rollback-btrfs-home = {
+          description = "Rollback and clean old btrfs home subvolumes";
+          wantedBy = ["initrd.target"];
+          before = ["home.mount"];
+          after = [partDeviceUnit "rollback-btrfs-root.service"];
+          requires = [partDeviceUnit];
+          unitConfig.DefaultDependencies = false;
+          serviceConfig.Type = "oneshot";
+          script = script "home";
+        };
+      };
+    };
+  };
+}
modules/disk/preservation/default.nix
@@ -0,0 +1,94 @@
+{
+  den,
+  lib,
+  ...
+}: let
+  # A single preserved file or directory entry, shaped like
+  # `preservation.preserveAt."/…".files` / `.directories` entries.
+  # Declared fields are checked, unknown fields pass through.
+  mkEntryType = kind:
+    lib.types.submodule {
+      freeformType = lib.types.attrsOf lib.types.anything;
+      options.${kind} = lib.mkOption {
+        type = lib.types.str;
+      };
+    };
+
+  directoryEntry = mkEntryType "directory";
+  fileEntry = mkEntryType "file";
+
+  # Carrier options in the shape of `preservation.preserveAt."/…".users.*`:
+  #
+  #   persistence."<name>" = {
+  #     directories = [ … ];
+  #     files = [ … ];
+  #   };
+  persistenceOptions = lib.types.attrsOf (lib.types.submodule {
+    options = {
+      directories = lib.mkOption {
+        type = lib.types.listOf (lib.types.coercedTo lib.types.str (d: {directory = d;}) directoryEntry);
+        default = [];
+      };
+      files = lib.mkOption {
+        type = lib.types.listOf (lib.types.coercedTo lib.types.str (f: {file = f;}) fileEntry);
+        default = [];
+      };
+    };
+  });
+in {
+  flake-file.inputs.preservation = {
+    url = "github:nix-community/preservation";
+  };
+
+  den.aspects.disk.preservation = let
+    homeFiles = {
+      files = [
+        ".bash_history"
+      ];
+    };
+  in {
+    includes = with den.aspects.disk.preservation; [
+      persist-collector
+
+      btrfs
+    ];
+
+    persist = {
+      directories = [
+        "/etc/NetworkManager/system-connections"
+      ];
+      files = [
+        "/root/.bash_history"
+      ];
+    };
+    cache = {
+      directories = [
+        "/var/lib/nixos"
+        "/srv"
+      ];
+    };
+    persistHome = homeFiles;
+
+    provides.to-users = {
+      # Re-emit persistHome at user scopes: the aspect itself only runs at
+      # host scope, so its own `persistHome` attribute alone would not land.
+      persistHome = homeFiles;
+
+      hjem = {lib, ...}: {
+        options.environment.persistence = lib.mkOption {
+          type = persistenceOptions;
+          default = {};
+          description = ''
+            Preservation-shaped persistence carriers for this hjem user,
+            translated into `preservation.preserveAt` on the host.
+          '';
+        };
+
+        config = {
+          environment.persistence.persist = {};
+          environment.persistence.cache = {};
+        };
+      };
+    };
+  };
+}
modules/disk/preservation/quirks.nix
@@ -0,0 +1,82 @@
+{inputs, ...}: let
+in {
+  den.quirks.persist.description = "Persistent directories/files collected from aspects (host)";
+  den.quirks.cache.description = "Cache directories/files collected from aspects (host)";
+  den.quirks.persistHome.description = "Persistent directories/files collected from aspects (hjem)";
+  den.quirks.cacheHome.description = "Cache directories/files collected from aspects (hjem)";
+
+  den.aspects.disk.preservation.persist-collector = {
+    nixos = {
+      persist,
+      cache,
+      lib,
+      config,
+      ...
+    }: let
+      mergePersist = entries: {
+        directories = lib.unique (lib.concatMap (e: e.directories or []) entries);
+        files = lib.unique (lib.concatMap (e: e.files or []) entries);
+      };
+
+      persistData = mergePersist persist;
+      cacheData = mergePersist cache;
+      persistFiles = persistData.files;
+      cacheFiles = cacheData.files;
+
+      # Merge per-user persistence collected from hjem scopes.
+      relayUsers = prefix: let
+        fromHjem = lib.filterAttrs (_: u: (u.environment.persistence or {}) ? ${prefix}) (config.hjem.users or {});
+        merge = userName: fromHjem.${userName}.environment.persistence.${prefix};
+      in
+        lib.mapAttrs (userName: _: merge userName) fromHjem;
+    in {
+      imports = [inputs.preservation.nixosModules.preservation];
+
+      preservation.enable = true;
+      preservation.preserveAt = {
+        "/persist" = {
+          commonMountOptions = ["x-gvfs-hide"];
+          directories = persistData.directories;
+          files = persistFiles;
+          users = relayUsers "persist";
+        };
+        "/cache" = {
+          commonMountOptions = ["x-gvfs-hide"];
+          directories = cacheData.directories;
+          files = cacheFiles;
+          users = relayUsers "cache";
+        };
+      };
+    };
+
+    provides.to-users = {
+      hjem = {
+        persistHome,
+        cacheHome,
+        lib,
+        config,
+        ...
+      }: let
+        _removeHomePrefix = path: lib.removePrefix config.directory path;
+        removeHomePrefix = path:
+          if lib.typeOf path == "string"
+          then _removeHomePrefix path
+          else path // {directory = _removeHomePrefix path.directory;};
+        removeHomePrefixes = paths: lib.map removeHomePrefix paths;
+        mergePersist = entries: {
+          directories = lib.unique (lib.concatMap (e: removeHomePrefixes (e.directories or [])) entries);
+          files = lib.unique (lib.concatMap (e: removeHomePrefixes (e.files or [])) entries);
+        };
+      in {
+        environment.persistence.persist = {
+          directories = (mergePersist persistHome).directories;
+          files = (mergePersist persistHome).files;
+        };
+        environment.persistence.cache = {
+          directories = (mergePersist cacheHome).directories;
+          files = (mergePersist cacheHome).files;
+        };
+      };
+    };
+  };
+}
modules/disk/btrfs-disko.nix
@@ -0,0 +1,156 @@
+{
+  lib,
+  den,
+  ...
+}: {
+  den.aspects.disk.btrfs-disko = {
+    includes = [
+      den.aspects.disk.disko
+      den.aspects.disk.btrfs
+    ];
+
+    settings.host = {
+      deviceId = lib.mkOption {
+        type = lib.types.str;
+        default = "";
+        description = ''
+          Disk device id (e.g., "ata-..." or "/dev/disk/by-id/...").
+          If not set, auto-detects a single non-USB disk via facter.
+        '';
+      };
+      swapSize = lib.mkOption {
+        type = lib.types.int;
+        default = 0;
+        description = "Size of swap in MiB, 0 disables swap.";
+      };
+      grubPart = lib.mkOption {
+        type = lib.types.nullOr lib.types.bool;
+        default = null;
+        description = "Whether to creat 1M part for GRUB. Set null to auto detect";
+      };
+    };
+
+    nixos = {
+      config,
+      host,
+      ...
+    }: let
+      cfg = host.settings.disk.btrfs-disko;
+
+      diskDevice =
+        if cfg.deviceId != ""
+        then
+          if lib.hasPrefix "/dev/" cfg.deviceId
+          then cfg.deviceId
+          else "/dev/disk/by-id/" + cfg.deviceId
+        else let
+          native-disks = builtins.filter (f: f.driver != "usb-storage") config.hardware.facter.report.hardware.disk;
+          disk-labels =
+            map (
+              disk:
+                builtins.head (
+                  builtins.filter (f: builtins.substring 0 16 f == "/dev/disk/by-id/") disk.unix_device_names
+                )
+            )
+            native-disks;
+        in
+          if (builtins.length disk-labels == 1)
+          then (builtins.head disk-labels)
+          else
+            abort (
+              "Multiple disks found. Please set settings.disk.btrfs-disko.deviceId. Found: "
+              + toString disk-labels
+            );
+
+      defaultBtrfsOpts = [
+        "defaults"
+        "compress=zstd:1"
+        "ssd"
+        "discard=async"
+      ];
+    in {
+      disko.devices = {
+        disk = {
+          main = {
+            device = diskDevice;
+            type = "disk";
+            content = {
+              type = "gpt";
+              partitions = {
+                boot =
+                  lib.mkIf
+                  (
+                    if cfg.grubPart == null
+                    then !config.hardware.facter.detected.uefi.supported
+                    else cfg.grubPart
+                  )
+                  {
+                    label = "GRUB";
+                    size = "1M";
+                    type = "EF02";
+                    priority = 0;
+                  };
+                ESP = {
+                  label = "boot";
+                  name = "ESP";
+                  size = "1G";
+                  type = "EF00";
+                  priority = 1;
+                  content = {
+                    type = "filesystem";
+                    format = "vfat";
+                    mountpoint = "/boot";
+                    mountOptions = ["defaults" "umask=0077"];
+                  };
+                };
+                nixos = {
+                  size = "100%";
+                  priority = 2;
+                  content = {
+                    type = "btrfs";
+                    subvolumes =
+                      {
+                        "/root" = {
+                          mountpoint = "/";
+                          mountOptions = defaultBtrfsOpts ++ ["noatime"];
+                        };
+                        "/home" = {
+                          mountpoint = "/home";
+                          mountOptions = defaultBtrfsOpts ++ ["noatime"];
+                        };
+                        "/nix" = {
+                          mountpoint = "/nix";
+                          mountOptions = defaultBtrfsOpts ++ ["noatime"];
+                        };
+                        "/persist" = lib.mkIf (host.hasAspect den.aspects.disk.preservation) {
+                          mountpoint = "/persist";
+                          mountOptions = defaultBtrfsOpts;
+                        };
+                        "/cache" = lib.mkIf (host.hasAspect den.aspects.disk.preservation) {
+                          mountpoint = "/cache";
+                          mountOptions = defaultBtrfsOpts;
+                        };
+                      }
+                      // lib.optionalAttrs (cfg.swapSize > 0) {
+                        "@swap" = {
+                          mountpoint = "/swap";
+                          swap.swapfile.size = "${toString cfg.swapSize}M";
+                        };
+                      };
+                  };
+                };
+              };
+            };
+          };
+        };
+      };
+
+      fileSystems = {
+        "/nix".neededForBoot = true;
+        "/home".neededForBoot = true;
+        "/persist".neededForBoot = true;
+        "/cache".neededForBoot = true;
+      };
+    };
+  };
+}
modules/disk/btrfs.nix
@@ -0,0 +1,12 @@
+{
+  den.aspects.disk.btrfs = {
+    nixos = {
+      boot.supportedFilesystems.btrfs = true;
+
+      services.btrfs.autoScrub = {
+        enable = true;
+        fileSystems = ["/"];
+      };
+    };
+  };
+}
modules/disk/disko.nix
@@ -0,0 +1,12 @@
+{inputs, ...}: {
+  flake-file.inputs.disko = {
+    url = "github:nix-community/disko";
+    inputs.nixpkgs.follows = "nixpkgs";
+  };
+
+  den.aspects.disk.disko = {
+    nixos = {
+      imports = [inputs.disko.nixosModules.disko];
+    };
+  };
+}
modules/hosts/cyrene/file-system.nix
@@ -0,0 +1,15 @@
+{den, ...}: {
+  den.hosts.cyrene.settings = {
+    disk.btrfs-disko = {
+      deviceId = "/dev/vda";
+      grubPart = true;
+    };
+  };
+
+  den.aspects.cyrene = {
+    includes = [
+      den.aspects.disk.btrfs-disko
+      den.aspects.disk.preservation
+    ];
+  };
+}
modules/hosts/hyacine/file-system.nix
@@ -0,0 +1,15 @@
+{den, ...}: {
+  den.hosts.hyacine.settings = {
+    disk.btrfs-disko = {
+      deviceId = "/dev/vda";
+      grubPart = true;
+    };
+  };
+
+  den.aspects.hyacine = {
+    includes = [
+      den.aspects.disk.btrfs-disko
+      den.aspects.disk.preservation
+    ];
+  };
+}
modules/hosts/kevin/file-system.nix
@@ -0,0 +1,15 @@
+{den, ...}: {
+  den.hosts.kevin.settings = {
+    disk.btrfs-disko = {
+      deviceId = "/dev/disk/by-id/nvme-Samsung_SSD_990_EVO_Plus_2TB_S7U7NJ0XC02928B";
+      swapSize = 32 * 1024; # 32GiB
+    };
+  };
+
+  den.aspects.kevin = {
+    includes = [
+      den.aspects.disk.btrfs-disko
+      den.aspects.disk.preservation
+    ];
+  };
+}
modules/hosts/mobius/file-system.nix
@@ -0,0 +1,14 @@
+{den, ...}: {
+  den.hosts.mobius.settings = {
+    disk.btrfs-disko = {
+      swapSize = 16 * 1024; # 16GiB
+    };
+  };
+
+  den.aspects.mobius = {
+    includes = [
+      den.aspects.disk.btrfs-disko
+      den.aspects.disk.preservation
+    ];
+  };
+}
modules/hosts/tribios/file-system.nix
@@ -0,0 +1,62 @@
+{den, ...}: {
+  den.hosts.tribios = {
+    settings.disk.preservation.btrfs.partId = "/dev/disk/by-uuid/9886235f-5a74-41e8-919b-9e4096a7cb9e";
+  };
+
+  den.aspects.tribios = {
+    # Manually manage file system.
+    # Since tribios has edk2-rk3588 firmware in main storage.
+    # Using disko will break this.
+    includes = [
+      # den.aspects.disk.btrfs-disko
+      den.aspects.disk.btrfs
+      den.aspects.disk.preservation
+    ];
+
+    nixos = let
+      defaultBtrfsOpts = [
+        "defaults"
+        "compress=zstd:1"
+        "ssd"
+        "discard=async"
+      ];
+    in {
+      fileSystems = {
+        "/boot" = {
+          device = "/dev/disk/by-uuid/A0F7-6D8C";
+          fsType = "vfat";
+          options = ["fmask=0022" "dmask=0022"];
+        };
+        "/" = {
+          device = "/dev/disk/by-uuid/9886235f-5a74-41e8-919b-9e4096a7cb9e";
+          fsType = "btrfs";
+          options = ["subvol=/root"] ++ defaultBtrfsOpts;
+        };
+        "/nix" = {
+          device = "/dev/disk/by-uuid/9886235f-5a74-41e8-919b-9e4096a7cb9e";
+          fsType = "btrfs";
+          options = ["subvol=/nix" "noatime"] ++ defaultBtrfsOpts;
+          neededForBoot = true;
+        };
+        "/home" = {
+          device = "/dev/disk/by-uuid/9886235f-5a74-41e8-919b-9e4096a7cb9e";
+          fsType = "btrfs";
+          options = ["subvol=/home"] ++ defaultBtrfsOpts;
+          neededForBoot = true;
+        };
+        "/persist" = {
+          device = "/dev/disk/by-uuid/9886235f-5a74-41e8-919b-9e4096a7cb9e";
+          fsType = "btrfs";
+          options = ["subvol=/persist"] ++ defaultBtrfsOpts;
+          neededForBoot = true;
+        };
+        "/cache" = {
+          device = "/dev/disk/by-uuid/9886235f-5a74-41e8-919b-9e4096a7cb9e";
+          fsType = "btrfs";
+          options = ["subvol=/cache"] ++ defaultBtrfsOpts;
+          neededForBoot = true;
+        };
+      };
+    };
+  };
+}
modules/secret/default.nix
@@ -64,6 +64,7 @@
     } @ args: let
       cfg = host.settings.secret;
       dummyPubkey = "age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq";
+      persistPrefix = lib.optionalString (host.hasAspect den.aspects.disk.preservation) "/persist";
     in {
       imports = [
         (import (inputs.vaultix.outPath + "/module") (args // {inherit self;}))
@@ -79,7 +80,7 @@
             then cfg.pubKey
             else dummyPubkey;
 
-          hostKeys = config.services.openssh.hostKeys;
+          hostKeys = map (key: key // {path = persistPrefix + key.path;}) config.services.openssh.hostKeys;
         };
       };
 
flake.lock
@@ -74,6 +74,26 @@
         "type": "github"
       }
     },
+    "disko": {
+      "inputs": {
+        "nixpkgs": [
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1781152676,
+        "narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=",
+        "owner": "nix-community",
+        "repo": "disko",
+        "rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-community",
+        "repo": "disko",
+        "type": "github"
+      }
+    },
     "flake-compat": {
       "locked": {
         "lastModified": 1777699697,
@@ -281,11 +301,27 @@
         "type": "github"
       }
     },
+    "preservation": {
+      "locked": {
+        "lastModified": 1757436102,
+        "narHash": "sha256-mMI9IanU+Xw+pVogD2oT0I2kTmvz2Un/Apc5+CwUpEY=",
+        "owner": "nix-community",
+        "repo": "preservation",
+        "rev": "93416f4614ad2dfed5b0dcf12f27e57d27a5ab11",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-community",
+        "repo": "preservation",
+        "type": "github"
+      }
+    },
     "root": {
       "inputs": {
         "den": "den",
         "deploy-rs": "deploy-rs",
         "devshell": "devshell",
+        "disko": "disko",
         "flake-compat": "flake-compat",
         "flake-file": "flake-file",
         "flake-parts": "flake-parts",
@@ -295,6 +331,7 @@
         "nixpkgs": "nixpkgs",
         "nur": "nur",
         "nur-hpcesia": "nur-hpcesia",
+        "preservation": "preservation",
         "vaultix": "vaultix"
       }
     },
flake.nix
@@ -18,6 +18,10 @@
       url = "github:numtide/devshell";
       inputs.nixpkgs.follows = "nixpkgs";
     };
+    disko = {
+      url = "github:nix-community/disko";
+      inputs.nixpkgs.follows = "nixpkgs";
+    };
     flake-compat.url = "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz";
     flake-file.url = "github:denful/flake-file";
     flake-parts = {
@@ -45,6 +49,7 @@
       url = "https://codeberg.org/HPCesia/nur-packages/archive/main.tar.gz";
       inputs.nixpkgs.follows = "nixpkgs";
     };
+    preservation.url = "github:nix-community/preservation";
     vaultix = {
       url = "github:HPCesia/vaultix";
       inputs = {