asgard 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +179 -99
  3. data/README.md +141 -46
  4. data/bin/asgard +10 -0
  5. data/lib/asgard/base/dispatch.rb +257 -31
  6. data/lib/asgard/base/registry.rb +7 -3
  7. data/lib/asgard/base.rb +140 -9
  8. data/lib/asgard/declarations.rb +21 -0
  9. data/lib/asgard/doctor/task_sections.rb +11 -3
  10. data/lib/asgard/doctor.rb +12 -8
  11. data/lib/asgard/jobs/commands.rb +152 -0
  12. data/lib/asgard/jobs/declaration.rb +149 -0
  13. data/lib/asgard/jobs/launcher.rb +109 -0
  14. data/lib/asgard/jobs/state.rb +190 -0
  15. data/lib/asgard/jobs/stopper.rb +141 -0
  16. data/lib/asgard/jobs.rb +25 -0
  17. data/lib/asgard/kernel_methods.rb +19 -14
  18. data/lib/asgard/leading_flag.rb +10 -0
  19. data/lib/asgard/pid.rb +130 -0
  20. data/lib/asgard/schedule/commands.rb +39 -11
  21. data/lib/asgard/schedule/declaration.rb +139 -12
  22. data/lib/asgard/schedule/job_files.rb +24 -0
  23. data/lib/asgard/schedule/launchd.rb +55 -18
  24. data/lib/asgard/schedule/systemd.rb +69 -27
  25. data/lib/asgard/schedule/table.rb +34 -16
  26. data/lib/asgard/schedule.rb +49 -9
  27. data/lib/asgard/shell.rb +372 -44
  28. data/lib/asgard/state_dir.rb +84 -0
  29. data/lib/asgard/tasks.rb +8 -0
  30. data/lib/asgard/version.rb +1 -1
  31. data/lib/asgard/watch/changes.rb +26 -1
  32. data/lib/asgard/watch/commands.rb +52 -12
  33. data/lib/asgard/watch/daemon.rb +334 -43
  34. data/lib/asgard/watch/declaration.rb +135 -40
  35. data/lib/asgard/watch/dispatcher.rb +61 -10
  36. data/lib/asgard/watch/mailbox.rb +50 -0
  37. data/lib/asgard/watch/reporter.rb +50 -34
  38. data/lib/asgard/watch/spawn.rb +41 -16
  39. data/lib/asgard/watch/state.rb +93 -27
  40. data/lib/asgard/watch/worker.rb +44 -9
  41. data/lib/asgard/watch.rb +4 -11
  42. data/lib/asgard.rb +80 -14
  43. metadata +167 -186
  44. data/.envrc +0 -6
  45. data/.github/workflows/deploy-github-pages.yml +0 -52
  46. data/.loki +0 -39
  47. data/.reek.yml +0 -166
  48. data/.rubocop.yml +0 -163
  49. data/Archspec.rb +0 -24
  50. data/CLAUDE.md +0 -139
  51. data/COMMITS.md +0 -196
  52. data/bin/console +0 -11
  53. data/bin/setup +0 -8
  54. data/doc_tasks.loki +0 -21
  55. data/docs/api.md +0 -368
  56. data/docs/assets/css/custom.css +0 -93
  57. data/docs/assets/images/asgard.jpg +0 -0
  58. data/docs/changelog.md +0 -1
  59. data/docs/dependencies.md +0 -313
  60. data/docs/environment.md +0 -129
  61. data/docs/examples.md +0 -194
  62. data/docs/getting-started.md +0 -181
  63. data/docs/helpers.md +0 -262
  64. data/docs/index.md +0 -87
  65. data/docs/options.md +0 -224
  66. data/docs/schedule.md +0 -106
  67. data/docs/shell.md +0 -237
  68. data/docs/subcommands.md +0 -181
  69. data/docs/task-files.md +0 -407
  70. data/docs/tasks.md +0 -326
  71. data/docs/variables.md +0 -338
  72. data/docs/watch.md +0 -123
  73. data/examples/.env +0 -4
  74. data/examples/.envrc +0 -3
  75. data/examples/.loki +0 -24
  76. data/examples/bad.loki +0 -63
  77. data/examples/concurrent.loki +0 -58
  78. data/examples/confirm.loki +0 -48
  79. data/examples/db_subcommands.loki +0 -74
  80. data/examples/depends_on_block/bad/.loki +0 -51
  81. data/examples/depends_on_block/good/.loki +0 -83
  82. data/examples/do.loki +0 -45
  83. data/examples/env_usage.loki +0 -27
  84. data/examples/kitchen_sink.loki +0 -210
  85. data/examples/server_subcommands.loki +0 -65
  86. data/examples/subdir/.loki +0 -12
  87. data/examples/subdir/import_demo.loki +0 -14
  88. data/examples/subdir/import_up_demo.loki +0 -18
  89. data/examples/watcher_examples/.envrc +0 -3
  90. data/examples/watcher_examples/.loki +0 -54
  91. data/examples/watcher_examples/README.md +0 -112
  92. data/examples/watcher_examples/bin/hello +0 -3
  93. data/examples/watcher_examples/data/records.json +0 -1
  94. data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
  95. data/examples/watcher_examples/docs/index.md +0 -4
  96. data/examples/watcher_examples/lib/greeter.rb +0 -7
  97. data/gem_tasks.loki +0 -51
  98. data/git.loki +0 -13
  99. data/mkdocs.yml +0 -166
  100. data/quality.loki +0 -266
  101. data/quality_rails.loki +0 -47
  102. data/xyzzy.loki +0 -12
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "open3"
4
5
  require "shellwords"
