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.
@@ -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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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