aboutsummaryrefslogtreecommitdiff
path: root/home-manager/modules/systemd-activate.rb
blob: 8382c840e9328a377f04c6c553de07e9498e6691 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
require 'set'
require 'open3'
require 'shellwords'

@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?

  # These services should be running when this script is finished
  services_to_run = get_services_to_run(new_units_path)
  maybe_changed_services = services_to_run & old_services

  # 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 = get_changed_services(old_units_path, new_units_path, maybe_changed_services)
  to_start = get_inactive_units(services_to_run - to_restart)

  raise "daemon-reload failed" unless run_cmd('systemctl --user daemon-reload')

  # Exclude services that aren't allowed to be manually started or stopped
  no_manual_start, no_manual_stop, no_restart = get_restricted_units(to_stop + to_restart + to_start)
  to_stop -= no_manual_stop + no_restart
  to_restart -= no_manual_stop + no_manual_start + no_restart
  to_start -= no_manual_start

  puts "Not restarting: #{no_restart.join(' ')}" unless no_restart.empty?

  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('stop', to_stop)
    systemctl('start', to_start)
    systemctl('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}'] }
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 services wanted by active targets
def get_services_to_run(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)
  services_to_run = active_targets.map do |target|
    get_service_files(File.join(units_dir, "#{target}.wants"))
  end.flatten
  Set.new(services_to_run)
end

# @return true on success
def run_cmd(cmd)
  print_cmd cmd
  @dry_run || system(cmd)
end

def systemctl(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
    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 print_cmd(cmd)
  puts cmd if @verbose || @dry_run
end

def get_active_units(units)
  get_units_by_activity(units, true)
end

def get_inactive_units(units)
  get_units_by_activity(units, false)
end

def get_units_by_activity(units, active)
  return [] if units.empty?
  units = units.to_a
  is_active = `systemctl --user is-active #{units.shelljoin}`.split
  units.select.with_index do |_, i|
    (is_active[i] == 'active') == active
  end
end

def get_restricted_units(units)
  units = units.to_a
  infos = `systemctl --user show -p RefuseManualStart -p RefuseManualStop #{units.shelljoin}`
          .split("\n\n")
  no_restart = []
  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
  # Regular expression that indicates that a service should not be
  # restarted even if a change has been detected.
  restartRe = /^[ \t]*X-RestartIfChanged[ \t]*=[ \t]*false[ \t]*(?:#.*)?$/
  units.each do |unit|
    if `systemctl --user cat #{unit.shellescape}` =~ restartRe
      no_restart << unit
    end
  end
  [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_inactive_units(services)
end

def show_failed_services_status(services)
  puts
  services.each do |service|
    run_cmd("systemctl --user status #{service.shellescape}")
    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

setup_services(*ARGV)