commit a44fd5e3620ef28bc91af1b438285301494a9490 Author: bit Date: Fri Aug 22 16:24:02 2025 +0200 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bff065 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# nix-auto-update + +A nix module for automatically updating a flake based system. +It is inspired by `system.autoUpgrade` but tries to implement protection against soft-bricked systems due to failed updates as well as accidential system downgrades. diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..53b2d2d --- /dev/null +++ b/flake.nix @@ -0,0 +1,7 @@ +{ + + outputs = { self }: { + nixosModules.default = import ./module; + }; + +} diff --git a/module/default.nix b/module/default.nix new file mode 100644 index 0000000..008a772 --- /dev/null +++ b/module/default.nix @@ -0,0 +1,218 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.system.autoUpdate; +in +{ + + options = { + + system.autoUpdate = { + + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to periodically update the NixOS + configuration to the latest version. + ''; + }; + + flake = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "github:kloenk/nix"; + description = '' + The Flake URI of the NixOS configuration to build. + ''; + }; + + flags = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "-I" + "stuff=/home/alice/nixos-stuff" + "--option" + "extra-binary-caches" + "http://my-cache.example.org/" + ]; + description = '' + Any additional flags passed to {command}`nixos-rebuild`. + + If you are using flakes and use a local repo you can add + {command}`[ "--update-input" "nixpkgs" "--commit-lock-file" ]` + to update nixpkgs. + ''; + }; + + dates = lib.mkOption { + type = lib.types.str; + default = "04:40"; + example = "daily"; + description = '' + How often or when upgrade occurs. For most desktop and server systems + a sufficient upgrade frequency is once a day. + + The format is described in + {manpage}`systemd.time(7)`. + ''; + }; + + randomizedDelaySec = lib.mkOption { + default = "0"; + type = lib.types.str; + example = "45min"; + description = '' + Add a randomized delay before each automatic upgrade. + The delay will be chosen between zero and this value. + This value must be a time span in the format specified by + {manpage}`systemd.time(7)` + ''; + }; + + fixedRandomDelay = lib.mkOption { + default = false; + type = lib.types.bool; + example = true; + description = '' + Make the randomized delay consistent between runs. + This reduces the jitter between automatic upgrades. + See {option}`randomizedDelaySec` for configuring the randomized delay. + ''; + }; + + rebootWindow = lib.mkOption { + description = '' + Define a lower and upper time value (in HH:MM format) which + constitute a time window during which reboots are allowed after an upgrade. + This option only has an effect when {option}`allowReboot` is enabled. + The default value of `null` means that reboots are allowed at any time. + ''; + default = null; + example = { + lower = "01:00"; + upper = "05:00"; + }; + type = + with lib.types; + nullOr (submodule { + options = { + lower = lib.mkOption { + description = "Lower limit of the reboot window"; + type = lib.types.strMatching "[[:digit:]]{2}:[[:digit:]]{2}"; + example = "01:00"; + }; + + upper = lib.mkOption { + description = "Upper limit of the reboot window"; + type = lib.types.strMatching "[[:digit:]]{2}:[[:digit:]]{2}"; + example = "05:00"; + }; + }; + }); + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + description = "Extra Environment variables to pass to the update script."; + default = {}; + example = { + GIT_SSH = "ssh -i /root/.ssh/custom-deploy-key"; + }; + }; + + persistent = lib.mkOption { + default = true; + type = lib.types.bool; + example = false; + description = '' + Takes a boolean argument. If true, the time when the service + unit was last triggered is stored on disk. When the timer is + activated, the service unit is triggered immediately if it + would have been triggered at least once during the time when + the timer was inactive. Such triggering is nonetheless + subject to the delay imposed by RandomizedDelaySec=. This is + useful to catch up on missed runs of the service when the + system was powered down. + ''; + }; + + }; + + }; + + config = lib.mkIf cfg.enable { + + system.autoUpgrade.flags = ( + [ + "--refresh" + "--flake ${cfg.flake}" + ] + ); + + systemd.services.nixos-update = { + description = "NixOS Upgrade"; + + restartIfChanged = false; + unitConfig.X-StopOnRemoval = false; + + serviceConfig.Type = "oneshot"; + + environment = + config.nix.envVars + // { + inherit (config.environment.sessionVariables) NIX_PATH; + HOME = "/root"; + } + // config.networking.proxy.envVars // cfg.environment; + + path = with pkgs; [ + coreutils + gnutar + xz.bin + gzip + gitMinimal + config.nix.package.out + config.programs.ssh.package + ]; + + script = import ./script.nix { + inherit cfg; + nixos-rebuild = "${config.system.build.nixos-rebuild}/bin/nixos-rebuild"; + date = "${pkgs.coreutils}/bin/date"; + realpath = "${pkgs.coreutils}/bin/realpath"; + stat = "${pkgs.coreutils}/bin/stat"; + cut = "${pkgs.coreutils}/bin/cut"; + head = "${pkgs.coreutils}/bin/head"; + grep = "${pkgs.gnugrep}/bin/grep"; + grub-reboot = "${pkgs.grub2}/bin/grub-reboot"; + shutdown = "${config.systemd.package}/bin/shutdown"; + bootctl = "${config.systemd.package}/bin/bootctl"; + systemd-analyze = "${config.systemd.package}/bin/systemd-analyze"; + upgradeFlag = "--upgrade"; + units = "${pkgs.units}/bin/units"; + jq = "${pkgs.jq}/bin/jq"; + hostname = "${pkgs.nettools}/bin/hostname"; + }; + + startAt = cfg.dates; + + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + }; + + systemd.timers.nixos-update = { + timerConfig = { + RandomizedDelaySec = cfg.randomizedDelaySec; + FixedRandomDelay = cfg.fixedRandomDelay; + Persistent = cfg.persistent; + }; + }; + }; + +} diff --git a/module/script.nix b/module/script.nix new file mode 100644 index 0000000..ccba7eb --- /dev/null +++ b/module/script.nix @@ -0,0 +1,185 @@ +{ + cfg, + nixos-rebuild, + date, + realpath, + stat, + cut, + head, + grep, + grub-reboot, + shutdown, + bootctl, + systemd-analyze, + upgradeFlag, + units, + jq, + hostname, + ... +}: +'' + #!/usr/bin/env bash + set -e + + hostname=$(${hostname}) + new_system=$(nix build --no-link --print-out-paths "${cfg.flake}#nixosConfigurations.''${hostname}.config.system.build.toplevel") + + last_flake_update_time() { + nix flake metadata --refresh --json "$1" | ${jq} .lastModified + } + + updated_since_reboot() { + [[ $(${stat} -c %Y /run/current-system) -gt $(($(${date} +%s) - $(${cut} -d. -f1 /proc/uptime) + $(${units} --compact --one-line "ceil($(${units} --compact --one-line "$(${systemd-analyze} time | head -n 1 | ${grep} -Eo "\S+\s+$")" seconds))"))) ]] + } + + boot_system_path() { + bpath="" + btime=0 + for f in /nix/var/nix/profiles/* + do + if [[ -z $bpath ]] || [[ $btime -lt $(${stat} -c %Y $f) ]] + then + bpath=$f + btime=$(${stat} -c %Y $f) + fi + done + if [[ -d "/nix/var/nix/profiles/system-profiles/" ]] && [[ -n "$(ls -A /nix/var/nix/profiles/system-profiles/)" ]] + then + for f in /nix/var/nix/profiles/system-profiles/* + do + if [[ -z $bpath ]] || [[ $btime -lt $(${stat} -c %Y $f) ]] + then + bpath=$f + btime=$(${stat} -c %Y $f) + fi + done + fi + echo "$bpath" + } + + last_update_time() { + rpath=$(${realpath} /run/current-system) + rtime=$(${stat} -c %Y /run/current-system) + boot_system=$(boot_system_path) + bpath=$(${realpath} "$boot_system") + btime=$(${stat} -c %Y "$boot_system") + if [[ "''${rpath}" == "''${bpath}" ]] + then + if ! updated_since_reboot + then + echo $btime + return + fi + fi + if [[ $rtime -gt $btime ]] + then + echo $rtime + else + echo $btime + fi + } + + latest_system=$(boot_system_path) + + + if [[ "$(${realpath} /run/booted-system)" == "$(${realpath} /run/current-system)" ]] && [[ "$(${realpath} /run/booted-system)" == "$(${realpath} "$latest_system")" ]] + then + if [[ -f /boot/grub/grub.cfg ]] + then + if [[ $(($(${date} +%s) - $(${cut} -d. -f1 /proc/uptime))) -gt $(${stat} -c %Y /boot/grub/grub.cfg) ]] + then + echo "persisting boot system" + "$(${realpath} /run/booted-system)/bin/switch-to-configuration" "boot" + fi + fi + fi + + if [[ "$new_system" == "$(${realpath} /run/current-system)" ]] && [[ "$new_system" == "$(${realpath} "$latest_system")" ]] + then + echo "system is up-to-date" + exit 0 + fi + + system_update_time=$(last_update_time) + flake_update_time=$(last_flake_update_time "${cfg.flake}") + + echo "last system update: $(${date} -d @"$system_update_time")" + echo "last flake update: $(${date} -d @"$flake_update_time")" + + if [[ "$system_update_time" -gt "$flake_update_time" ]] + then + echo "" + echo "System is newer than remote!" + echo "This happens if the system was updated manually or a system update failed." + echo "Skipping system update to prevent potential downgrade!" + exit 0 + fi + + echo "Updating system ..." + + system_info_booted="$(${realpath} /run/booted-system/{initrd,kernel,kernel-modules})" + system_info_built="$(${realpath} "''${new_system}"/{initrd,kernel,kernel-modules})" + + echo $system_info_booted + echo $system_info_built + + current_time="$(${date} +%H:%M)" + + lower=${cfg.rebootWindow.lower} + upper=${cfg.rebootWindow.upper} + + if [[ "''${lower}" < "''${upper}" ]]; then + if [[ "''${current_time}" > "''${lower}" ]] && \ + [[ "''${current_time}" < "''${upper}" ]]; then + do_reboot="true" + else + do_reboot="false" + fi + else + # lower > upper, so we are crossing midnight (e.g. lower=23h, upper=6h) + # we want to reboot if cur > 23h or cur < 6h + if [[ "''${current_time}" < "''${upper}" ]] || \ + [[ "''${current_time}" > "''${lower}" ]]; then + do_reboot="true" + else + do_reboot="false" + fi + fi + + if [[ "$system_info_booted" != "$system_info_built" ]] + then + echo "Updating system requires reboot!" + if [ "''${do_reboot}" != true ] + then + echo "Outside of configured reboot window, skipping update." + exit 0 + fi + + echo nix-env --profile /nix/var/nix/profiles/system --set "$new_system" + if [[ -f /boot/grub/grub.cfg ]] + then + echo "bootloader 'GRUB' detected, configuring test-boot into new system" + "''${latest_system}/bin/switch-to-configuration" "boot" + ${grub-reboot} "NixOS - All configurations>0" + #${grub-reboot} "NixOS - Profile '$PROFILE_NAME'>0" + elif ${bootctl} list 2>&1 | ${grep} -v "No boot loader entries found." > /dev/null + then + echo "bootloader 'systemd-boot' detected, configuring oneshot-boot into new system" + # TODO + exit -1 + else + echo "test-boot not supported. persisting new boot system" + "''${new_system}/bin/switch-to-configuration" "boot" + fi + ${shutdown} -r +1 + else + nix-env --profile /nix/var/nix/profiles/system --set "$new_system" + if ! "''${new_system}/bin/switch-to-configuration" "test" + then + echo "testing new configuration failed!" + echo "system was automatically rolled back to old configuration." + exit 1 + fi + "''${new_system}/bin/switch-to-configuration" "boot" + fi +''