6
+ require "zlib"
5
7
 
6
8
  module Asgard
7
9
  # Scheduled asgard tasks, run by the platform's own scheduler: launchd on
@@ -27,7 +29,8 @@ module Asgard
27
29
  # #installed_entries # => [[project_slug, "demo"], ...] for every project
28
30
  # #installed_columns(name) # => { command:, schedule: } read back from the job files, or nil
29
31
  # #installed_directory(name) # => the project root the job runs in, or nil
30
- # #status(name) # => { state: :active|:stopped|:not_loaded, last_exit: String|nil }
32
+ # #status(name) # => { state: :active|:running|:stopped|:not_loaded, last_exit: String|nil }
33
+ # # last_exit: an exit code ("1") or a signal ("signal 15 (Terminated)")
31
34
  # #log_path(name) # => path the job's output is appended to
32
35
  # #notes # => [String] platform hints to show after install
33
36
  module Schedule
@@ -37,6 +40,15 @@ module Asgard
37
40
  # Weekday numbers follow launchd and cron: 0 (Sunday) through 6 (Saturday).
38
41
  DAYS = %i[sunday monday tuesday wednesday thursday friday saturday].freeze
39
42
 
43
+ # Longest derived entry name, hash suffix included, so a job's file
44
+ # names stay well inside the 255-byte limit.
45
+ NAME_MAX = 64
46
+
47
+ # Options a slug spells out: lowercase words and flags. Any other option
48
+ # (--period=week, 'Week End') adds a hash suffix, so spellings that slug
49
+ # alike still name different entries.
50
+ PLAIN_ARG = /\A-{0,2}[a-z0-9]+(?:-[a-z0-9]+)*\z/
51
+
40
52
  DAY_GROUPS = {
41
53
  weekdays: %i[monday tuesday wednesday thursday friday],
42
54
  weekends: %i[saturday sunday]
@@ -73,27 +85,67 @@ module Asgard
73
85
  # task's command-line options as a String, split shell-style with quotes
74
86
  # respected ("--period week --title 'Week End'"), or an Array of words.
75
87
  # Exactly one of at: ("HH:MM" or an Array of them, with on:) or every:
76
- # (seconds or a Duration) is required. as: names the entry.
77
- def normalize(task, options: nil, at: nil, on: :daily, every: nil, env: {}, as: nil)
78
- task = task.to_s
79
- unless task.match?(/\A[\w:-]+\z/)
80
- raise ArgumentError,
81
- "schedule: task name #{task.inspect} must be a single word; put its flags in options:"
82
- end
88
+ # (seconds or a Duration) is required; on: goes only with at:. as: names
89
+ # the entry.
90
+ def normalize(task, options: nil, at: nil, on: nil, every: nil, env: {}, as: nil)
91
+ task = task_name(task)
83
92
  raise ArgumentError, "schedule :#{task} needs either at: or every:, not both" unless at.nil? ^ every.nil?
84
93
 
85
94
  if every
95
+ raise ArgumentError, "schedule :#{task} on: only applies to at: times; every: runs on every day" if on
96
+
86
97
  every = seconds(every)
87
98
  unless every&.positive?
88
99
  raise ArgumentError,
89
100
  "schedule :#{task} every: must be a positive number of seconds or a Duration (3.minutes)"
90
101
  end
91
102
  else
103
+ on ||= :daily
92
104
  calendar(at:, on:) # raises on a bad time or day
93
105
  end
94
106
 
95
107
  args = options.is_a?(Array) ? options.map(&:to_s) : Shellwords.split(options.to_s)
96
- { name: entry_name(task, args, as), task:, args:, at:, on:, every:, env: env.to_h { |key, value| [key.to_s, value.to_s] } }
108
+ args.each { |arg| reject_control_characters!(task, "options:", arg) }
109
+ { name: entry_name(task, args, as), task:, args:, at:, on:, every:, env: environment_variables(task, env) }
110
+ end
111
+
112
+ # The command a declaration names, as Thor dispatches it: a dash is the
113
+ # underscore of the method name (`asgard daily-summary` runs
114
+ # daily_summary). A colon would name a subcommand, which is its own
115
+ # command word plus arguments.
116
+ def task_name(task)
117
+ name = task.to_s
118
+ if name.include?(":")
119
+ raise ArgumentError,
120
+ "schedule: #{name.inspect} names a subcommand; schedule its command and pass the rest in options: " \
121
+ "(schedule :#{name.split(':').first}, options: #{name.split(':', 2).last.inspect}, ...)"
122
+ end
123
+ unless name.match?(/\A[\w-]+\z/)
124
+ raise ArgumentError, "schedule: task name #{name.inspect} must be a single word; put its flags in options:"
125
+ end
126
+
127
+ name.tr("-", "_")
128
+ end
129
+
130
+ # env: as String pairs. Names must be shell-style identifiers, and no
131
+ # name, value or option may hold a control character other than tab: a
132
+ # newline would end the line it is written on in a unit file and start
133
+ # another directive, and plists cannot hold most of them at all.
134
+ def environment_variables(task, env)
135
+ env.to_h do |key, value|
136
+ key = key.to_s
137
+ raise ArgumentError, "schedule :#{task} env: #{key.inspect} is not a variable name" unless key.match?(/\A[A-Za-z_]\w*\z/)
138
+
139
+ value = value.to_s
140
+ reject_control_characters!(task, "env: #{key}", value)
141
+ [key, value]
142
+ end
143
+ end
144
+
145
+ def reject_control_characters!(task, what, text)
146
+ return unless text.match?(/[\x00-\x08\x0a-\x1f\x7f]/)
147
+
148
+ raise ArgumentError, "schedule :#{task} #{what} holds a control character (#{text.inspect})"
97
149
  end
98
150
 
99
151
  # Integer seconds from an Integer or anything Duration-like (ActiveSupport's
@@ -107,14 +159,51 @@ module Asgard
107
159
  # The task alone, or a slug of the whole command when it has arguments,
108
160
  # so one task can be scheduled more than once with different flags.
109
161
  def entry_name(task, args, as = nil)
110
- name = (as || (args.empty? ? task : slug([task, *args].join(" ")))).to_s
162
+ name = (as || (args.empty? ? task : command_slug(task, args))).to_s
111
163
  raise ArgumentError, "schedule as: #{name.inspect} may only contain letters, digits, _ . -" unless name.match?(/\A[\w.-]+\z/)
112
164
 
113
165
  name
114
166
  end
115
167
 
168
+ # "report-period-week". A short hash of the exact words is appended when
169
+ # the slug would lose something (an option that isn't PLAIN_ARG, or more
170
+ # than NAME_MAX characters). Plain slugs can still meet: `--period week`
171
+ # and `period week` both give report-period-week, so do `report` with
172
+ # `period week` and `report_period` with `week`. Schedule.declare
173
+ # gives each of two such rivals its hashed_slug, so different commands
174
+ # never share a name.
175
+ def command_slug(task, args)
176
+ words = [task, *args].map(&:to_s)
177
+ name = slug(words.join(" "))
178
+ return name if name.length <= NAME_MAX && words.drop(1).all? { |arg| arg.match?(PLAIN_ARG) }
179
+
180
+ hashed_slug(task, args)
181
+ end
182
+
183
+ # The slug with a short hash of the exact words appended, capped at
184
+ # NAME_MAX: "report-period-week-1a2b3c4d".
185
+ def hashed_slug(task, args)
186
+ words = [task, *args].map(&:to_s)
187
+ digest = Digest::SHA256.hexdigest(words.join("\0"))[0, 8]
188
+ "#{slug(words.join(' '))[0, NAME_MAX - 9].delete_suffix('-')}-#{digest}"
189
+ end
190
+
116
191
  def slug(name) = name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
117
192
 
193
+ # The slug a project's jobs and watcher state are filed under: the
194
+ # basename of its root plus the CRC32 of the real path in hex
195
+ # ("api-9f3a1c2b"), so two projects in directories of the same name
196
+ # never share labels, units or state. Symlinked paths to one project
197
+ # agree, since the real path is hashed.
198
+ def project_slug(root)
199
+ real = File.realpath(root.to_s)
200
+ "#{slug(File.basename(real))}-#{format('%08x', Zlib.crc32(real))}"
201
+ end
202
+
203
+ # Whether +slug+ is the project the user named: the whole slug, or its
204
+ # basename alone when that is enough to pick it out.
205
+ def project_match?(slug, name) = slug == name || slug.sub(/-\h{8}\z/, "") == name
206
+
118
207
  # For display: the command line the job runs.
119
208
  def command_line(task, args = []) = Shellwords.join(["asgard", task.to_s, *args])
120
209
 
@@ -142,7 +231,18 @@ module Asgard
142
231
  # The state and last-exit columns of a `list` row, from a backend's live status.
143
232
  def live_columns(backend, name)
144
233
  state, last_exit = backend.status(name).values_at(:state, :last_exit)
145
- { state: state.to_s.tr("_", " "), last_exit: state == :active ? (last_exit || "never run") : "-" }
234
+ { state: state.to_s.tr("_", " "), last_exit: %i[active running].include?(state) ? (last_exit || "never run") : "-" }
235
+ end
236
+
237
+ # The [slug, name] entries whose job files name a project directory
238
+ # that no longer exists: the project moved or was deleted, and its jobs
239
+ # fire in a directory that is not there. An entry whose directory
240
+ # cannot be read is left alone.
241
+ def orphaned_entries(entries, directory_of:, exists: File.method(:directory?))
242
+ entries.select do |slug, name|
243
+ dir = directory_of.call(slug, name)
244
+ dir && !exists.call(dir)
245
+ end
146
246
  end
147
247
 
148
248
  # The command a job runs, from its program arguments: everything from
@@ -172,7 +272,7 @@ module Asgard
172
272
  return :daily unless days
173
273
 
174
274
  names = days.map { |day| DAYS.fetch(day) }
175
- DAY_GROUPS.key(names) || names
275
+ DAY_GROUPS.find { |_group, members| members.sort == names.sort }&.first || names
176
276
  end
177
277
 
178
278
  def compact_times(times)
@@ -200,6 +300,8 @@ module Asgard
200
300
  return nil if on.to_s == "daily"
201
301
 
202
302
  names = DAY_GROUPS.fetch(on.is_a?(Array) ? nil : on.to_sym) { Array(on) }
303
+ raise ArgumentError, "on: needs at least one day" if names.empty?
304
+
203
305
  names.map do |day|
204
306
  DAYS.index(day.to_sym) or raise ArgumentError, "on: unknown day #{day.inspect}"
205
307
  end
@@ -208,6 +310,8 @@ module Asgard
208
310
  # One entry per time: [{ hour: 17, minute: 30, days: [1, 2, 3, 4, 5] }];
209
311
  # days is nil for every day.
210
312
  def calendar(at:, on: :daily)
313
+ raise ArgumentError, "at: needs at least one time" if Array(at).empty?
314
+
211
315
  days = weekdays(on)
212
316
  Array(at).map do |time|
213
317
  hour, minute = parse_time(time)
@@ -215,6 +319,29 @@ module Asgard
215
319
  end
216
320
  end
217
321
 
322
+ # A job's last exit when a signal ended it: "signal 15 (Terminated)".
323
+ def signal_exit(number, name) = "signal #{number} (#{name})"
324
+
325
+ # Raises when a job file's name passes the 255-byte filename limit.
326
+ def check_lengths!(paths)
327
+ long = paths.map { |path| File.basename(path) }.find { |base| base.bytesize > 255 }
328
+ raise Error, "schedule: job file name is too long (#{long.bytesize} bytes, limit 255): #{long}; use a shorter as: name" if long
329
+ end
330
+
331
+ # Runs the block that writes job files; a failure there (the system
332
+ # message names the path) is a Schedule::Error, not a backtrace.
333
+ def writing
334
+ yield
335
+ rescue SystemCallError => e
336
+ raise Error, "schedule: could not write job files: #{e.message}"
337
+ end
338
+
339
+ # Writes a job file only its owner can read: it holds the env: values.
340
+ def write_private(path, content)
341
+ File.write(path, content, perm: 0o600)
342
+ File.chmod(0o600, path) # perm: applies only when the file is created
343
+ end
344
+
218
345
  # PATH captured at install time, plus the declaration's env:.
219
346
  def environment(spec, path = ENV.fetch("PATH", nil)) = { "PATH" => path.to_s }.merge(spec[:env])
220
347
 
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asgard
4
+ module Schedule
5
+ # Reading back the job files a backend writes, the same way for
6
+ # launchd plists and systemd units. Pure apart from reading the files.
7
+ module JobFiles
8
+ module_function
9
+
10
+ # [[project_slug, name], ...], sorted, for each job file in +dir+.
11
+ def entries_in(dir, prefix:, ext:) = entries(Dir.glob(File.join(dir, "#{prefix}.*#{ext}")), prefix:, ext:)
12
+
13
+ # [[project_slug, name], ...], sorted, for each of +paths+ named
14
+ # <prefix>.<project_slug>.<name><ext>; any other file name is skipped.
15
+ def entries(paths, prefix:, ext:)
16
+ paths.map { File.basename(it, ext).delete_prefix("#{prefix}.").split(".", 2) }.select { it.size == 2 }.sort
17
+ end
18
+
19
+ # The project root +parser+ reads from the job file at +path+, or nil
20
+ # when there is no such file.
21
+ def directory(path, parser) = File.exist?(path) ? parser.call(File.read(path)) : nil
22
+ end
23
+ end
24
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "job_files"
4
5
 
5
6
  module Asgard
6
7
  module Schedule
@@ -28,6 +29,9 @@ module Asgard
28
29
  # ("label" => disabled, or "label" => true on older macOS).
29
30
  def self.disabled_labels(output) = output.scan(/"([^"]+)"\s*=>\s*(disabled|true)\b/).map(&:first)
