From 223e3c38a13fb45726c7a9d97e2612ae53ab4f98 Mon Sep 17 00:00:00 2001 From: Robert Helgesson Date: Tue, 4 Aug 2020 19:38:14 +0200 Subject: [PATCH] Revert "systemd: use sd-switch" This reverts commit 9c0fe3957bb91128d5d3599e289fe9344a293ece. --- doc/release-notes/rl-2009.adoc | 5 +- modules/misc/news.nix | 15 --- modules/systemd-activate.rb | 216 +++++++++++++++++++++++++++++++++ modules/systemd-activate.sh | 114 +++++++++++++++++ modules/systemd.nix | 33 ++--- 5 files changed, 344 insertions(+), 39 deletions(-) create mode 100644 modules/systemd-activate.rb create mode 100644 modules/systemd-activate.sh diff --git a/doc/release-notes/rl-2009.adoc b/doc/release-notes/rl-2009.adoc index 90d5466004bf..0562e046d8d1 100644 --- a/doc/release-notes/rl-2009.adoc +++ b/doc/release-notes/rl-2009.adoc @@ -6,13 +6,10 @@ section is therefore not final. [[sec-release-20.09-highlights]] === Highlights -:sd-switch-url: https://gitlab.com/rycee/sd-switch This release has the following notable changes: -* 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. +* Nothing has happened. [[sec-release-20.09-state-version-changes]] === State Version Changes diff --git a/modules/misc/news.nix b/modules/misc/news.nix index fae018691222..62f57a44cec6 100644 --- a/modules/misc/news.nix +++ b/modules/misc/news.nix @@ -1619,21 +1619,6 @@ in 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. - ''; - } ]; }; } diff --git a/modules/systemd-activate.rb b/modules/systemd-activate.rb new file mode 100644 index 000000000000..31d06d8fc199 --- /dev/null +++ b/modules/systemd-activate.rb @@ -0,0 +1,216 @@ +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) diff --git a/modules/systemd-activate.sh b/modules/systemd-activate.sh new file mode 100644 index 000000000000..1c464693cfc3 --- /dev/null +++ b/modules/systemd-activate.sh @@ -0,0 +1,114 @@ +#!/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 diff --git a/modules/systemd.nix b/modules/systemd.nix index 5acfabc74697..dcb1a29570de 100644 --- a/modules/systemd.nix +++ b/modules/systemd.nix @@ -54,6 +54,8 @@ let buildServices = style: serviceCfgs: concatLists (mapAttrsToList (buildService style) serviceCfgs); + servicesStartTimeoutMs = builtins.toString cfg.servicesStartTimeoutMs; + unitType = unitKind: with types; let primitive = either bool (either int str); @@ -150,11 +152,9 @@ in example = unitExample "Path"; }; - # Keep for a while for backwards compatibility. startServices = mkOption { default = false; type = types.bool; - visible = false; description = '' Start all services that are wanted by active targets. Additionally, stop obsolete services from the previous @@ -164,10 +164,10 @@ in servicesStartTimeoutMs = mkOption { default = 0; - type = types.ints.unsigned; + type = types.int; description = '' - How long to wait for started services to fail until their start is - considered successful. The value 0 indicates no timeout. + How long to wait for started services to fail until their + start is considered successful. ''; }; @@ -203,10 +203,6 @@ in "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 @@ -234,17 +230,13 @@ in # set it ourselves in that case. home.activation.reloadSystemD = hm.dag.entryAfter ["linkGeneration"] ( let - timeoutArg = - if cfg.servicesStartTimeoutMs != 0 then - "--timeout " + toString cfg.servicesStartTimeoutMs - else - ""; + autoReloadCmd = '' + ${pkgs.ruby}/bin/ruby ${./systemd-activate.rb} \ + "''${oldGenPath=}" "$newGenPath" "${servicesStartTimeoutMs}" + ''; - sdSwitchCmd = '' - ${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 + legacyReloadCmd = '' + bash ${./systemd-activate.sh} "''${oldGenPath=}" "$newGenPath" ''; ensureRuntimeDir = "XDG_RUNTIME_DIR=\${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"; @@ -262,7 +254,8 @@ in fi ${ensureRuntimeDir} \ - ${sdSwitchCmd} + PATH=${dirOf cfg.systemctlPath}:$PATH \ + ${if cfg.startServices then autoReloadCmd else legacyReloadCmd} else echo "User systemd daemon not running. Skipping reload." fi