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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +179 -99
- data/README.md +141 -46
- data/bin/asgard +10 -0
- data/lib/asgard/base/dispatch.rb +257 -31
- data/lib/asgard/base/registry.rb +7 -3
- data/lib/asgard/base.rb +140 -9
- data/lib/asgard/declarations.rb +21 -0
- data/lib/asgard/doctor/task_sections.rb +11 -3
- data/lib/asgard/doctor.rb +12 -8
- data/lib/asgard/jobs/commands.rb +152 -0
- data/lib/asgard/jobs/declaration.rb +149 -0
- data/lib/asgard/jobs/launcher.rb +109 -0
- data/lib/asgard/jobs/state.rb +190 -0
- data/lib/asgard/jobs/stopper.rb +141 -0
- data/lib/asgard/jobs.rb +25 -0
- data/lib/asgard/kernel_methods.rb +19 -14
- data/lib/asgard/leading_flag.rb +10 -0
- data/lib/asgard/pid.rb +130 -0
- data/lib/asgard/schedule/commands.rb +39 -11
- data/lib/asgard/schedule/declaration.rb +139 -12
- data/lib/asgard/schedule/job_files.rb +24 -0
- data/lib/asgard/schedule/launchd.rb +55 -18
- data/lib/asgard/schedule/systemd.rb +69 -27
- data/lib/asgard/schedule/table.rb +34 -16
- data/lib/asgard/schedule.rb +49 -9
- data/lib/asgard/shell.rb +372 -44
- data/lib/asgard/state_dir.rb +84 -0
- data/lib/asgard/tasks.rb +8 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard/watch/changes.rb +26 -1
- data/lib/asgard/watch/commands.rb +52 -12
- data/lib/asgard/watch/daemon.rb +334 -43
- data/lib/asgard/watch/declaration.rb +135 -40
- data/lib/asgard/watch/dispatcher.rb +61 -10
- data/lib/asgard/watch/mailbox.rb +50 -0
- data/lib/asgard/watch/reporter.rb +50 -34
- data/lib/asgard/watch/spawn.rb +41 -16
- data/lib/asgard/watch/state.rb +93 -27
- data/lib/asgard/watch/worker.rb +44 -9
- data/lib/asgard/watch.rb +4 -11
- data/lib/asgard.rb +80 -14
- metadata +167 -186
- data/.envrc +0 -6
- data/.github/workflows/deploy-github-pages.yml +0 -52
- data/.loki +0 -39
- data/.reek.yml +0 -166
- data/.rubocop.yml +0 -163
- data/Archspec.rb +0 -24
- data/CLAUDE.md +0 -139
- data/COMMITS.md +0 -196
- data/bin/console +0 -11
- data/bin/setup +0 -8
- data/doc_tasks.loki +0 -21
- data/docs/api.md +0 -368
- data/docs/assets/css/custom.css +0 -93
- data/docs/assets/images/asgard.jpg +0 -0
- data/docs/changelog.md +0 -1
- data/docs/dependencies.md +0 -313
- data/docs/environment.md +0 -129
- data/docs/examples.md +0 -194
- data/docs/getting-started.md +0 -181
- data/docs/helpers.md +0 -262
- data/docs/index.md +0 -87
- data/docs/options.md +0 -224
- data/docs/schedule.md +0 -106
- data/docs/shell.md +0 -237
- data/docs/subcommands.md +0 -181
- data/docs/task-files.md +0 -407
- data/docs/tasks.md +0 -326
- data/docs/variables.md +0 -338
- data/docs/watch.md +0 -123
- data/examples/.env +0 -4
- data/examples/.envrc +0 -3
- data/examples/.loki +0 -24
- data/examples/bad.loki +0 -63
- data/examples/concurrent.loki +0 -58
- data/examples/confirm.loki +0 -48
- data/examples/db_subcommands.loki +0 -74
- data/examples/depends_on_block/bad/.loki +0 -51
- data/examples/depends_on_block/good/.loki +0 -83
- data/examples/do.loki +0 -45
- data/examples/env_usage.loki +0 -27
- data/examples/kitchen_sink.loki +0 -210
- data/examples/server_subcommands.loki +0 -65
- data/examples/subdir/.loki +0 -12
- data/examples/subdir/import_demo.loki +0 -14
- data/examples/subdir/import_up_demo.loki +0 -18
- data/examples/watcher_examples/.envrc +0 -3
- data/examples/watcher_examples/.loki +0 -54
- data/examples/watcher_examples/README.md +0 -112
- data/examples/watcher_examples/bin/hello +0 -3
- data/examples/watcher_examples/data/records.json +0 -1
- data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
- data/examples/watcher_examples/docs/index.md +0 -4
- data/examples/watcher_examples/lib/greeter.rb +0 -7
- data/gem_tasks.loki +0 -51
- data/git.loki +0 -13
- data/mkdocs.yml +0 -166
- data/quality.loki +0 -266
- data/quality_rails.loki +0 -47
- 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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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 :
|
|
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:
|
|
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.
|
|
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
|
|
139
|
-
plist
|
|
140
|
-
|
|
141
|
-
files(spec, asgard:, direnv:).
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
35
|
-
#
|
|
36
|
-
def self.quote(text)
|
|
37
|
-
|
|
38
|
-
|
|
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=#{
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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", *)
|