Skip to content

Commit

Permalink
Revert "systemd: use sd-switch"
Browse files Browse the repository at this point in the history
This reverts commit 9c0fe39.
  • Loading branch information
rycee committed Aug 4, 2020
1 parent 9c0fe39 commit 223e3c3
Show file tree
Hide file tree
Showing 5 changed files with 344 additions and 39 deletions.
5 changes: 1 addition & 4 deletions doc/release-notes/rl-2009.adoc
Expand Up @@ -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
Expand Down
15 changes: 0 additions & 15 deletions modules/misc/news.nix
Expand Up @@ -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.
'';
}
];
};
}
216 changes: 216 additions & 0 deletions 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)
114 changes: 114 additions & 0 deletions 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

2 comments on commit 223e3c3

@reedrw
Copy link

@reedrw reedrw commented on 223e3c3 Aug 6, 2020

Choose a reason for hiding this comment

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

I liked this. Why revert it?

@cole-h
Copy link
Member

@cole-h cole-h commented on 223e3c3 Aug 6, 2020

Choose a reason for hiding this comment

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

Because of #1388 (comment).

Please sign in to comment.