Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions examples/example.nix
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
config = {
services.nginx.enable = true;

system.autoUpgrade = {
enable = true;
flake = "github:numtide/system-manager";
flags = [
"--nix-option"
"accept-flake-config"
"true"
];
dates = "hourly";
randomizedDelaySec = "10min";
};

environment = {
systemPackages = [
pkgs.ripgrep
Expand Down
198 changes: 198 additions & 0 deletions nix/modules/auto-upgrade.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
{
config,
lib,
pkgs,
system-manager,
...
}:
let
cfg = config.system.autoUpgrade;
in
{
options.system.autoUpgrade = {

enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to periodically upgrade the system-manager configuration.
When enabled, a systemd timer runs
`system-manager switch --flake <uri>` on the configured schedule.
'';
};

flake = lib.mkOption {
type = lib.types.str;
example = "github:numtide/example";
description = ''
The flake URI passed to `system-manager switch --flake`.
'';
};

flags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [
"--ephemeral"
"--sudo"
];
description = ''
Additional flags passed to `system-manager switch`.
'';
};

dates = lib.mkOption {
type = lib.types.str;
default = "04:40";
example = "daily";
description = ''
How often or when the upgrade runs, in
{manpage}`systemd.time(7)` calendar event format.
'';
};

randomizedDelaySec = lib.mkOption {
type = lib.types.str;
default = "0";
example = "45min";
description = ''
Random delay added before each upgrade, as a
{manpage}`systemd.time(7)` time span.
'';
};

fixedRandomDelay = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Make the randomized delay consistent between runs.
'';
};

persistent = lib.mkOption {
type = lib.types.bool;
default = true;
example = false;
description = ''
If true, a missed timer trigger (e.g. system was off) fires
immediately on next boot.
'';
};

# No-op options for NixOS compatibility.
# These allow configs that target both NixOS and system-manager

allowReboot = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
This option has no effect in system-manager (no kernel/initrd
to reboot into). It exists for NixOS configuration compatibility.
'';
};

rebootWindow = lib.mkOption {
type =
with lib.types;
nullOr (submodule {
options = {
lower = lib.mkOption {
type = lib.types.strMatching "[[:digit:]]{2}:[[:digit:]]{2}";
example = "01:00";
};
upper = lib.mkOption {
type = lib.types.strMatching "[[:digit:]]{2}:[[:digit:]]{2}";
example = "05:00";
};
};
});
default = null;
description = ''
This option has no effect in system-manager (no kernel/initrd
to reboot into). It exists for NixOS configuration compatibility.
'';
};

operation = lib.mkOption {
type = lib.types.enum [
"switch"
"boot"
];
default = "switch";
description = ''
This option has no effect in system-manager (only switch is
supported). It exists for NixOS configuration compatibility.
'';
};

channel = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
This option has no effect in system-manager (flake-only).
It exists for NixOS configuration compatibility.
'';
};

upgrade = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
This option has no effect in system-manager.
It exists for NixOS configuration compatibility.
'';
};
};

config = lib.mkIf cfg.enable {
warnings =
lib.optional cfg.allowReboot "system.autoUpgrade.allowReboot has no effect: system-manager does not manage the kernel or initrd"
++
lib.optional (cfg.rebootWindow != null)
"system.autoUpgrade.rebootWindow has no effect: system-manager does not manage the kernel or initrd"
++ lib.optional (
cfg.operation != "switch"
) "system.autoUpgrade.operation has no effect: system-manager only supports switch"
++ lib.optional (
cfg.channel != null
) "system.autoUpgrade.channel has no effect: system-manager is flake-only"
++ lib.optional (!cfg.upgrade) "system.autoUpgrade.upgrade has no effect in system-manager";

system.autoUpgrade.flags = [
"--refresh"
"--flake ${cfg.flake}"
];

systemd.services.system-manager-upgrade = {
description = "System Manager Upgrade";

restartIfChanged = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this property a typo? Seems like it should be serviceConfig.X-RestartIfChanged.

Copy link
Copy Markdown
Contributor

@commiterate commiterate Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, that's a Nixpkgs helper we currently do not have ported to system-manager: https://github.com/NixOS/nixpkgs/blob/2353f2a8a524d93eb4746d817382e50521018a09/nixos/lib/systemd-lib.nix#L795

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

X-RestartIfChanged should be correctly managed by #438
We call systemd-lib.serviceToUnit in

// lib.mapAttrs' (n: v: lib.nameValuePair "${n}.service" (systemd-lib.serviceToUnit v)) cfg.services

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! Right! I forgot we're passing the services through the upstream lib!

unitConfig.X-StopOnRemoval = false;
Comment thread
jfroche marked this conversation as resolved.

serviceConfig.Type = "oneshot";

path = with pkgs; [
coreutils
gitMinimal
];

script = ''
${system-manager}/bin/system-manager switch ${lib.concatStringsSep " " cfg.flags}
'';

startAt = [ cfg.dates ];

after = [ "network-online.target" ];
wants = [ "network-online.target" ];
};

systemd.timers.system-manager-upgrade = {
timerConfig = {
RandomizedDelaySec = cfg.randomizedDelaySec;
FixedRandomDelay = cfg.fixedRandomDelay;
Persistent = cfg.persistent;
};
};
};
}
1 change: 1 addition & 0 deletions nix/modules/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
}:
{
imports = [
./auto-upgrade.nix
./environment.nix
./etc.nix
./systemd.nix
Expand Down
51 changes: 51 additions & 0 deletions testFlake/container-tests/auto-upgrade.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{ forEachDistro, ... }:

forEachDistro "auto-upgrade" {
modules = [
{
system.autoUpgrade = {
enable = true;
flake = "github:example/repo";
dates = "Mon 03:00";
randomizedDelaySec = "45min";
persistent = true;
fixedRandomDelay = true;
};
}
];
testScriptFunction =
{ toplevel, hostPkgs, ... }:
''
start_all()

machine.wait_for_unit("multi-user.target")

activation_logs = machine.activate()
for line in activation_logs.split("\n"):
assert not "ERROR" in line, line
machine.wait_for_unit("system-manager.target")

with subtest("timer unit file exists"):
timer = machine.file("/etc/systemd/system/system-manager-upgrade.timer")
assert timer.exists, "system-manager-upgrade.timer should exist"

with subtest("service unit file exists"):
service = machine.file("/etc/systemd/system/system-manager-upgrade.service")
assert service.exists, "system-manager-upgrade.service should exist"

with subtest("timer is enabled via wantedBy symlink"):
machine.succeed("test -L /etc/systemd/system/system-manager.target.wants/system-manager-upgrade.timer")

with subtest("timer OnCalendar matches configured dates"):
content = machine.succeed("cat /etc/systemd/system/system-manager-upgrade.timer")
assert "OnCalendar=Mon 03:00" in content, \
f"Expected 'OnCalendar=Mon 03:00' in timer, got: {content}"

with subtest("deactivation removes the units"):
machine.succeed("${toplevel}/bin/deactivate")
timer = machine.file("/etc/systemd/system/system-manager-upgrade.timer")
assert not timer.exists, "system-manager-upgrade.timer should be removed after deactivation"
service = machine.file("/etc/systemd/system/system-manager-upgrade.service")
assert not service.exists, "system-manager-upgrade.service should be removed after deactivation"
'';
}