diff --git a/doc/release-notes/rl-2009.adoc b/doc/release-notes/rl-2009.adoc index 0562e046..90d54660 100644 --- a/doc/release-notes/rl-2009.adoc +++ b/doc/release-notes/rl-2009.adoc @@ -6,10 +6,13 @@ 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: -* 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]] === State Version Changes diff --git a/modules/misc/news.nix b/modules/misc/news.nix index 62f57a44..fae01869 100644 --- a/modules/misc/news.nix +++ b/modules/misc/news.nix @@ -1619,6 +1619,21 @@ 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 deleted file mode 100644 index 31d06d8f..00000000 --- a/modules/systemd-activate.rb +++ /dev/null @@ -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) diff --git a/modules/systemd-activate.sh b/modules/systemd-activate.sh deleted file mode 100644 index 1c464693..00000000 --- a/modules/systemd-activate.sh +++ /dev/null @@ -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 diff --git a/modules/systemd.nix b/modules/systemd.nix index dcb1a295..5acfabc7 100644 --- a/modules/systemd.nix +++ b/modules/systemd.nix @@ -54,8 +54,6 @@ 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); @@ -152,9 +150,11 @@ 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.int; + type = types.ints.unsigned; description = '' - How long to wait for started services to fail until their - start is considered successful. + How long to wait for started services to fail until their start is + considered successful. The value 0 indicates no timeout. ''; }; @@ -203,6 +203,10 @@ 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 @@ -230,13 +234,17 @@ in # set it ourselves in that case. home.activation.reloadSystemD = hm.dag.entryAfter ["linkGeneration"] ( let - autoReloadCmd = '' - ${pkgs.ruby}/bin/ruby ${./systemd-activate.rb} \ - "''${oldGenPath=}" "$newGenPath" "${servicesStartTimeoutMs}" - ''; + timeoutArg = + if cfg.servicesStartTimeoutMs != 0 then + "--timeout " + toString cfg.servicesStartTimeoutMs + else + ""; - legacyReloadCmd = '' - bash ${./systemd-activate.sh} "''${oldGenPath=}" "$newGenPath" + 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 ''; ensureRuntimeDir = "XDG_RUNTIME_DIR=\${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"; @@ -254,8 +262,7 @@ in fi ${ensureRuntimeDir} \ - PATH=${dirOf cfg.systemctlPath}:$PATH \ - ${if cfg.startServices then autoReloadCmd else legacyReloadCmd} + ${sdSwitchCmd} else echo "User systemd daemon not running. Skipping reload." fi