asgard 0.3.2 → 0.4.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/.loki +7 -1
- data/.reek.yml +42 -0
- data/CHANGELOG.md +60 -4
- data/CLAUDE.md +8 -2
- data/README.md +77 -2
- data/doc_tasks.loki +21 -0
- data/docs/api.md +28 -5
- data/docs/changelog.md +1 -132
- data/docs/dependencies.md +18 -7
- data/docs/index.md +1 -0
- data/docs/schedule.md +101 -0
- data/docs/shell.md +30 -1
- data/docs/tasks.md +40 -0
- data/examples/depends_on_block/bad/.loki +51 -0
- data/examples/depends_on_block/good/.loki +83 -0
- data/examples/kitchen_sink.loki +13 -0
- data/lib/asgard/base/dependency_graph.rb +43 -7
- data/lib/asgard/base/task_dsl.rb +1 -2
- data/lib/asgard/errors.rb +7 -0
- data/lib/asgard/schedule/commands.rb +174 -0
- data/lib/asgard/schedule/declaration.rb +176 -0
- data/lib/asgard/schedule/launchd.rb +183 -0
- data/lib/asgard/schedule/systemd.rb +199 -0
- data/lib/asgard/schedule.rb +44 -0
- data/lib/asgard/shell.rb +21 -8
- data/lib/asgard/tasks.rb +12 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard.rb +9 -4
- data/mkdocs.yml +1 -0
- data/quality.loki +7 -2
- data/quality_rails.loki +3 -2
- metadata +14 -3
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
# warn is silent when $VERBOSE is nil and bypasses $stderr in Ruby 4.0 (see
|
|
7
|
+
# base/task_dsl.rb); these messages are for the user, so they always print.
|
|
8
|
+
# rubocop:disable Style/StderrPuts
|
|
9
|
+
|
|
10
|
+
module Asgard
|
|
11
|
+
module Schedule
|
|
12
|
+
# `asgard schedule SUBCOMMAND` — installs and manages the entries declared
|
|
13
|
+
# with `schedule :task, ...` in a .loki file. Subclasses Asgard::Base
|
|
14
|
+
# rather than Tasks so `asgard schedule help` lists only these
|
|
15
|
+
# subcommands, not every project task.
|
|
16
|
+
class Commands < Asgard::Base
|
|
17
|
+
namespace "schedule"
|
|
18
|
+
|
|
19
|
+
desc "tree", "Print a tree of the schedule subcommands", hide: true
|
|
20
|
+
# Redefined only to re-register tree as hidden in this namespace.
|
|
21
|
+
def tree = super # rubocop:disable Lint/UselessMethodDefinition
|
|
22
|
+
|
|
23
|
+
no_commands do
|
|
24
|
+
def schedules = Schedule.declarations
|
|
25
|
+
|
|
26
|
+
def schedule_root = (Asgard.find_task_file&.dirname || Pathname.pwd).to_s
|
|
27
|
+
|
|
28
|
+
def schedule_project = File.basename(schedule_root)
|
|
29
|
+
|
|
30
|
+
def scheduler
|
|
31
|
+
@scheduler ||= Schedule.backend_class.new(project: schedule_project, root: schedule_root)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def declared_schedules
|
|
35
|
+
if schedules.empty?
|
|
36
|
+
abort "No schedules declared. Add `schedule :task, at: \"HH:MM\"` inside class Tasks in #{schedule_root}/.loki."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
specs = schedules.values
|
|
40
|
+
unknown = specs.map { |spec| spec[:task] }.uniq - Tasks.all_commands.keys
|
|
41
|
+
abort "schedule: no such task(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
42
|
+
|
|
43
|
+
specs
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def schedule_direnv
|
|
47
|
+
return unless File.exist?(File.join(schedule_root, ".envrc"))
|
|
48
|
+
|
|
49
|
+
Schedule.which("direnv", ENV.fetch("PATH", nil)) or
|
|
50
|
+
$stderr.puts "schedule: .envrc found but direnv is not on PATH; its variables will not be loaded"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def schedule_asgard = Schedule.which("asgard", ENV.fetch("PATH", nil)) || abort("schedule: asgard is not on PATH")
|
|
54
|
+
|
|
55
|
+
def schedule_summary(spec) = "#{Schedule.command_line(spec[:task], spec[:args])} (#{Schedule.describe(**spec)})"
|
|
56
|
+
|
|
57
|
+
def schedule_install(spec, asgard:, direnv:)
|
|
58
|
+
name = spec[:name]
|
|
59
|
+
state = scheduler.install(spec, asgard:, direnv:)
|
|
60
|
+
note = state == :stopped ? " [stopped; `asgard schedule start #{name}` to resume]" : ""
|
|
61
|
+
puts "installed #{name}: #{schedule_summary(spec)}#{note}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def schedule_uninstall(name)
|
|
65
|
+
scheduler.uninstall(name)
|
|
66
|
+
puts "removed #{name}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def require_installed!(name)
|
|
70
|
+
abort "#{name} is not installed (see `asgard schedule list`)." unless scheduler.installed_names.include?(name)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def require_known!(name)
|
|
74
|
+
return if schedules.key?(name) || scheduler.installed_names.include?(name)
|
|
75
|
+
|
|
76
|
+
abort "#{name} is neither declared nor installed (see `asgard schedule list`)."
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
desc "preview", "Show the job files install would write, without installing them"
|
|
81
|
+
def preview
|
|
82
|
+
asgard = Schedule.which("asgard", ENV.fetch("PATH", nil)) || "asgard"
|
|
83
|
+
direnv = schedule_direnv
|
|
84
|
+
declared_schedules.each do |spec|
|
|
85
|
+
scheduler.files(spec, asgard:, direnv:).each do |path, content|
|
|
86
|
+
puts "# #{path}: #{schedule_summary(spec)}"
|
|
87
|
+
puts content
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
desc "install", "Install this project's schedule declarations (removes undeclared ones)"
|
|
93
|
+
def install
|
|
94
|
+
specs = declared_schedules
|
|
95
|
+
asgard = schedule_asgard
|
|
96
|
+
direnv = schedule_direnv
|
|
97
|
+
|
|
98
|
+
(scheduler.installed_names - specs.map { |spec| spec[:name] }).each { |name| schedule_uninstall(name) }
|
|
99
|
+
specs.each { |spec| schedule_install(spec, asgard:, direnv:) }
|
|
100
|
+
scheduler.notes.each { |note| $stderr.puts note }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
desc "list", "Show this project's installed entries, their state, and last exit status"
|
|
104
|
+
def list
|
|
105
|
+
names = scheduler.installed_names
|
|
106
|
+
return puts "No scheduled tasks installed for #{schedule_project} (#{scheduler.scheduler})." if names.empty?
|
|
107
|
+
|
|
108
|
+
names.each do |name|
|
|
109
|
+
status = scheduler.status(name)
|
|
110
|
+
state = case status[:state]
|
|
111
|
+
when :active then "active; last exit: #{status[:last_exit] || 'never run'}"
|
|
112
|
+
when :stopped then "stopped"
|
|
113
|
+
else "not loaded"
|
|
114
|
+
end
|
|
115
|
+
spec = schedules[name]
|
|
116
|
+
timing = spec ? schedule_summary(spec) : "no longer declared"
|
|
117
|
+
puts "#{name} #{timing} [#{state}] log: #{scheduler.log_path(name)}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
desc "stop NAME", "Stop one scheduled entry; it stays stopped across reboots and installs until started"
|
|
122
|
+
def stop(name)
|
|
123
|
+
require_installed!(name)
|
|
124
|
+
scheduler.stop(name)
|
|
125
|
+
puts "stopped #{name}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
desc "start NAME", "Start a stopped entry, or install and start just this declared entry"
|
|
129
|
+
def start(name)
|
|
130
|
+
require_known!(name)
|
|
131
|
+
spec = schedules[name]
|
|
132
|
+
|
|
133
|
+
if spec
|
|
134
|
+
declared_schedules # validates the task exists
|
|
135
|
+
scheduler.install(spec, asgard: schedule_asgard, direnv: schedule_direnv) # refresh the job files
|
|
136
|
+
else
|
|
137
|
+
$stderr.puts "schedule: #{name} is no longer declared; starting its installed job as-is"
|
|
138
|
+
end
|
|
139
|
+
scheduler.start(name)
|
|
140
|
+
puts "started #{name}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
desc "trigger NAME", "Run an installed entry now, under the scheduler's environment"
|
|
144
|
+
def trigger(name)
|
|
145
|
+
require_installed!(name)
|
|
146
|
+
abort "#{name} is stopped; `asgard schedule start #{name}` first." if scheduler.status(name)[:state] == :stopped
|
|
147
|
+
|
|
148
|
+
scheduler.trigger(name)
|
|
149
|
+
puts "triggered #{name}; output goes to #{scheduler.log_path(name)}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
desc "log NAME", "Print the named entry's log file to STDOUT"
|
|
153
|
+
method_option :follow, aliases: "-f", type: :boolean, desc: "Keep printing new output as it arrives (tail -f)"
|
|
154
|
+
def log(name)
|
|
155
|
+
require_known!(name)
|
|
156
|
+
|
|
157
|
+
path = scheduler.log_path(name)
|
|
158
|
+
return $stderr.puts "#{name} has no log yet (#{path}); it is created on the first run." unless File.exist?(path)
|
|
159
|
+
return sh("tail -n +1 -f #{path.shellescape}", silent: true, exec: true) if options[:follow]
|
|
160
|
+
|
|
161
|
+
IO.copy_stream(path, $stdout)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
desc "remove", "Remove all of this project's installed entries"
|
|
165
|
+
def remove
|
|
166
|
+
names = scheduler.installed_names
|
|
167
|
+
return puts "No scheduled tasks installed for #{schedule_project}." if names.empty?
|
|
168
|
+
|
|
169
|
+
names.each { |name| schedule_uninstall(name) }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
# rubocop:enable Style/StderrPuts
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Asgard
|
|
7
|
+
# Scheduled asgard tasks, run by the platform's own scheduler: launchd on
|
|
8
|
+
# macOS (Schedule::Launchd), systemd user timers on Linux
|
|
9
|
+
# (Schedule::Systemd). Both run a calendar job missed while the machine
|
|
10
|
+
# slept as soon as it wakes.
|
|
11
|
+
#
|
|
12
|
+
# This file is the platform-neutral half: it validates `schedule`
|
|
13
|
+
# declarations and builds the command a job runs. Everything here is pure,
|
|
14
|
+
# so each method can be tested on its own.
|
|
15
|
+
#
|
|
16
|
+
# Every backend implements the same instance API, which is all
|
|
17
|
+
# Schedule::Commands uses:
|
|
18
|
+
#
|
|
19
|
+
# Backend.new(project:, root:, home: Dir.home, runner: Schedule.runner)
|
|
20
|
+
# #scheduler # => "launchd" / "systemd"
|
|
21
|
+
# #files(spec, asgard:, direnv:) # => { path => content } install would write
|
|
22
|
+
# #install(spec, asgard:, direnv:) # write + load; => :active, or :stopped if stopped earlier
|
|
23
|
+
# #uninstall(name) # unload, clear any stop, delete files
|
|
24
|
+
# #start(name) / #stop(name) # stop persists across reboots and reinstalls
|
|
25
|
+
# #trigger(name) # run once now, under the scheduler
|
|
26
|
+
# #installed_names # => ["demo", ...] for this project
|
|
27
|
+
# #status(name) # => { state: :active|:stopped|:not_loaded, last_exit: String|nil }
|
|
28
|
+
# #log_path(name) # => path the job's output is appended to
|
|
29
|
+
# #notes # => [String] platform hints to show after install
|
|
30
|
+
module Schedule
|
|
31
|
+
# A scheduler command (launchctl, systemctl) failed.
|
|
32
|
+
class Error < Asgard::Error; end
|
|
33
|
+
|
|
34
|
+
# Weekday numbers follow launchd and cron: 0 (Sunday) through 6 (Saturday).
|
|
35
|
+
DAYS = %i[sunday monday tuesday wednesday thursday friday saturday].freeze
|
|
36
|
+
|
|
37
|
+
DAY_GROUPS = {
|
|
38
|
+
weekdays: %i[monday tuesday wednesday thursday friday],
|
|
39
|
+
weekends: %i[saturday sunday]
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Default command runner for the backends: argv in, [output, success?] out.
|
|
43
|
+
RUNNER = lambda do |*argv|
|
|
44
|
+
out, status = Open3.capture2e(*argv)
|
|
45
|
+
[out, status.success?]
|
|
46
|
+
rescue SystemCallError => e
|
|
47
|
+
[e.message, false]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# The runner new backends get. Tests assign one that records commands
|
|
52
|
+
# instead of running them; nil restores RUNNER.
|
|
53
|
+
attr_writer :runner
|
|
54
|
+
|
|
55
|
+
def runner = @runner || RUNNER
|
|
56
|
+
|
|
57
|
+
# The backend class for +platform+ (a RUBY_PLATFORM string).
|
|
58
|
+
def backend_class(platform = RUBY_PLATFORM)
|
|
59
|
+
case platform
|
|
60
|
+
when /darwin/ then Launchd
|
|
61
|
+
when /linux/ then Systemd
|
|
62
|
+
else raise Error, "#{platform} is not supported (needs macOS launchd or Linux systemd)"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
# Validates a declaration and returns it as a plain Hash. options: is the
|
|
70
|
+
# task's command-line options as a String, split shell-style with quotes
|
|
71
|
+
# respected ("--period week --title 'Week End'"), or an Array of words.
|
|
72
|
+
# Exactly one of at: ("HH:MM" or an Array of them, with on:) or every:
|
|
73
|
+
# (seconds or a Duration) is required. as: names the entry.
|
|
74
|
+
def normalize(task, options: nil, at: nil, on: :daily, every: nil, env: {}, as: nil)
|
|
75
|
+
task = task.to_s
|
|
76
|
+
unless task.match?(/\A[\w:-]+\z/)
|
|
77
|
+
raise ArgumentError,
|
|
78
|
+
"schedule: task name #{task.inspect} must be a single word; put its flags in options:"
|
|
79
|
+
end
|
|
80
|
+
raise ArgumentError, "schedule :#{task} needs either at: or every:, not both" unless at.nil? ^ every.nil?
|
|
81
|
+
|
|
82
|
+
if every
|
|
83
|
+
every = seconds(every)
|
|
84
|
+
unless every&.positive?
|
|
85
|
+
raise ArgumentError,
|
|
86
|
+
"schedule :#{task} every: must be a positive number of seconds or a Duration (3.minutes)"
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
calendar(at:, on:) # raises on a bad time or day
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
args = options.is_a?(Array) ? options.map(&:to_s) : Shellwords.split(options.to_s)
|
|
93
|
+
{ name: entry_name(task, args, as), task:, args:, at:, on:, every:, env: env.to_h { |key, value| [key.to_s, value.to_s] } }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Integer seconds from an Integer or anything Duration-like (ActiveSupport's
|
|
97
|
+
# 3.minutes responds to in_seconds); nil for anything else.
|
|
98
|
+
def seconds(value)
|
|
99
|
+
return value if value.is_a?(Integer)
|
|
100
|
+
|
|
101
|
+
value.respond_to?(:in_seconds) ? value.in_seconds.to_i : nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The task alone, or a slug of the whole command when it has arguments,
|
|
105
|
+
# so one task can be scheduled more than once with different flags.
|
|
106
|
+
def entry_name(task, args, as = nil)
|
|
107
|
+
name = (as || (args.empty? ? task : slug([task, *args].join(" ")))).to_s
|
|
108
|
+
raise ArgumentError, "schedule as: #{name.inspect} may only contain letters, digits, _ . -" unless name.match?(/\A[\w.-]+\z/)
|
|
109
|
+
|
|
110
|
+
name
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def slug(name) = name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
|
|
114
|
+
|
|
115
|
+
# For display: the command line the job runs.
|
|
116
|
+
def command_line(task, args = []) = Shellwords.join(["asgard", task.to_s, *args])
|
|
117
|
+
|
|
118
|
+
def describe(at: nil, on: :daily, every: nil, **)
|
|
119
|
+
return "every #{every}s" if every
|
|
120
|
+
|
|
121
|
+
"#{Array(at).join(', ')} #{Array(on).join(', ')}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# "17:30" => [17, 30]
|
|
125
|
+
def parse_time(time)
|
|
126
|
+
shown = time.inspect
|
|
127
|
+
match = /\A(\d{1,2}):(\d{2})\z/.match(time.to_s) or
|
|
128
|
+
raise ArgumentError, %(at: expects "HH:MM" (got #{shown}))
|
|
129
|
+
hour = match[1].to_i
|
|
130
|
+
minute = match[2].to_i
|
|
131
|
+
raise ArgumentError, "at: #{shown} is not a valid time" unless hour <= 23 && minute <= 59
|
|
132
|
+
|
|
133
|
+
[hour, minute]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# :daily => nil (every day); :weekdays => [1, 2, 3, 4, 5]; :friday => [5];
|
|
137
|
+
# %i[monday thursday] => [1, 4]
|
|
138
|
+
def weekdays(on)
|
|
139
|
+
return nil if on.to_s == "daily"
|
|
140
|
+
|
|
141
|
+
names = DAY_GROUPS.fetch(on.is_a?(Array) ? nil : on.to_sym) { Array(on) }
|
|
142
|
+
names.map do |day|
|
|
143
|
+
DAYS.index(day.to_sym) or raise ArgumentError, "on: unknown day #{day.inspect}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# One entry per time: [{ hour: 17, minute: 30, days: [1, 2, 3, 4, 5] }];
|
|
148
|
+
# days is nil for every day.
|
|
149
|
+
def calendar(at:, on: :daily)
|
|
150
|
+
days = weekdays(on)
|
|
151
|
+
Array(at).map do |time|
|
|
152
|
+
hour, minute = parse_time(time)
|
|
153
|
+
{ hour:, minute:, days: }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# PATH captured at install time, plus the declaration's env:.
|
|
158
|
+
def environment(spec, path = ENV.fetch("PATH", nil)) = { "PATH" => path.to_s }.merge(spec[:env])
|
|
159
|
+
|
|
160
|
+
# First executable named +command+ on +path+ (a PATH-style String), or nil.
|
|
161
|
+
def which(command, path)
|
|
162
|
+
path.to_s.split(File::PATH_SEPARATOR)
|
|
163
|
+
.map { |dir| File.join(dir, command) }
|
|
164
|
+
.find { |file| File.file?(file) && File.executable?(file) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The job's argv. Schedulers need an absolute program. With direnv, the
|
|
168
|
+
# repo's .envrc (API keys, ...) is loaded at run time instead of being
|
|
169
|
+
# copied into the job definition, and direnv finds asgard on the job's
|
|
170
|
+
# PATH. Each argument is its own element, so no shell re-splits them.
|
|
171
|
+
def program_arguments(task, args = [], root:, asgard:, direnv: nil)
|
|
172
|
+
argv = [task.to_s, *args.map(&:to_s)]
|
|
173
|
+
direnv ? [direnv, "exec", root, "asgard", *argv] : [asgard, *argv]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
def self.escape(text) = text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
68
|
+
|
|
69
|
+
# ---- backend API ------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def initialize(project:, root:, home: Dir.home, runner: Schedule.runner, uid: Process.uid)
|
|
72
|
+
@project = project
|
|
73
|
+
@root = root
|
|
74
|
+
@home = home
|
|
75
|
+
@runner = runner
|
|
76
|
+
@domain = "gui/#{uid}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def scheduler = "launchd"
|
|
80
|
+
|
|
81
|
+
def label(name) = self.class.label(@project, name)
|
|
82
|
+
|
|
83
|
+
def plist_path(name) = File.join(@home, "Library", "LaunchAgents", "#{label(name)}.plist")
|
|
84
|
+
|
|
85
|
+
def log_path(name) = File.join(@home, "Library", "Logs", "asgard", "#{label(name)}.log")
|
|
86
|
+
|
|
87
|
+
def files(spec, asgard:, direnv:)
|
|
88
|
+
name = spec[:name]
|
|
89
|
+
every = spec[:every]
|
|
90
|
+
klass = self.class
|
|
91
|
+
plist = klass.plist(
|
|
92
|
+
label: label(name),
|
|
93
|
+
arguments: Schedule.program_arguments(spec[:task], spec[:args], root: @root, asgard:, direnv:),
|
|
94
|
+
working_directory: @root,
|
|
95
|
+
environment: Schedule.environment(spec),
|
|
96
|
+
log_path: log_path(name),
|
|
97
|
+
intervals: every ? nil : klass.calendar_intervals(at: spec[:at], on: spec[:on]),
|
|
98
|
+
every:
|
|
99
|
+
)
|
|
100
|
+
{ plist_path(name) => plist }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def install(spec, asgard:, direnv:)
|
|
104
|
+
name = spec[:name]
|
|
105
|
+
plist = plist_path(name)
|
|
106
|
+
FileUtils.mkdir_p [File.dirname(plist), File.dirname(log_path(name))]
|
|
107
|
+
files(spec, asgard:, direnv:).each { |path, content| File.write(path, content) }
|
|
108
|
+
run! "plutil", "-lint", "-s", plist
|
|
109
|
+
unload(name)
|
|
110
|
+
return :stopped if stopped?(name)
|
|
111
|
+
|
|
112
|
+
run! "launchctl", "bootstrap", @domain, plist
|
|
113
|
+
:active
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def uninstall(name)
|
|
117
|
+
unload(name)
|
|
118
|
+
run "launchctl", "enable", target(name) # clear any stop
|
|
119
|
+
FileUtils.rm_f(plist_path(name))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def start(name)
|
|
123
|
+
run! "launchctl", "enable", target(name)
|
|
124
|
+
run! "launchctl", "bootstrap", @domain, plist_path(name) unless loaded?(name)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def stop(name)
|
|
128
|
+
run! "launchctl", "disable", target(name)
|
|
129
|
+
unload(name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def trigger(name) = run!("launchctl", "kickstart", target(name))
|
|
133
|
+
|
|
134
|
+
def installed_names
|
|
135
|
+
prefix = self.class.label_prefix(@project)
|
|
136
|
+
Dir.glob(plist_path("*")).map { |path| File.basename(path, ".plist").delete_prefix(prefix) }.sort
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def status(name)
|
|
140
|
+
out, ok = run("launchctl", "print", target(name))
|
|
141
|
+
return { state: stopped?(name) ? :stopped : :not_loaded, last_exit: nil } unless ok
|
|
142
|
+
|
|
143
|
+
code = out[/last exit code = (\d+)/, 1]
|
|
144
|
+
{ state: :active, last_exit: code }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def notes = []
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def target(name) = "#{@domain}/#{label(name)}"
|
|
152
|
+
|
|
153
|
+
def loaded?(name) = run("launchctl", "print", target(name)).last
|
|
154
|
+
|
|
155
|
+
def stopped?(name)
|
|
156
|
+
out, = run("launchctl", "print-disabled", @domain)
|
|
157
|
+
self.class.disabled_labels(out).include?(label(name))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# bootout returns before the job is fully gone; bootstrapping the same
|
|
161
|
+
# label too soon fails with "Input/output error", so wait for it.
|
|
162
|
+
def unload(name)
|
|
163
|
+
return unless loaded?(name)
|
|
164
|
+
|
|
165
|
+
run "launchctl", "bootout", target(name)
|
|
166
|
+
20.times do
|
|
167
|
+
break unless loaded?(name)
|
|
168
|
+
|
|
169
|
+
sleep 0.1
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def run(*argv) = @runner.call(*argv)
|
|
174
|
+
|
|
175
|
+
def run!(*argv)
|
|
176
|
+
out, ok = run(*argv)
|
|
177
|
+
raise Error, "#{argv.join(' ')} failed: #{out.strip}" unless ok
|
|
178
|
+
|
|
179
|
+
out
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|