systemd: use sd-switch

This makes the systemd module use the sd-switch application to perform
the unit switch during a generation activation.

Since the closure of sd-switch is relatively lightweight we
unconditionally pull it in as a dependency. We simultaneously remove
the `systemd.user.startServices` option and perform the switch action
automatically.

PR #1388
This commit is contained in:
Robert Helgesson 2020-07-07 00:35:28 +02:00
parent 152769aed9
commit 9c0fe3957b
No known key found for this signature in database
GPG key ID: 36BDAA14C2797E89
5 changed files with 39 additions and 344 deletions

View file

@ -6,10 +6,13 @@ section is therefore not final.
[[sec-release-20.09-highlights]] [[sec-release-20.09-highlights]]
=== Highlights === Highlights
:sd-switch-url: https://gitlab.com/rycee/sd-switch
This release has the following notable changes: This release has the following notable changes:
* Nothing has happened. * The systemd activation is now handled by {sd-switch-url}[sd-switch], a program that stops, starts, reloads, etc. systemd units as necessary to match the new Home Manager configuration.
+
Since sd-switch is relatively lightweight it is always used and the option `systemd.user.startServices` is therefore considered obsolete and can be removed from your configuration.
[[sec-release-20.09-state-version-changes]] [[sec-release-20.09-state-version-changes]]
=== State Version Changes === State Version Changes

View file

@ -1619,6 +1619,21 @@ in
A new module is available: 'services.dropbox'. A new module is available: 'services.dropbox'.
''; '';
} }
{
time = "2020-08-03T22:34:42+00:00";
condition = hostPlatform.isLinux && (with config.systemd.user;
services != {} || sockets != {} || targets != {} || timers != {});
message = ''
The systemd activation is now handled by 'sd-switch', a program that
stops, starts, reloads, etc. systemd units as necessary to match the
new Home Manager configuration.
Since sd-switch is relatively lightweight it is always used and the
option 'systemd.user.startServices' is therefore considered obsolete
and can be removed from your configuration.
'';
}
]; ];
}; };
} }

View file

