asgard 0.3.3 → 0.4.1
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/.reek.yml +39 -0
- data/CHANGELOG.md +86 -5
- data/CLAUDE.md +8 -2
- data/README.md +37 -0
- data/docs/api.md +21 -0
- data/docs/changelog.md +1 -134
- data/docs/index.md +1 -0
- data/docs/schedule.md +106 -0
- data/lib/asgard/errors.rb +7 -0
- data/lib/asgard/schedule/commands.rb +240 -0
- data/lib/asgard/schedule/declaration.rb +237 -0
- data/lib/asgard/schedule/launchd.rb +238 -0
- data/lib/asgard/schedule/systemd.rb +246 -0
- data/lib/asgard/schedule/table.rb +78 -0
- data/lib/asgard/schedule.rb +45 -0
- data/lib/asgard/tasks.rb +12 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard.rb +14 -6
- data/mkdocs.yml +1 -0
- data/quality.loki +7 -2
- metadata +26 -3
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Asgard
|
|
6
|
+
module Schedule
|
|
7
|
+
# macOS backend: each declaration becomes a launchd user agent
|
|
8
|
+
# (~/Library/LaunchAgents/com.madbomber.asgard.<project>.<name>.plist).
|
|
9
|
+
# launchd runs a calendar job missed while the Mac slept as soon as it
|
|
10
|
+
# wakes. Implements the backend API documented in declaration.rb.
|
|
11
|
+
class Launchd
|
|
12
|
+
LABEL_PREFIX = "com.madbomber.asgard"
|
|
13
|
+
|
|
14
|
+
# ---- pure helpers -----------------------------------------------------
|
|
15
|
+
|
|
16
|
+
def self.label_prefix(project) = "#{LABEL_PREFIX}.#{Schedule.slug(project)}."
|
|
17
|
+
|
|
18
|
+
def self.label(project, name) = "#{label_prefix(project)}#{name}"
|
|
19
|
+
|
|
20
|
+
# One StartCalendarInterval entry per (time, weekday) pair.
|
|
21
|
+
def self.calendar_intervals(at:, on: :daily)
|
|
22
|
+
Schedule.calendar(at:, on:).flat_map do |entry|
|
|
23
|
+
(entry[:days] || [nil]).map { |day| { "Hour" => entry[:hour], "Minute" => entry[:minute], "Weekday" => day }.compact }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Labels marked disabled in `launchctl print-disabled` output
|
|
28
|
+
# ("label" => disabled, or "label" => true on older macOS).
|
|
29
|
+
def self.disabled_labels(output) = output.scan(/"([^"]+)"\s*=>\s*(disabled|true)\b/).map(&:first)
|
|
30
|
+
|
|
31
|
+
def self.plist(label:, arguments:, working_directory:, environment:, log_path:, intervals: nil, every: nil)
|
|
32
|
+
dict = {
|
|
33
|
+
"Label" => label,
|
|
34
|
+
"ProgramArguments" => arguments,
|
|
35
|
+
"WorkingDirectory" => working_directory,
|
|
36
|
+
"EnvironmentVariables" => environment,
|
|
37
|
+
"StandardOutPath" => log_path,
|
|
38
|
+
"StandardErrorPath" => log_path
|
|
39
|
+
}
|
|
40
|
+
every ? dict["StartInterval"] = every : dict["StartCalendarInterval"] = intervals
|
|
41
|
+
|
|
42
|
+
<<~XML
|
|
43
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
44
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
45
|
+
<plist version="1.0">
|
|
46
|
+
#{to_xml(dict)}
|
|
47
|
+
</plist>
|
|
48
|
+
XML
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.to_xml(value, indent = "")
|
|
52
|
+
case value
|
|
53
|
+
in Hash
|
|
54
|
+
body = value.flat_map { |key, item| ["#{indent} <key>#{escape(key)}</key>", to_xml(item, "#{indent} ")] }
|
|
55
|
+
["#{indent}<dict>", *body, "#{indent}</dict>"].join("\n")
|
|
56
|
+
in Array
|
|
57
|
+
["#{indent}<array>", *value.map { |element| to_xml(element, "#{indent} ") }, "#{indent}</array>"].join("\n")
|
|
58
|
+
in Integer
|
|
59
|
+
"#{indent}<integer>#{value}</integer>"
|
|
60
|
+
in true | false
|
|
61
|
+
"#{indent}<#{value}/>"
|
|
62
|
+
else
|
|
63
|
+
"#{indent}<string>#{escape(value)}</string>"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The parts of a plist (as `plist` writes it) that say what runs and when:
|
|
68
|
+
# { arguments: [String], calendar: [{ hour:, minute:, days: [Integer]|nil }], every: Integer|nil }
|
|
69
|
+
def self.parse_plist(xml)
|
|
70
|
+
{
|
|
71
|
+
arguments: array_body(xml, "ProgramArguments").scan(%r{<string>(.*?)</string>}m).flatten.map { |text| unescape(text) },
|
|
72
|
+
calendar: calendar_entries(array_body(xml, "StartCalendarInterval").scan(%r{<dict>(.*?)</dict>}m).flatten),
|
|
73
|
+
every: xml[%r{<key>StartInterval</key>\s*<integer>(\d+)</integer>}, 1]&.to_i
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# StartCalendarInterval dict bodies => one entry per time, days collected.
|
|
78
|
+
def self.calendar_entries(dicts)
|
|
79
|
+
dicts.map { |body| parse_dict(body) }
|
|
80
|
+
.group_by { |dict| dict.values_at("Hour", "Minute") }
|
|
81
|
+
.map { |(hour, minute), group| calendar_entry(hour, minute, group) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# <key>Hour</key><integer>13</integer>... => { "Hour" => 13, ... }
|
|
85
|
+
def self.parse_dict(body) = body.scan(%r{<key>(\w+)</key>\s*<integer>(\d+)</integer>}).to_h { |key, num| [key, num.to_i] }
|
|
86
|
+
|
|
87
|
+
# group: the Weekday-bearing dicts for one time; days is nil when there are none (every day).
|
|
88
|
+
def self.calendar_entry(hour, minute, group)
|
|
89
|
+
days = group.filter_map { |dict| dict["Weekday"] }
|
|
90
|
+
{ hour:, minute:, days: days.empty? ? nil : days }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The text inside the <array> that follows <key>key</key>.
|
|
94
|
+
def self.array_body(xml, key) = xml[%r{<key>#{key}</key>\s*<array>(.*?)</array>}m, 1].to_s
|
|
95
|
+
|
|
96
|
+
# The WorkingDirectory (the project root) of a plist, or nil.
|
|
97
|
+
def self.parse_directory(xml) = xml[%r{<key>WorkingDirectory</key>\s*<string>(.*?)</string>}m, 1]&.then { |text| unescape(text) }
|
|
98
|
+
|
|
99
|
+
def self.unescape(text) = text.gsub("<", "<").gsub(">", ">").gsub("&", "&")
|
|
100
|
+
|
|
101
|
+
def self.escape(text) = text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
102
|
+
|
|
103
|
+
# ---- backend API ------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def initialize(project:, root:, home: Dir.home, runner: Schedule.runner, uid: Process.uid)
|
|
106
|
+
@project = project
|
|
107
|
+
@root = root
|
|
108
|
+
@home = home
|
|
109
|
+
@runner = runner
|
|
110
|
+
@domain = "gui/#{uid}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def scheduler = "launchd"
|
|
114
|
+
|
|
115
|
+
def label(name) = self.class.label(@project, name)
|
|
116
|
+
|
|
117
|
+
def plist_path(name) = File.join(@home, "Library", "LaunchAgents", "#{label(name)}.plist")
|
|
118
|
+
|
|
119
|
+
def log_path(name) = File.join(@home, "Library", "Logs", "asgard", "#{label(name)}.log")
|
|
120
|
+
|
|
121
|
+
def files(spec, asgard:, direnv:)
|
|
122
|
+
name = spec[:name]
|
|
123
|
+
every = spec[:every]
|
|
124
|
+
klass = self.class
|
|
125
|
+
plist = klass.plist(
|
|
126
|
+
label: label(name),
|
|
127
|
+
arguments: Schedule.program_arguments(spec[:task], spec[:args], root: @root, asgard:, direnv:),
|
|
128
|
+
working_directory: @root,
|
|
129
|
+
environment: Schedule.environment(spec),
|
|
130
|
+
log_path: log_path(name),
|
|
131
|
+
intervals: every ? nil : klass.calendar_intervals(at: spec[:at], on: spec[:on]),
|
|
132
|
+
every:
|
|
133
|
+
)
|
|
134
|
+
{ plist_path(name) => plist }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
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
|
|
143
|
+
unload(name)
|
|
144
|
+
return :stopped if stopped?(name)
|
|
145
|
+
|
|
146
|
+
run! "launchctl", "bootstrap", @domain, plist
|
|
147
|
+
:active
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def uninstall(name)
|
|
151
|
+
unload(name)
|
|
152
|
+
run "launchctl", "enable", target(name) # clear any stop
|
|
153
|
+
FileUtils.rm_f(plist_path(name))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def start(name)
|
|
157
|
+
run! "launchctl", "enable", target(name)
|
|
158
|
+
run! "launchctl", "bootstrap", @domain, plist_path(name) unless loaded?(name)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def stop(name)
|
|
162
|
+
run! "launchctl", "disable", target(name)
|
|
163
|
+
unload(name)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def trigger(name) = run!("launchctl", "kickstart", target(name))
|
|
167
|
+
|
|
168
|
+
def installed_names
|
|
169
|
+
prefix = self.class.label_prefix(@project)
|
|
170
|
+
Dir.glob(plist_path("*")).map { |path| File.basename(path, ".plist").delete_prefix(prefix) }.sort
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# 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
|
|
179
|
+
|
|
180
|
+
# { command:, schedule: } as installed, read from the plist; nil when there isn't one.
|
|
181
|
+
def installed_columns(name)
|
|
182
|
+
path = plist_path(name)
|
|
183
|
+
return unless File.exist?(path)
|
|
184
|
+
|
|
185
|
+
Schedule.installed_columns(**self.class.parse_plist(File.read(path)))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# 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
|
|
193
|
+
|
|
194
|
+
def status(name)
|
|
195
|
+
out, ok = run("launchctl", "print", target(name))
|
|
196
|
+
return { state: stopped?(name) ? :stopped : :not_loaded, last_exit: nil } unless ok
|
|
197
|
+
|
|
198
|
+
code = out[/last exit code = (\d+)/, 1]
|
|
199
|
+
{ state: :active, last_exit: code }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def notes = []
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def target(name) = "#{@domain}/#{label(name)}"
|
|
207
|
+
|
|
208
|
+
def loaded?(name) = run("launchctl", "print", target(name)).last
|
|
209
|
+
|
|
210
|
+
def stopped?(name)
|
|
211
|
+
out, = run("launchctl", "print-disabled", @domain)
|
|
212
|
+
self.class.disabled_labels(out).include?(label(name))
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# bootout returns before the job is fully gone; bootstrapping the same
|
|
216
|
+
# label too soon fails with "Input/output error", so wait for it.
|
|
217
|
+
def unload(name)
|
|
218
|
+
return unless loaded?(name)
|
|
219
|
+
|
|
220
|
+
run "launchctl", "bootout", target(name)
|
|
221
|
+
20.times do
|
|
222
|
+
break unless loaded?(name)
|
|
223
|
+
|
|
224
|
+
sleep 0.1
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def run(*argv) = @runner.call(*argv)
|
|
229
|
+
|
|
230
|
+
def run!(*argv)
|
|
231
|
+
out, ok = run(*argv)
|
|
232
|
+
raise Error, "#{argv.join(' ')} failed: #{out.strip}" unless ok
|
|
233
|
+
|
|
234
|
+
out
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "etc"
|
|
5
|
+
|
|
6
|
+
module Asgard
|
|
7
|
+
module Schedule
|
|
8
|
+
# Linux backend: each declaration becomes a systemd user service + timer
|
|
9
|
+
# pair (~/.config/systemd/user/asgard.<project>.<name>.{service,timer}).
|
|
10
|
+
# Calendar timers are Persistent=, so a run missed while the machine was
|
|
11
|
+
# off or asleep happens on the next boot/wake. Requires systemd 240+ (for
|
|
12
|
+
# StandardOutput=append:). User timers only run while you are logged in
|
|
13
|
+
# unless lingering is enabled (`loginctl enable-linger`); #notes says so.
|
|
14
|
+
# Implements the backend API documented in declaration.rb.
|
|
15
|
+
class Systemd
|
|
16
|
+
UNIT_PREFIX = "asgard"
|
|
17
|
+
DAY_ABBREVIATIONS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze
|
|
18
|
+
|
|
19
|
+
# ---- pure helpers -----------------------------------------------------
|
|
20
|
+
|
|
21
|
+
def self.unit_prefix(project) = "#{UNIT_PREFIX}.#{Schedule.slug(project)}."
|
|
22
|
+
|
|
23
|
+
def self.unit(project, name) = "#{unit_prefix(project)}#{name}"
|
|
24
|
+
|
|
25
|
+
# { hour: 17, minute: 30, days: [1, 5] } => "Mon,Fri *-*-* 17:30:00"
|
|
26
|
+
def self.on_calendar(entry)
|
|
27
|
+
days = entry[:days]&.map { |day| DAY_ABBREVIATIONS.fetch(day) }&.join(",")
|
|
28
|
+
[days, format("*-*-* %<hour>02d:%<minute>02d:00", entry)].compact.join(" ")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# systemd expands %specifiers everywhere; % must be doubled to be literal.
|
|
32
|
+
def self.escape_specifiers(text) = text.to_s.gsub("%", "%%")
|
|
33
|
+
|
|
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
|
|
39
|
+
|
|
40
|
+
def self.service_unit(description:, arguments:, working_directory:, environment:, log_path:)
|
|
41
|
+
env_lines = environment.map { |key, value| "Environment=#{quote("#{key}=#{value}")}" }
|
|
42
|
+
log = escape_specifiers(log_path)
|
|
43
|
+
<<~UNIT
|
|
44
|
+
[Unit]
|
|
45
|
+
Description=#{escape_specifiers(description)}
|
|
46
|
+
|
|
47
|
+
[Service]
|
|
48
|
+
Type=oneshot
|
|
49
|
+
WorkingDirectory=#{escape_specifiers(working_directory)}
|
|
50
|
+
#{env_lines.join("\n")}
|
|
51
|
+
ExecStart=#{arguments.map { |arg| quote(arg) }.join(' ')}
|
|
52
|
+
StandardOutput=append:#{log}
|
|
53
|
+
StandardError=append:#{log}
|
|
54
|
+
UNIT
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# every: runs one interval after the timer starts, then one interval
|
|
58
|
+
# after each run, matching launchd's StartInterval.
|
|
59
|
+
def self.timer_unit(description:, service:, calendar: nil, every: nil)
|
|
60
|
+
schedule = if every
|
|
61
|
+
["OnActiveSec=#{every}", "OnUnitActiveSec=#{every}"]
|
|
62
|
+
else
|
|
63
|
+
[*calendar.map { |entry| "OnCalendar=#{on_calendar(entry)}" }, "Persistent=true"]
|
|
64
|
+
end
|
|
65
|
+
<<~UNIT
|
|
66
|
+
[Unit]
|
|
67
|
+
Description=#{escape_specifiers(description)}
|
|
68
|
+
|
|
69
|
+
[Timer]
|
|
70
|
+
#{schedule.join("\n")}
|
|
71
|
+
AccuracySec=1s
|
|
72
|
+
Unit=#{service}
|
|
73
|
+
|
|
74
|
+
[Install]
|
|
75
|
+
WantedBy=timers.target
|
|
76
|
+
UNIT
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ExecStart= words, undoing `quote`.
|
|
80
|
+
def self.parse_exec_start(service)
|
|
81
|
+
line = service[/^ExecStart=(.*)$/, 1].to_s
|
|
82
|
+
line.scan(/"((?:[^"\\]|\\.)*)"/).flatten.map { |word| word.gsub(/\\(.)/, '\1').gsub("$$", "$").gsub("%%", "%") }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# "Mon,Fri *-*-* 17:30:00" => { hour: 17, minute: 30, days: [1, 5] }
|
|
86
|
+
def self.parse_on_calendar(text)
|
|
87
|
+
days = text[/\A([A-Z][a-z]{2}(?:,[A-Z][a-z]{2})*) /, 1]
|
|
88
|
+
hour, minute = text.scan(/(\d+):(\d+)/).first.map(&:to_i)
|
|
89
|
+
{ hour:, minute:, days: days&.split(",")&.map { |abbr| DAY_ABBREVIATIONS.index(abbr) } }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The WorkingDirectory= (the project root) of a service unit, or nil.
|
|
93
|
+
def self.parse_directory(service) = service[/^WorkingDirectory=(.*)$/, 1]&.gsub("%%", "%")
|
|
94
|
+
|
|
95
|
+
# { calendar: [entry], every: Integer|nil } from a timer unit.
|
|
96
|
+
def self.parse_timer(timer)
|
|
97
|
+
{
|
|
98
|
+
calendar: timer.scan(/^OnCalendar=(.*)$/).flatten.map { |text| parse_on_calendar(text) },
|
|
99
|
+
every: timer[/^OnUnitActiveSec=(\d+)/, 1]&.to_i
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# `systemctl show -p A -p B` output => { "A" => "...", "B" => "..." }
|
|
104
|
+
def self.parse_show(output) = output.lines.to_h { |line| line.chomp.split("=", 2) }.reject { |k, _| k.to_s.empty? }
|
|
105
|
+
|
|
106
|
+
# ---- backend API ------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def initialize(project:, root:, home: Dir.home, runner: Schedule.runner, env: ENV, user: Etc.getpwuid(Process.uid).name)
|
|
109
|
+
@project = project
|
|
110
|
+
@root = root
|
|
111
|
+
@runner = runner
|
|
112
|
+
@user = user
|
|
113
|
+
@config = env["XDG_CONFIG_HOME"] || File.join(home, ".config")
|
|
114
|
+
@state = env["XDG_STATE_HOME"] || File.join(home, ".local", "state")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def scheduler = "systemd"
|
|
118
|
+
|
|
119
|
+
def unit(name) = self.class.unit(@project, name)
|
|
120
|
+
|
|
121
|
+
def service_path(name) = File.join(@config, "systemd", "user", "#{unit(name)}.service")
|
|
122
|
+
|
|
123
|
+
def timer_path(name) = File.join(@config, "systemd", "user", "#{unit(name)}.timer")
|
|
124
|
+
|
|
125
|
+
def log_path(name) = File.join(@state, "asgard", "#{unit(name)}.log")
|
|
126
|
+
|
|
127
|
+
def files(spec, asgard:, direnv:)
|
|
128
|
+
name, task, args, every = spec.values_at(:name, :task, :args, :every)
|
|
129
|
+
klass = self.class
|
|
130
|
+
description = "#{Schedule.command_line(task, args)} (#{@project})"
|
|
131
|
+
service = klass.service_unit(
|
|
132
|
+
description:,
|
|
133
|
+
arguments: Schedule.program_arguments(task, args, root: @root, asgard:, direnv:),
|
|
134
|
+
working_directory: @root,
|
|
135
|
+
environment: Schedule.environment(spec),
|
|
136
|
+
log_path: log_path(name)
|
|
137
|
+
)
|
|
138
|
+
timer = klass.timer_unit(
|
|
139
|
+
description:,
|
|
140
|
+
service: "#{unit(name)}.service",
|
|
141
|
+
calendar: every ? nil : Schedule.calendar(at: spec[:at], on: spec[:on]),
|
|
142
|
+
every:
|
|
143
|
+
)
|
|
144
|
+
{ service_path(name) => service, timer_path(name) => timer }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A timer that exists but is disabled was stopped on purpose; keep it so.
|
|
148
|
+
def install(spec, asgard:, direnv:)
|
|
149
|
+
name = spec[:name]
|
|
150
|
+
timer_file = timer_path(name)
|
|
151
|
+
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"
|
|
156
|
+
return :stopped if stopped
|
|
157
|
+
|
|
158
|
+
systemctl! "enable", timer_unit
|
|
159
|
+
systemctl! "restart", timer_unit # picks up a changed schedule
|
|
160
|
+
:active
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def uninstall(name)
|
|
164
|
+
service_unit = service(name)
|
|
165
|
+
systemctl "disable", "--now", timer(name)
|
|
166
|
+
systemctl "stop", service_unit
|
|
167
|
+
FileUtils.rm_f [service_path(name), timer_path(name)]
|
|
168
|
+
systemctl "daemon-reload"
|
|
169
|
+
systemctl "reset-failed", service_unit
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def start(name) = systemctl!("enable", "--now", timer(name))
|
|
173
|
+
|
|
174
|
+
def stop(name) = systemctl!("disable", "--now", timer(name))
|
|
175
|
+
|
|
176
|
+
def trigger(name) = systemctl!("start", "--no-block", service(name))
|
|
177
|
+
|
|
178
|
+
def installed_names
|
|
179
|
+
prefix = self.class.unit_prefix(@project)
|
|
180
|
+
Dir.glob(timer_path("*")).map { |path| File.basename(path, ".timer").delete_prefix(prefix) }.sort
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# 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
|
|
189
|
+
|
|
190
|
+
# { command:, schedule: } as installed, read from the unit files; nil when there are none.
|
|
191
|
+
def installed_columns(name)
|
|
192
|
+
paths = [service_path(name), timer_path(name)]
|
|
193
|
+
return unless paths.all? { |path| File.exist?(path) }
|
|
194
|
+
|
|
195
|
+
klass = self.class
|
|
196
|
+
service, timer = paths.map { |path| File.read(path) }
|
|
197
|
+
Schedule.installed_columns(arguments: klass.parse_exec_start(service), **klass.parse_timer(timer))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# 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
|
|
205
|
+
|
|
206
|
+
def status(name)
|
|
207
|
+
_, active = systemctl("is-active", "--quiet", timer(name))
|
|
208
|
+
state = if active then :active
|
|
209
|
+
elsif enabled?(name) then :not_loaded
|
|
210
|
+
else :stopped
|
|
211
|
+
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 }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def notes
|
|
220
|
+
out, ok = run("loginctl", "show-user", @user.to_s, "-p", "Linger", "--value")
|
|
221
|
+
return [] unless ok && out.strip == "no"
|
|
222
|
+
|
|
223
|
+
["systemd: user timers only run while #{@user} is logged in; run `loginctl enable-linger` to keep them running."]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def timer(name) = "#{unit(name)}.timer"
|
|
229
|
+
|
|
230
|
+
def service(name) = "#{unit(name)}.service"
|
|
231
|
+
|
|
232
|
+
def enabled?(name) = systemctl("is-enabled", "--quiet", timer(name)).last
|
|
233
|
+
|
|
234
|
+
def systemctl(*) = run("systemctl", "--user", *)
|
|
235
|
+
|
|
236
|
+
def systemctl!(*args)
|
|
237
|
+
out, ok = systemctl(*args)
|
|
238
|
+
raise Error, "systemctl --user #{args.join(' ')} failed: #{out.strip}" unless ok
|
|
239
|
+
|
|
240
|
+
out
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def run(*argv) = @runner.call(*argv)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
module Schedule
|
|
5
|
+
# The table `asgard schedule list` prints, drawn by tty-table. rows are
|
|
6
|
+
# hashes with :name, :command, :schedule, :state and :last_exit (strings).
|
|
7
|
+
# A cell that doesn't fit wraps onto extra lines; only COMMAND and SCHEDULE
|
|
8
|
+
# give up width, so the short columns always stay whole.
|
|
9
|
+
class Table
|
|
10
|
+
HEADERS = ["NAME", "COMMAND", "SCHEDULE", "STATE", "LAST EXIT"].freeze
|
|
11
|
+
FLEXIBLE = [1, 2].freeze
|
|
12
|
+
MIN_WIDTH = 14
|
|
13
|
+
EXTRA = 4 # a space either side of a cell, plus slack for tty-table's rounding
|
|
14
|
+
BORDERS = HEADERS.size + 1 # one before each column and one after the last
|
|
15
|
+
|
|
16
|
+
# Columns available for output: the terminal's when io is one, else
|
|
17
|
+
# $COLUMNS, else a roomy default (piped output shouldn't wrap at 80).
|
|
18
|
+
def self.terminal_width(io = $stdout, env: ENV, default: 120)
|
|
19
|
+
return Integer(env["COLUMNS"], exception: false) || default unless io.tty?
|
|
20
|
+
|
|
21
|
+
require "tty-screen"
|
|
22
|
+
TTY::Screen.width
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# rows rendered for the terminal, under title when given: colored when io
|
|
26
|
+
# is a tty and NO_COLOR is unset.
|
|
27
|
+
def self.draw(rows, title: nil, io: $stdout)
|
|
28
|
+
table = new(rows, color: io.tty? && !ENV.key?("NO_COLOR"), width: terminal_width(io)).render
|
|
29
|
+
[title, table].compact.join("\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# color: styles the header, state and last exit with ANSI codes.
|
|
33
|
+
def initialize(rows, color:, width:)
|
|
34
|
+
require "pastel"
|
|
35
|
+
|
|
36
|
+
@cells = rows.map { |row| row.values_at(:name, :command, :schedule, :state, :last_exit) }
|
|
37
|
+
@pastel = Pastel.new(enabled: color)
|
|
38
|
+
@width = width
|
|
39
|
+
@column_widths = natural_widths
|
|
40
|
+
shrink_widest while overflowing? && shrinkable?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render
|
|
44
|
+
require "tty-table"
|
|
45
|
+
|
|
46
|
+
grid = TTY::Table.new(header: HEADERS.map { |text| @pastel.bold(text) }, rows: @cells.map { |cells| styled(cells) })
|
|
47
|
+
grid.render(:unicode, multiline: true, resize: true, width: total_width, column_widths:, padding: [0, 1]).to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Width of each column, padding included. Every column starts at its
|
|
51
|
+
# natural width; when the table would be wider than the screen, the
|
|
52
|
+
# flexible columns give up a character at a time until it fits.
|
|
53
|
+
attr_reader :column_widths
|
|
54
|
+
|
|
55
|
+
# Rendered width: the columns plus their borders.
|
|
56
|
+
def total_width = column_widths.sum + BORDERS
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def natural_widths = [HEADERS, *@cells].transpose.map { |column| column.max_by(&:length).length + EXTRA }
|
|
61
|
+
|
|
62
|
+
def overflowing? = total_width > @width
|
|
63
|
+
|
|
64
|
+
def widest_flexible = FLEXIBLE.max_by { |index| @column_widths[index] }
|
|
65
|
+
|
|
66
|
+
def shrinkable? = @column_widths[widest_flexible] > MIN_WIDTH
|
|
67
|
+
|
|
68
|
+
def shrink_widest = @column_widths[widest_flexible] -= 1
|
|
69
|
+
|
|
70
|
+
def styled(cells)
|
|
71
|
+
*lead, state, last_exit = cells
|
|
72
|
+
state_color = { "active" => :green, "stopped" => :yellow }.fetch(state, :red)
|
|
73
|
+
exit_color = last_exit.match?(/\A[1-9]\d*\z/) ? :red : :dim
|
|
74
|
+
[*lead, @pastel.decorate(state, state_color), @pastel.decorate(last_exit, exit_color)]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "schedule/declaration"
|
|
4
|
+
require_relative "schedule/launchd"
|
|
5
|
+
require_relative "schedule/systemd"
|
|
6
|
+
require_relative "schedule/table"
|
|
7
|
+
require_relative "schedule/commands"
|
|
8
|
+
|
|
9
|
+
module Asgard
|
|
10
|
+
# Runs asgard tasks periodically under the platform's own scheduler.
|
|
11
|
+
# Declare entries at class level in a .loki file:
|
|
12
|
+
#
|
|
13
|
+
# class Tasks
|
|
14
|
+
# schedule :daily_summary, at: "17:30", on: :weekdays
|
|
15
|
+
# schedule :sync, every: 3600
|
|
16
|
+
# schedule :report, options: "--period week", at: "16:00", on: :friday
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# then manage them with `asgard schedule install|list|start|stop|...`.
|
|
20
|
+
module Schedule
|
|
21
|
+
class << self
|
|
22
|
+
# Declared entries for this run, keyed by entry name.
|
|
23
|
+
def declarations
|
|
24
|
+
@declarations ||= {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Records one declaration. Redeclaring an identical entry is a no-op
|
|
28
|
+
# (a .loki file loaded twice); a different entry under the same name
|
|
29
|
+
# is an error.
|
|
30
|
+
def declare(task, **settings)
|
|
31
|
+
spec = normalize(task, **settings)
|
|
32
|
+
name = spec[:name]
|
|
33
|
+
taken = declarations[name]
|
|
34
|
+
raise ArgumentError, "schedule: #{name.inspect} is already declared; give one a distinct as: name" if taken && taken != spec
|
|
35
|
+
|
|
36
|
+
declarations[name] = spec
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The class-level `schedule` declaration helper for Tasks.
|
|
41
|
+
module DSL
|
|
42
|
+
def schedule(task, **) = Schedule.declare(task, **)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/asgard/tasks.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "schedule"
|
|
4
|
+
|
|
3
5
|
# Tasks is the single conventional entry point for all .loki files.
|
|
4
6
|
# It is pre-defined by the gem so .loki files never need to declare a class.
|
|
5
7
|
# Auxiliary *.loki files define modules which are imported into Tasks.
|
|
@@ -32,4 +34,14 @@ class Tasks < Asgard::Base
|
|
|
32
34
|
default: false,
|
|
33
35
|
desc: "Diagnose .loki resolution, imports, and task definitions for the CWD, then exit"
|
|
34
36
|
no_negate :doctor
|
|
37
|
+
|
|
38
|
+
# Class-level `schedule :task, at: "HH:MM"` declarations (see Asgard::Schedule).
|
|
39
|
+
extend Asgard::Schedule::DSL
|
|
40
|
+
|
|
41
|
+
# Gem-owned, so the command is _schedule; the map keeps `asgard schedule`
|
|
42
|
+
# as the name users type, and ancestor_name keeps it in subcommand help.
|
|
43
|
+
desc "schedule SUBCOMMAND", "Manage schedules declared with `schedule :task, ...` (launchd on macOS, systemd on Linux)"
|
|
44
|
+
subcommand "_schedule", Asgard::Schedule::Commands
|
|
45
|
+
map "schedule" => :_schedule
|
|
46
|
+
Asgard::Schedule::Commands.commands.each_value { |command| command.ancestor_name = "schedule" }
|
|
35
47
|
end
|
data/lib/asgard/version.rb
CHANGED
data/lib/asgard.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "asgard/errors"
|
|
3
4
|
require_relative "asgard/version"
|
|
4
5
|
require_relative "asgard/kernel_methods"
|
|
5
6
|
require_relative "asgard/shell"
|
|
@@ -8,15 +9,16 @@ require_relative "asgard/tasks"
|
|
|
8
9
|
require_relative "asgard/doctor"
|
|
9
10
|
|
|
10
11
|
module Asgard
|
|
11
|
-
class Error < StandardError; end
|
|
12
|
-
class CircularDependencyError < Error; end
|
|
13
|
-
|
|
14
12
|
# Search the current directory and its ancestors for a .loki task file.
|
|
15
13
|
# Returns the path string, or nil if not found.
|
|
16
14
|
def self.find_task_file
|
|
17
15
|
loki_up
|
|
18
16
|
end
|
|
19
17
|
|
|
18
|
+
# `asgard schedule list` reports every project's scheduled entries, so it
|
|
19
|
+
# works from a directory with no .loki above it.
|
|
20
|
+
def self.machine_wide?(argv) = argv.first(2) == %w[schedule list]
|
|
21
|
+
|
|
20
22
|
# Main entry point invoked by the asgard executable.
|
|
21
23
|
def self.run!(argv)
|
|
22
24
|
first = argv.first
|
|
@@ -29,13 +31,19 @@ module Asgard
|
|
|
29
31
|
Asgard::Doctor.new.run
|
|
30
32
|
exit
|
|
31
33
|
end
|
|
32
|
-
task_file = find_task_file
|
|
34
|
+
task_file = find_task_file
|
|
35
|
+
abort "asgard: no .loki file found in #{Dir.pwd}" unless task_file || machine_wide?(argv)
|
|
33
36
|
before = Asgard::Base.subclasses.dup
|
|
34
|
-
load task_file
|
|
37
|
+
load task_file if task_file
|
|
35
38
|
newly_defined = Asgard::Base.subclasses - before
|
|
36
39
|
(newly_defined + [Tasks]).uniq.each(&:validate_deps!)
|
|
37
40
|
Tasks._reset_ran!
|
|
38
|
-
Tasks.start(argv)
|
|
41
|
+
result = Tasks.start(argv)
|
|
42
|
+
# Quality-gate convention: a task signals failure by returning :fail
|
|
43
|
+
# (see dev/quality.loki's *_check tasks). Surface that as a nonzero
|
|
44
|
+
# exit so callers (CI, cross-repo runners) can rely on $?.
|
|
45
|
+
exit(1) if result == :fail
|
|
46
|
+
result
|
|
39
47
|
rescue CircularDependencyError => e
|
|
40
48
|
abort "asgard: circular dependency — #{e.message}"
|
|
41
49
|
rescue Error => e
|