sops-install-secrets: call systemctl directly when run as a systemd unit

When useSystemdActivation is enabled, sops-install-secrets.service runs
ordered Before=sysinit-reactivation.target, which switch-to-configuration
restarts *after* it has already consumed /run/nixos/activation-*-list.
Writing to those files from the service therefore does nothing on the
current switch and leaks into the next one.

NixOS 26.05 also deprecates the activation-list mechanism, printing a
warning whenever the files exist, with removal planned for 26.11.

The systemd unit now sets SOPS_RESTART_UNITS_VIA_SYSTEMCTL=1 so the
binary knows to call systemctl directly (try-restart /
try-reload-or-restart, --no-block to avoid deadlocking the sysinit
transaction). INVOCATION_ID cannot be used for this since
switch-to-configuration is almost always invoked from a process tree
rooted in some unit (sshd, getty, systemd-run), so the activation script
inherits it too. The legacy activation-script path keeps writing the
list files for backward compatibility.

Closes #934
This commit is contained in:
r-vdp 2026-03-30 15:49:09 +02:00
parent c591bf6657
commit 81440e3757
No known key found for this signature in database
3 changed files with 88 additions and 28 deletions

View file

@ -475,7 +475,9 @@ in
]; ];
requiredBy = [ "sysinit-reactivation.target" ]; requiredBy = [ "sysinit-reactivation.target" ];
before = [ "sysinit-reactivation.target" ]; before = [ "sysinit-reactivation.target" ];
environment = cfg.environment; environment = cfg.environment // {
SOPS_RESTART_UNITS_VIA_SYSTEMCTL = "1";
};
unitConfig.DefaultDependencies = "no"; unitConfig.DefaultDependencies = "no";
path = cfg.age.plugins; path = cfg.age.plugins;

View file

@ -35,7 +35,9 @@ in
{ {
wantedBy = [ "systemd-sysusers.service" ]; wantedBy = [ "systemd-sysusers.service" ];
before = [ "systemd-sysusers.service" ]; before = [ "systemd-sysusers.service" ];
environment = cfg.environment; environment = cfg.environment // {
SOPS_RESTART_UNITS_VIA_SYSTEMCTL = "1";
};
unitConfig.DefaultDependencies = "no"; unitConfig.DefaultDependencies = "no";
path = cfg.age.plugins; path = cfg.age.plugins;

View file

@ -8,6 +8,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"os/exec"
"os/user" "os/user"
"path" "path"
"path/filepath" "path/filepath"
@ -1020,38 +1021,93 @@ func handleModifications(isDry bool, logcfg loggingConfig, symlinkPath string, s
} }
} }
writeLines := func(list []string, file string) error { // Decide how to propagate restart/reload requests.
if len(list) != 0 { //
if _, err := os.Stat(filepath.Dir(file)); err != nil { // When we run as a systemd service (useSystemdActivation = true), the
if os.IsNotExist(err) { // unit is ordered Before=sysinit-reactivation.target, which
return nil // switch-to-configuration restarts *after* it has already consumed
} // /run/nixos/activation-{restart,reload}-list. Writing to those files
// from here therefore does nothing on the current switch and leaks
// into the next one. On top of that, NixOS 26.05 deprecates the
// activation-list mechanism entirely.
//
// The NixOS module sets SOPS_RESTART_UNITS_VIA_SYSTEMCTL=1 on the
// systemd unit so we know to call systemctl directly. We cannot rely
// on INVOCATION_ID for this: switch-to-configuration is almost always
// invoked from a process tree rooted in some unit (sshd, getty,
// systemd-run, ...), so the activation script inherits it too. For
// the legacy activation-script path, keep writing the list files so
// that switch-to-configuration picks them up as before.
if os.Getenv("SOPS_RESTART_UNITS_VIA_SYSTEMCTL") == "1" {
systemctl := func(verb string, units []string) error {
if len(units) == 0 {
return nil
}
// --no-block: we are ordered before sysinit-reactivation.target
// with DefaultDependencies=no. Blocking on a normal service
// (which has After=sysinit.target) would deadlock the
// transaction.
args := append([]string{"--no-block", verb}, units...)
cmd := exec.Command("systemctl", args...)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("error running 'systemctl %s': %w", strings.Join(args, " "), err)
}
return nil
}
if isDry {
for _, u := range restart {
fmt.Fprintf(os.Stderr, "would restart %s\n", u)
}
for _, u := range reload {
fmt.Fprintf(os.Stderr, "would reload %s\n", u)
}
} else {
// try-restart: only act on units that are already running.
// On first activation the unit starts fresh with the new
// secret anyway, so a no-op is correct.
if err := systemctl("try-restart", restart); err != nil {
return err return err
} }
f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600) if err := systemctl("try-reload-or-restart", reload); err != nil {
if err != nil {
return err return err
} }
defer func() { _ = f.Close() }()
for _, unit := range list {
if _, err = f.WriteString(unit + "\n"); err != nil {
return err
}
}
} }
return nil
}
var dryPrefix string
if isDry {
dryPrefix = "/run/nixos/dry-activation"
} else { } else {
dryPrefix = "/run/nixos/activation" writeLines := func(list []string, file string) error {
} if len(list) != 0 {
if err := writeLines(restart, dryPrefix+"-restart-list"); err != nil { if _, err := os.Stat(filepath.Dir(file)); err != nil {
return err if os.IsNotExist(err) {
} return nil
if err := writeLines(reload, dryPrefix+"-reload-list"); err != nil { }
return err return err
}
f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
for _, unit := range list {
if _, err = f.WriteString(unit + "\n"); err != nil {
return err
}
}
}
return nil
}
var dryPrefix string
if isDry {
dryPrefix = "/run/nixos/dry-activation"
} else {
dryPrefix = "/run/nixos/activation"
}
if err := writeLines(restart, dryPrefix+"-restart-list"); err != nil {
return err
}
if err := writeLines(reload, dryPrefix+"-reload-list"); err != nil {
return err
}
} }
// Do not output changes if not requested // Do not output changes if not requested