@ -1,216 +0,0 @@
require 'set'
require 'open3'
@dry_run = ENV['DRY_RUN']
@verbose = ENV['VERBOSE']
UnitsDir = 'home-files/.config/systemd/user'
# 1. Stop all services from the old generation that are not present in the new generation.
# 2. Ensure all services from the new generation that are wanted by active targets are running:
# - Start services that are not already running.
# - Restart services whose unit config files have changed between generations.
# 3. If any services were (re)started, wait 'start_timeout_ms' and report services
# that failed to start. This helps debugging quickly failing services.
#
# Whenever service failures are detected, show the output of
# 'systemd --user status' for the affected services.
#
def setup_services(old_gen_path, new_gen_path, start_timeout_ms_string)
start_timeout_ms = start_timeout_ms_string.to_i
old_units_path = File.join(old_gen_path, UnitsDir) unless old_gen_path.empty?
new_units_path = File.join(new_gen_path, UnitsDir)
old_services = get_services(old_units_path)
new_services = get_services(new_units_path)
exit if old_services.empty? && new_services.empty?
all_services = get_active_targets_units(new_units_path)
maybe_changed = all_services & old_services
changed_services = get_changed_services(old_units_path, new_units_path, maybe_changed)
unchanged_oneshots = get_oneshot_services(maybe_changed - changed_services)
# These services should be running when this script is finished
services_to_run = all_services - unchanged_oneshots
# Only stop active services, otherwise we might get a 'service not loaded' error
# for inactive services that were removed in the current generation.
to_stop = get_active_units(old_services - new_services)
to_restart = changed_services
to_start = get_inactive_units(services_to_run - to_restart)
raise "daemon-reload failed" unless run_cmd('systemctl', '--user', 'daemon-reload')
# Exclude units that shouldn't be (re)started or stopped
no_manual_start, no_manual_stop, no_restart = get_restricted_units(to_stop + to_restart + to_start)
notify_skipped_units(to_restart & no_restart)
to_stop -= no_manual_stop
to_restart -= no_manual_stop + no_manual_start + no_restart
to_start -= no_manual_start
if to_stop.empty? && to_start.empty? && to_restart.empty?
print_service_msg("All services are already running", services_to_run)
else
puts "Setting up services" if @verbose
systemctl_action('stop', to_stop)
systemctl_action('start', to_start)
systemctl_action('restart', to_restart)
started_services = to_start + to_restart
if start_timeout_ms > 0 && !started_services.empty? && !@dry_run
failed = wait_and_get_failed_services(started_services, start_timeout_ms)
if failed.empty?
print_service_msg("All services are running", services_to_run)
else
puts
puts "Error. These services failed to start:", failed
show_failed_services_status(failed)
exit 1
end
end
end
end
def get_services(dir)
services = get_service_files(dir) if dir && Dir.exists?(dir)
Set.new(services)
end
def get_service_files(dir)
Dir.chdir(dir) { Dir['*[^@].{service,socket,timer}'] }
end
def get_changed_services(dir_a, dir_b, services)
services.select do |service|
a = File.join(dir_a, service)
b = File.join(dir_b, service)
(File.size(a) != File.size(b)) || (File.read(a) != File.read(b))
end
end
TargetDirRegexp = /^(.*\.target)\.wants$/
# @return all units wanted by active targets
def get_active_targets_units(units_dir)
return Set.new unless Dir.exists?(units_dir)
targets = Dir.entries(units_dir).map { |entry| entry[TargetDirRegexp, 1] }.compact
active_targets = get_active_units(targets)
active_units = active_targets.map do |target|
get_service_files(File.join(units_dir, "#{target}.wants"))
end.flatten
Set.new(active_units)
end
# @return true on success
def run_cmd(*cmd)
print_cmd cmd
@dry_run || system(*cmd)
end
def systemctl_action(cmd, services)
return if services.empty?
verb = (cmd == 'stop') ? 'Stopping' : "#{cmd.capitalize}ing"
puts "#{verb}: #{services.join(' ')}"
cmd = ['systemctl', '--user', cmd, *services]
if @dry_run
puts cmd.join(' ')
return
end
output, status = Open3.capture2e(*cmd)
print output
# Show status for failed services
unless status.success?
# Due to a bug in systemd, the '--user' argument is not always provided
output.scan(/systemctl (?:--user )?(status .*?)['"]/).flatten.each do |status_cmd|
puts
run_cmd("systemctl --user #{status_cmd}")
end
exit 1
end
end
def systemctl(*cmd)
output, _ = Open3.capture2('systemctl', '--user', *cmd)
output
end
def print_cmd(cmd)
puts [*cmd].join(' ') if @verbose || @dry_run
end
def get_active_units(units)
filter_units(units) { |state| state == 'active' }
end
def get_inactive_units(units)
filter_units(units) { |state| state != 'active' }
end
def get_failed_units(units)
filter_units(units) { |state| state == 'failed' }
end
def filter_units(units)
return [] if units.empty?
states = systemctl('is-active', *units).split
units.select.with_index { |_, i| yield states[i] }
end
def get_oneshot_services(units)
return [] if units.empty?
types = systemctl('show', '-p', 'Type', *units).split
units.select.with_index do |_, i|
types[i] == 'Type=oneshot'
end
end
def get_restricted_units(units)
infos = systemctl('show', '-p', 'RefuseManualStart', '-p', 'RefuseManualStop', *units)
.split("\n\n")
no_manual_start = []
no_manual_stop = []
infos.zip(units).each do |info, unit|
no_start, no_stop = info.split("\n")
no_manual_start << unit if no_start.end_with?('yes')
no_manual_stop << unit if no_stop.end_with?('yes')
end
# Get units that should not be restarted even if a change has been detected.
no_restart_regexp = /^\s*X-RestartIfChanged\s*=\s*false\b/
no_restart = units.select { |unit| systemctl('cat', unit) =~ no_restart_regexp }
[no_manual_start, no_manual_stop, no_restart]
end
def wait_and_get_failed_services(services, start_timeout_ms)
puts "Waiting #{start_timeout_ms} ms for services to fail"
# Force the previous message to always be visible before sleeping
STDOUT.flush
sleep(start_timeout_ms / 1000.0)
get_failed_units(services)
end
def show_failed_services_status(services)
puts
services.each do |service|
run_cmd('systemctl', '--user', 'status', service)
puts
end
end
def print_service_msg(msg, services)
return if services.empty?
if @verbose
puts "#{msg}:", services.to_a
else
puts msg
end
end
def notify_skipped_units(no_restart)
puts "Not restarting: #{no_restart.join(' ')}" unless no_restart.empty?
end
setup_services(*ARGV)

View file

@ -1,114 +0,0 @@
#!/usr/bin/env bash
function isStartable() {
local service="$1"
[[ $(systemctl --user show -p RefuseManualStart "$service") == *=no ]]
}
function isStoppable() {
if [[ -v oldGenPath ]] ; then
local service="$1"
[[ $(systemctl --user show -p RefuseManualStop "$service") == *=no ]]
fi
}
function systemdPostReload() {
local workDir
workDir="$(mktemp -d)"
if [[ -v oldGenPath ]] ; then
local oldUserServicePath="$oldGenPath/home-files/.config/systemd/user"
fi
local newUserServicePath="$newGenPath/home-files/.config/systemd/user"
local oldServiceFiles="$workDir/old-files"
local newServiceFiles="$workDir/new-files"
local servicesDiffFile="$workDir/diff-files"
if [[ ! (-v oldUserServicePath && -d "$oldUserServicePath") \
&& ! -d "$newUserServicePath" ]]; then
return
fi
if [[ ! (-v oldUserServicePath && -d "$oldUserServicePath") ]]; then
touch "$oldServiceFiles"
else
find "$oldUserServicePath" \
-maxdepth 1 -name '*.service' -exec basename '{}' ';' \
| sort \
> "$oldServiceFiles"
fi
if [[ ! -d "$newUserServicePath" ]]; then
touch "$newServiceFiles"
else
find "$newUserServicePath" \
-maxdepth 1 -name '*.service' -exec basename '{}' ';' \
| sort \
> "$newServiceFiles"
fi
diff \
--new-line-format='+%L' \
--old-line-format='-%L' \
--unchanged-line-format=' %L' \
"$oldServiceFiles" "$newServiceFiles" \
> "$servicesDiffFile" || true
local -a maybeRestart=( $(grep '^ ' "$servicesDiffFile" | cut -c2-) )
local -a maybeStop=( $(grep '^-' "$servicesDiffFile" | cut -c2-) )
local -a maybeStart=( $(grep '^+' "$servicesDiffFile" | cut -c2-) )
local -a toRestart=( )
local -a toStop=( )
local -a toStart=( )
for f in "${maybeRestart[@]}" ; do
if isStoppable "$f" \
&& isStartable "$f" \
&& systemctl --quiet --user is-active "$f" \
&& ! cmp --quiet \
"$oldUserServicePath/$f" \
"$newUserServicePath/$f" ; then
toRestart+=("$f")
fi
done
for f in "${maybeStop[@]}" ; do
if isStoppable "$f" ; then
toStop+=("$f")
fi
done
for f in "${maybeStart[@]}" ; do
if isStartable "$f" ; then
toStart+=("$f")
fi
done
rm -r "$workDir"
local sugg=""
if [[ -n "${toRestart[@]}" ]] ; then
sugg="${sugg}systemctl --user restart ${toRestart[@]}\n"
fi
if [[ -n "${toStop[@]}" ]] ; then
sugg="${sugg}systemctl --user stop ${toStop[@]}\n"
fi
if [[ -n "${toStart[@]}" ]] ; then
sugg="${sugg}systemctl --user start ${toStart[@]}\n"
fi
if [[ -n "$sugg" ]] ; then
echo "Suggested commands:"
echo -n -e "$sugg"
fi
}
oldGenPath="$1"
newGenPath="$2"
$DRY_RUN_CMD systemctl --user daemon-reload
systemdPostReload

View file

@ -54,8 +54,6 @@ let
buildServices = style: serviceCfgs: buildServices = style: serviceCfgs:
concatLists (mapAttrsToList (buildService style) serviceCfgs); concatLists (mapAttrsToList (buildService style) serviceCfgs);
servicesStartTimeoutMs = builtins.toString cfg.servicesStartTimeoutMs;
unitType = unitKind: with types; unitType = unitKind: with types;
let let
primitive = either bool (either int str); primitive = either bool (either int str);
@ -152,9 +150,11 @@ in
example = unitExample "Path"; example = unitExample "Path";
}; };
# Keep for a while for backwards compatibility.
startServices = mkOption { startServices = mkOption {
default = false; default = false;
type = types.bool; type = types.bool;
visible = false;
description = '' description = ''
Start all services that are wanted by active targets. Start all services that are wanted by active targets.
Additionally, stop obsolete services from the previous Additionally, stop obsolete services from the previous
@ -164,10 +164,10 @@ in
servicesStartTimeoutMs = mkOption { servicesStartTimeoutMs = mkOption {
default = 0; default = 0;
type = types.int; type = types.ints.unsigned;
description = '' description = ''
How long to wait for started services to fail until their How long to wait for started services to fail until their start is
start is considered successful. considered successful. The value 0 indicates no timeout.
''; '';
}; };
@ -203,6 +203,10 @@ in
"Must use Linux for modules that require systemd: " + names; "Must use Linux for modules that require systemd: " + names;
} }
]; ];
warnings = mkIf cfg.startServices [
"The option 'systemd.user.startServices' is obsolete and can be removed."
];
} }
# If we run under a Linux system we assume that systemd is # If we run under a Linux system we assume that systemd is
@ -230,13 +234,17 @@ in
# set it ourselves in that case. # set it ourselves in that case.
home.activation.reloadSystemD = hm.dag.entryAfter ["linkGeneration"] ( home.activation.reloadSystemD = hm.dag.entryAfter ["linkGeneration"] (
let let
autoReloadCmd = '' timeoutArg =
${pkgs.ruby}/bin/ruby ${./systemd-activate.rb} \ if cfg.servicesStartTimeoutMs != 0 then
"''${oldGenPath=}" "$newGenPath" "${servicesStartTimeoutMs}" "--timeout " + toString cfg.servicesStartTimeoutMs
''; else
"";
legacyReloadCmd = '' sdSwitchCmd = ''
bash ${./systemd-activate.sh} "''${oldGenPath=}" "$newGenPath" ${pkgs.sd-switch}/bin/sd-switch \
''${DRY_RUN:+--dry-run} $VERBOSE_ARG ${timeoutArg} \
''${oldGenPath:+--old-units $oldGenPath/home-files/.config/systemd/user} \
--new-units $newGenPath/home-files/.config/systemd/user
''; '';
ensureRuntimeDir = "XDG_RUNTIME_DIR=\${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"; ensureRuntimeDir = "XDG_RUNTIME_DIR=\${XDG_RUNTIME_DIR:-/run/user/$(id -u)}";
@ -254,8 +262,7 @@ in
fi fi
${ensureRuntimeDir} \ ${ensureRuntimeDir} \
PATH=${dirOf cfg.systemctlPath}:$PATH \ ${sdSwitchCmd}
${if cfg.startServices then autoReloadCmd else legacyReloadCmd}
else else
echo "User systemd daemon not running. Skipping reload." echo "User systemd daemon not running. Skipping reload."
fi fi