30
31
 
32
+ # Whether the installed file at +path+ already holds +content+.
33
+ def self.unchanged?(path, content) = File.exist?(path) && File.read(path) == content
34
+
31
35
  def self.plist(label:, arguments:, working_directory:, environment:, log_path:, intervals: nil, every: nil)
32
36
  dict = {
33
37
  "Label" => label,
@@ -134,12 +138,21 @@ module Asgard
134
138
  { plist_path(name) => plist }
135
139
  end
136
140
 
141
+ # An unchanged plist whose job is loaded is left alone: a bootout
142
+ # would kill a run in progress for nothing. A changed one is linted
143
+ # before it replaces the installed file, then reloaded.
137
144
  def install(spec, asgard:, direnv:)
138
- name = spec[:name]
139
- plist = plist_path(name)
140
- FileUtils.mkdir_p [File.dirname(plist), File.dirname(log_path(name))]
141
- files(spec, asgard:, direnv:).each { |path, content| File.write(path, content) }
142
- run! "plutil", "-lint", "-s", plist
145
+ name = spec[:name]
146
+ plist = plist_path(name)
147
+ log = log_path(name)
148
+ content = files(spec, asgard:, direnv:).fetch(plist)
149
+ Schedule.check_lengths!([plist, "#{plist}.new", log])
150
+ return :active if self.class.unchanged?(plist, content) && loaded?(name)
151
+
152
+ Schedule.writing do
153
+ FileUtils.mkdir_p [File.dirname(plist), File.dirname(log)]
154
+ write_linted(plist, content)
155
+ end
143
156
  unload(name)
144
157
  return :stopped if stopped?(name)
145
158
 
@@ -171,11 +184,7 @@ module Asgard
171
184
  end
172
185
 
173
186
  # Every asgard entry on this machine, whatever its project: [[project_slug, name], ...].
174
- def installed_entries
175
- paths = Dir.glob(File.join(@home, "Library", "LaunchAgents", "#{LABEL_PREFIX}.*.plist"))
176
- paths.map { |path| File.basename(path, ".plist").delete_prefix("#{LABEL_PREFIX}.").split(".", 2) }
177
- .select { |entry| entry.size == 2 }.sort
178
- end
187
+ def installed_entries = JobFiles.entries_in(File.join(@home, "Library", "LaunchAgents"), prefix: LABEL_PREFIX, ext: ".plist")
179
188
 
180
189
  # { command:, schedule: } as installed, read from the plist; nil when there isn't one.
181
190
  def installed_columns(name)
@@ -186,17 +195,23 @@ module Asgard
186
195
  end
187
196
 
188
197
  # The project root the job runs in, read from the plist; nil when there isn't one.
189
- def installed_directory(name)
190
- path = plist_path(name)
191
- self.class.parse_directory(File.read(path)) if File.exist?(path)
192
- end
198
+ def installed_directory(name) = JobFiles.directory(plist_path(name), self.class.method(:parse_directory))
193
199
 
194
200
  def status(name)
195
201
  out, ok = run("launchctl", "print", target(name))
196
202
  return { state: stopped?(name) ? :stopped : :not_loaded, last_exit: nil } unless ok
197
203
 
204
+ { state: out.include?("state = running") ? :running : :active, last_exit: self.class.last_exit(out) }
205
+ end
206
+
207
+ # The last exit from `launchctl print`: its code, or the signal that
208
+ # ended it ("signal 15 (Terminated)"); nil when it never exited.
209
+ def self.last_exit(out)
198
210
  code = out[/last exit code = (\d+)/, 1]
199
- { state: :active, last_exit: code }
211
+ return code if code
212
+
213
+ signal = out.match(/last terminating signal = ([^:\n]+): (\d+)/)
214
+ Schedule.signal_exit(signal[2], signal[1]) if signal
200
215
  end
201
216
 
202
217
  def notes = []
@@ -205,6 +220,17 @@ module Asgard
205
220
 
206
221
  def target(name) = "#{@domain}/#{label(name)}"
207
222
 
223
+ # Written beside the plist (not *.plist, so never listed), linted,
224
+ # then renamed over it; a plist plutil rejects never lands.
225
+ def write_linted(plist, content)
226
+ draft = "#{plist}.new"
227
+ Schedule.write_private(draft, content)
228
+ run! "plutil", "-lint", "-s", draft
229
+ File.rename(draft, plist)
230
+ ensure
231
+ FileUtils.rm_f(draft)
232
+ end
233
+
208
234
  def loaded?(name) = run("launchctl", "print", target(name)).last
209
235
 
210
236
  def stopped?(name)
@@ -213,16 +239,27 @@ module Asgard
213
239
  end
214
240
 
215
241
  # bootout returns before the job is fully gone; bootstrapping the same
216
- # label too soon fails with "Input/output error", so wait for it.
242
+ # label too soon fails with "Input/output error", so wait for it. A job
243
+ # still loaded afterwards raises, so callers never delete the plist of
244
+ # a job launchd keeps running.
217
245
  def unload(name)
218
246
  return unless loaded?(name)
219
247
 
220
- run "launchctl", "bootout", target(name)
248
+ out, ok = run("launchctl", "bootout", target(name))
249
+ return if ok && unloaded?(name)
250
+ return unless loaded?(name)
251
+
252
+ raise Error, "launchctl bootout #{target(name)} left #{label(name)} still loaded: #{out.strip}"
253
+ end
254
+
255
+ # Polls for up to two seconds; true once the job is gone.
256
+ def unloaded?(name)
221
257
  20.times do
222
- break unless loaded?(name)
258
+ return true unless loaded?(name)
223
259
 
224
260
  sleep 0.1
225
261
  end
262
+ false
226
263
  end
227
264
 
228
265
  def run(*argv) = @runner.call(*argv)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "etc"
5
+ require_relative "job_files"
5
6
 
6
7
  module Asgard
7
8
  module Schedule
@@ -15,6 +16,8 @@ module Asgard
15
16
  class Systemd
16
17
  UNIT_PREFIX = "asgard"
17
18
  DAY_ABBREVIATIONS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze
19
+ # The service properties status asks `systemctl show` for.
20
+ SHOW_PROPERTIES = %w[ActiveState SubState ExecMainCode ExecMainStatus ExecMainExitTimestampMonotonic].freeze
18
21
 
19
22
  # ---- pure helpers -----------------------------------------------------
20
23
 
@@ -31,14 +34,23 @@ module Asgard
31
34
  # systemd expands %specifiers everywhere; % must be doubled to be literal.
32
35
  def self.escape_specifiers(text) = text.to_s.gsub("%", "%%")
33
36
 
34
- # A double-quoted word for ExecStart=/Environment=. $ is doubled too so
35
- # ExecStart= doesn't expand it as a variable.
36
- def self.quote(text)
37
- %("#{escape_specifiers(text).gsub('\\') { '\\\\' }.gsub('"', '\\"').gsub('$', '$$')}")
38
- end
37
+ # A double-quoted word for ExecStart=. $ is doubled too so ExecStart=
38
+ # doesn't expand it as a variable.
39
+ def self.quote(text) = %("#{quote_body(text).gsub('$', '$$')}")
40
+
41
+ # A double-quoted Environment= assignment. Environment= does no
42
+ # variable expansion, so a $ there is already literal and doubling it
43
+ # would leave $$ in the value (systemd.exec(5)).
44
+ def self.quote_env(text) = %("#{quote_body(text)}")
39
45
 
46
+ # % doubled, \ and " backslash-escaped: what both kinds of quoting share.
47
+ def self.quote_body(text) = escape_specifiers(text).gsub('\\') { '\\\\' }.gsub('"', '\\"')
48
+
49
+ # KillMode=process: when the run ends, systemd kills only the task's own
50
+ # process, so background jobs it started (setsid alone can't leave the
51
+ # unit's cgroup) keep running, as they do under launchd.
40
52
  def self.service_unit(description:, arguments:, working_directory:, environment:, log_path:)
41
- env_lines = environment.map { |key, value| "Environment=#{quote("#{key}=#{value}")}" }
53
+ env_lines = environment.map { |key, value| "Environment=#{quote_env("#{key}=#{value}")}" }
42
54
  log = escape_specifiers(log_path)
43
55
  <<~UNIT
44
56
  [Unit]
@@ -46,6 +58,7 @@ module Asgard
46
58
 
47
59
  [Service]
48
60
  Type=oneshot
61
+ KillMode=process
49
62
  WorkingDirectory=#{escape_specifiers(working_directory)}
50
63
  #{env_lines.join("\n")}
51
64
  ExecStart=#{arguments.map { |arg| quote(arg) }.join(' ')}
@@ -103,6 +116,22 @@ module Asgard
103
116
  # `systemctl show -p A -p B` output => { "A" => "...", "B" => "..." }
104
117
  def self.parse_show(output) = output.lines.to_h { |line| line.chomp.split("=", 2) }.reject { |k, _| k.to_s.empty? }
105
118
 
119
+ # parse_show of SHOW_PROPERTIES => { running: Boolean, last_exit: String|nil }.
120
+ # A oneshot service is "activating" while its command runs. ExecMainCode
121
+ # 2 (killed) or 3 (dumped) means ExecMainStatus is a signal number.
122
+ def self.service_status(props)
123
+ stamp = props["ExecMainExitTimestampMonotonic"].to_s
124
+ status = props["ExecMainStatus"] unless stamp.empty? || stamp == "0"
125
+ signal = status if %w[2 3].include?(props["ExecMainCode"])
126
+ {
127
+ running: props["ActiveState"] == "activating" || props["SubState"] == "running",
128
+ last_exit: signal ? Schedule.signal_exit(signal, "SIG#{Signal.signame(signal.to_i)}") : status
129
+ }
130
+ end
131
+
132
+ # The { path => content } entries whose file is missing or differs.
133
+ def self.changed_files(files) = files.reject { |path, content| File.exist?(path) && File.read(path) == content }
134
+
106
135
  # ---- backend API ------------------------------------------------------
107
136
 
108
137
  def initialize(project:, root:, home: Dir.home, runner: Schedule.runner, env: ENV, user: Etc.getpwuid(Process.uid).name)
@@ -144,25 +173,36 @@ module Asgard
144
173
  { service_path(name) => service, timer_path(name) => timer }
145
174
  end
146
175
 
147
- # A timer that exists but is disabled was stopped on purpose; keep it so.
176
+ # A timer that exists but is disabled was stopped on purpose; keep it
177
+ # so. That is only true once an install has finished, so a first
178
+ # install that fails takes its unit files back out. Unchanged units are
179
+ # left alone: restarting a timer would restart an every: countdown, and
180
+ # `start` is a no-op for a timer already running.
148
181
  def install(spec, asgard:, direnv:)
149
182
  name = spec[:name]
150
183
  timer_file = timer_path(name)
151
184
  timer_unit = timer(name)
152
- stopped = File.exist?(timer_file) && !enabled?(name)
153
- FileUtils.mkdir_p [File.dirname(timer_file), File.dirname(log_path(name))]
154
- files(spec, asgard:, direnv:).each { |path, content| File.write(path, content) }
155
- systemctl! "daemon-reload"
185
+ job = files(spec, asgard:, direnv:)
186
+ Schedule.check_lengths!([*job.keys, log_path(name)])
187
+ first = !File.exist?(timer_file)
188
+ stopped = !first && !enabled?(name)
189
+ changed = write_changed(job, name).any?
190
+ systemctl! "daemon-reload" if changed
156
191
  return :stopped if stopped
157
192
 
158
193
  systemctl! "enable", timer_unit
159
- systemctl! "restart", timer_unit # picks up a changed schedule
194
+ systemctl! changed ? "restart" : "start", timer_unit # restart picks up a changed schedule
160
195
  :active
196
+ rescue Error
197
+ FileUtils.rm_f([service_path(name), timer_file]) if first
198
+ raise
161
199
  end
162
200
 
201
+ # A timer that won't disable is left installed, files and all, rather
202
+ # than deleted while systemd keeps it running.
163
203
  def uninstall(name)
164
204
  service_unit = service(name)
165
- systemctl "disable", "--now", timer(name)
205
+ systemctl! "disable", "--now", timer(name)
166
206
  systemctl "stop", service_unit
167
207
  FileUtils.rm_f [service_path(name), timer_path(name)]
168
208
  systemctl "daemon-reload"
@@ -181,11 +221,7 @@ module Asgard
181
221
  end
182
222
 
183
223
  # Every asgard entry on this machine, whatever its project: [[project_slug, name], ...].
184
- def installed_entries
185
- paths = Dir.glob(File.join(@config, "systemd", "user", "#{UNIT_PREFIX}.*.timer"))
186
- paths.map { |path| File.basename(path, ".timer").delete_prefix("#{UNIT_PREFIX}.").split(".", 2) }
187
- .select { |entry| entry.size == 2 }.sort
188
- end
224
+ def installed_entries = JobFiles.entries_in(File.join(@config, "systemd", "user"), prefix: UNIT_PREFIX, ext: ".timer")
189
225
 
190
226
  # { command:, schedule: } as installed, read from the unit files; nil when there are none.
191
227
  def installed_columns(name)
@@ -198,10 +234,7 @@ module Asgard
198
234
  end
199
235
 
200
236
  # The project root the job runs in, read from the service unit; nil when there isn't one.
201
- def installed_directory(name)
202
- path = service_path(name)
203
- self.class.parse_directory(File.read(path)) if File.exist?(path)
204
- end
237
+ def installed_directory(name) = JobFiles.directory(service_path(name), self.class.method(:parse_directory))
205
238
 
206
239
  def status(name)
207
240
  _, active = systemctl("is-active", "--quiet", timer(name))
@@ -209,11 +242,10 @@ module Asgard
209
242
  elsif enabled?(name) then :not_loaded
210
243
  else :stopped
211
244
  end
212
- out, = systemctl("show", service(name), "-p", "ExecMainStatus", "-p", "ExecMainExitTimestampMonotonic")
213
- props = self.class.parse_show(out)
214
- stamp = props["ExecMainExitTimestampMonotonic"].to_s
215
- ran = !stamp.empty? && stamp != "0"
216
- { state:, last_exit: ran ? props["ExecMainStatus"] : nil }
245
+ klass = self.class
246
+ out, = systemctl("show", service(name), *SHOW_PROPERTIES.flat_map { |prop| ["-p", prop] })
247
+ running, last_exit = klass.service_status(klass.parse_show(out)).values_at(:running, :last_exit)
248
+ { state: running ? :running : state, last_exit: }
217
249
  end
218
250
 
219
251
  def notes
@@ -229,6 +261,16 @@ module Asgard
229
261
 
230
262
  def service(name) = "#{unit(name)}.service"
231
263
 
264
+ # Writes the unit files whose content changed (and the log directory);
265
+ # returns them as { path => content }.
266
+ def write_changed(job, name)
267
+ changed = self.class.changed_files(job)
268
+ Schedule.writing do
269
+ FileUtils.mkdir_p [File.dirname(timer_path(name)), File.dirname(log_path(name))]
270
+ changed.each { |path, content| Schedule.write_private(path, content) }
271
+ end
272
+ end
273
+
232
274
  def enabled?(name) = systemctl("is-enabled", "--quiet", timer(name)).last
233
275
 
234
276
  def systemctl(*) = run("systemctl", "--user", *)