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.
@@ -0,0 +1,240 @@
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
+ # One `list` row for this project: its declaration (if still declared) plus live state.
58
+ def schedule_row(name)
59
+ { name:, **Schedule.declared_columns(schedules[name], "no longer declared"), **Schedule.live_columns(scheduler, name) }
60
+ end
61
+
62
+ def schedule_install(spec, asgard:, direnv:)
63
+ name = spec[:name]
64
+ state = scheduler.install(spec, asgard:, direnv:)
65
+ note = state == :stopped ? " [stopped; `asgard schedule start #{name}` to resume]" : ""
66
+ puts "installed #{name}: #{schedule_summary(spec)}#{note}"
67
+ end
68
+
69
+ def schedule_uninstall(name)
70
+ scheduler.uninstall(name)
71
+ puts "removed #{name}"
72
+ end
73
+ end
74
+
75
+ desc "preview", "Show the job files install would write, without installing them"
76
+ def preview
77
+ asgard = Schedule.which("asgard", ENV.fetch("PATH", nil)) || "asgard"
78
+ direnv = schedule_direnv
79
+ declared_schedules.each do |spec|
80
+ scheduler.files(spec, asgard:, direnv:).each do |path, content|
81
+ puts "# #{path}: #{schedule_summary(spec)}"
82
+ puts content
83
+ end
84
+ end
85
+ end
86
+
87
+ desc "install", "Install this project's schedule declarations (removes undeclared ones)"
88
+ def install
89
+ specs = declared_schedules
90
+ asgard = schedule_asgard
91
+ direnv = schedule_direnv
92
+
93
+ (scheduler.installed_names - specs.map { |spec| spec[:name] }).each { |name| schedule_uninstall(name) }
94
+ specs.each { |spec| schedule_install(spec, asgard:, direnv:) }
95
+ scheduler.notes.each { |note| $stderr.puts note }
96
+ end
97
+
98
+ no_commands do
99
+ # [backend, entry name] for an installed entry of this or any project: a
100
+ # bare name, or project/name when several projects use the same one. nil if none.
101
+ def find_installed(name)
102
+ return [scheduler, name] if scheduler.installed_names.include?(name)
103
+
104
+ project, entry = name.include?("/") ? name.split("/", 2) : [nil, name]
105
+ matches = scheduler.installed_entries.select { |slug, found| found == entry && (project.nil? || slug == project) }
106
+ abort "#{name} is in several projects (#{matches.map(&:first).join(', ')}); name one as project/#{entry}." if matches.size > 1
107
+
108
+ matches.first&.then { |slug, found| [backend_for(slug), found] }
109
+ end
110
+
111
+ def resolve_installed(name) = find_installed(name) || abort("#{name} is not installed (see `asgard schedule list --all`).")
112
+
113
+ # Like resolve_installed, but a declared entry of this project counts even before it is installed.
114
+ def resolve_known(name)
115
+ return [scheduler, name] if schedules.key?(name)
116
+
117
+ find_installed(name) || abort("#{name} is neither declared nor installed (see `asgard schedule list --all`).")
118
+ end
119
+
120
+ # True when list should cover every project: asked for with --all, or
121
+ # there is no .loki here, so no single project to scope to.
122
+ def list_all? = options[:all] || Asgard.find_task_file.nil?
123
+
124
+ # What `list` says when there is nothing to show; a project-scoped list
125
+ # also tells the user how to look further.
126
+ def no_schedules_message
127
+ backend = scheduler.scheduler
128
+ return "No scheduled tasks installed (#{backend})." if list_all?
129
+
130
+ "No scheduled tasks installed for #{schedule_project} (#{backend}).\n" \
131
+ "Use `asgard schedule list --all` to see all currently scheduled tasks."
132
+ end
133
+
134
+ # The rows to list, grouped under a project title: { "Project: name (/path)" => [row, ...] }
135
+ # for every project, or { nil => [row, ...] } for this one. Empty groups drop out.
136
+ def list_groups
137
+ groups = list_all? ? all_schedule_groups : { nil => scheduler.installed_names.map { |name| schedule_row(name) } }
138
+ groups.reject { |_, rows| rows.empty? }
139
+ end
140
+
141
+ # Every installed entry on the machine, grouped by project.
142
+ def all_schedule_groups
143
+ by_project = scheduler.installed_entries.group_by(&:first)
144
+ by_project.to_h { |slug, entries| [project_title(slug, entries.first.last), entry_rows(entries)] }
145
+ end
146
+
147
+ # "Project: slug (/full/path)", the path read from one of the project's job files.
148
+ def project_title(slug, name)
149
+ dir = slug == current_slug ? schedule_root : backend_for(slug).installed_directory(name)
150
+ ["Project: #{slug}", ("(#{dir})" if dir)].compact.join(" ")
151
+ end
152
+
153
+ def current_slug = Asgard.find_task_file && Schedule.slug(schedule_project)
154
+
155
+ def backend_for(slug) = Schedule.backend_class.new(project: slug, root: schedule_root)
156
+
157
+ def entry_rows(entries) = entries.map { |slug, name| entry_row(slug, name) }
158
+
159
+ # This project's entries keep their declaration columns; see other_project_row.
160
+ def entry_row(slug, name)
161
+ return schedule_row(name) if slug == current_slug
162
+
163
+ other_project_row(slug, name)
164
+ end
165
+
166
+ # A row for an entry of another project: its .loki isn't loaded, so the
167
+ # command and schedule are read back from the installed job files.
168
+ def other_project_row(slug, name)
169
+ backend = backend_for(slug)
170
+ shown = backend.installed_columns(name) || Schedule.declared_columns(nil, "(unreadable)")
171
+ { name:, **shown, **Schedule.live_columns(backend, name) }
172
+ end
173
+ end
174
+
175
+ desc "list", "Show this project's installed entries, their state, and last exit status"
176
+ method_option :all, aliases: "-a", type: :boolean, desc: "List every project's entries, not just this one's"
177
+ def list
178
+ groups = list_groups
179
+ return puts(no_schedules_message) if groups.empty?
180
+
181
+ puts groups.map { |title, rows| Schedule::Table.draw(rows, title:) }.join("\n\n")
182
+ puts "\nLogs: #{File.dirname(scheduler.log_path(groups.values.first.first[:name]))} (asgard schedule log NAME)"
183
+ end
184
+
185
+ desc "stop NAME", "Stop one scheduled entry; it stays stopped across reboots and installs until started"
186
+ def stop(name)
187
+ backend, entry = resolve_installed(name)
188
+ backend.stop(entry)
189
+ puts "stopped #{name}"
190
+ end
191
+
192
+ desc "start NAME", "Start a stopped entry, or install and start just this declared entry"
193
+ def start(name)
194
+ backend, entry = resolve_known(name)
195
+ own = backend.equal?(scheduler)
196
+ spec = schedules[entry] if own
197
+
198
+ if spec
199
+ declared_schedules # validates the task exists
200
+ scheduler.install(spec, asgard: schedule_asgard, direnv: schedule_direnv) # refresh the job files
201
+ else
202
+ why = own ? "is no longer declared" : "belongs to another project"
203
+ $stderr.puts "schedule: #{name} #{why}; starting its installed job as-is"
204
+ end
205
+ backend.start(entry)
206
+ puts "started #{name}"
207
+ end
208
+
209
+ desc "trigger NAME", "Run an installed entry now, under the scheduler's environment"
210
+ def trigger(name)
211
+ backend, entry = resolve_installed(name)
212
+ abort "#{name} is stopped; `asgard schedule start #{name}` first." if backend.status(entry)[:state] == :stopped
213
+
214
+ backend.trigger(entry)
215
+ puts "triggered #{name}; output goes to #{backend.log_path(entry)}"
216
+ end
217
+
218
+ desc "log NAME", "Print the named entry's log file to STDOUT"
219
+ method_option :follow, aliases: "-f", type: :boolean, desc: "Keep printing new output as it arrives (tail -f)"
220
+ def log(name)
221
+ backend, entry = resolve_known(name)
222
+
223
+ path = backend.log_path(entry)
224
+ return $stderr.puts "#{name} has no log yet (#{path}); it is created on the first run." unless File.exist?(path)
225
+ return sh("tail -n +1 -f #{path.shellescape}", silent: true, exec: true) if options[:follow]
226
+
227
+ IO.copy_stream(path, $stdout)
228
+ end
229
+
230
+ desc "remove", "Remove all of this project's installed entries"
231
+ def remove
232
+ names = scheduler.installed_names
233
+ return puts "No scheduled tasks installed for #{schedule_project}." if names.empty?
234
+
235
+ names.each { |name| schedule_uninstall(name) }
236
+ end
237
+ end
238
+ end
239
+ end
240
+ # rubocop:enable Style/StderrPuts
@@ -0,0 +1,237 @@
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
+ # #installed_entries # => [[project_slug, "demo"], ...] for every project
28
+ # #installed_columns(name) # => { command:, schedule: } read back from the job files, or nil
29
+ # #installed_directory(name) # => the project root the job runs in, or nil
30
+ # #status(name) # => { state: :active|:stopped|:not_loaded, last_exit: String|nil }
31
+ # #log_path(name) # => path the job's output is appended to
32
+ # #notes # => [String] platform hints to show after install
33
+ module Schedule
34
+ # A scheduler command (launchctl, systemctl) failed.
35
+ class Error < Asgard::Error; end
36
+
37
+ # Weekday numbers follow launchd and cron: 0 (Sunday) through 6 (Saturday).
38
+ DAYS = %i[sunday monday tuesday wednesday thursday friday saturday].freeze
39
+
40
+ DAY_GROUPS = {
41
+ weekdays: %i[monday tuesday wednesday thursday friday],
42
+ weekends: %i[saturday sunday]
43
+ }.freeze
44
+
45
+ # Default command runner for the backends: argv in, [output, success?] out.
46
+ RUNNER = lambda do |*argv|
47
+ out, status = Open3.capture2e(*argv)
48
+ [out, status.success?]
49
+ rescue SystemCallError => e
50
+ [e.message, false]
51
+ end
52
+
53
+ class << self
54
+ # The runner new backends get. Tests assign one that records commands
55
+ # instead of running them; nil restores RUNNER.
56
+ attr_writer :runner
57
+
58
+ def runner = @runner || RUNNER
59
+
60
+ # The backend class for +platform+ (a RUBY_PLATFORM string).
61
+ def backend_class(platform = RUBY_PLATFORM)
62
+ case platform
63
+ when /darwin/ then Launchd
64
+ when /linux/ then Systemd
65
+ else raise Error, "#{platform} is not supported (needs macOS launchd or Linux systemd)"
66
+ end
67
+ end
68
+ end
69
+
70
+ module_function
71
+
72
+ # Validates a declaration and returns it as a plain Hash. options: is the
73
+ # task's command-line options as a String, split shell-style with quotes
74
+ # respected ("--period week --title 'Week End'"), or an Array of words.
75
+ # Exactly one of at: ("HH:MM" or an Array of them, with on:) or every:
76
+ # (seconds or a Duration) is required. as: names the entry.
77
+ def normalize(task, options: nil, at: nil, on: :daily, every: nil, env: {}, as: nil)
78
+ task = task.to_s
79
+ unless task.match?(/\A[\w:-]+\z/)
80
+ raise ArgumentError,
81
+ "schedule: task name #{task.inspect} must be a single word; put its flags in options:"
82
+ end
83
+ raise ArgumentError, "schedule :#{task} needs either at: or every:, not both" unless at.nil? ^ every.nil?
84
+
85
+ if every
86
+ every = seconds(every)
87
+ unless every&.positive?
88
+ raise ArgumentError,
89
+ "schedule :#{task} every: must be a positive number of seconds or a Duration (3.minutes)"
90
+ end
91
+ else
92
+ calendar(at:, on:) # raises on a bad time or day
93
+ end
94
+
95
+ args = options.is_a?(Array) ? options.map(&:to_s) : Shellwords.split(options.to_s)
96
+ { name: entry_name(task, args, as), task:, args:, at:, on:, every:, env: env.to_h { |key, value| [key.to_s, value.to_s] } }
97
+ end
98
+
99
+ # Integer seconds from an Integer or anything Duration-like (ActiveSupport's
100
+ # 3.minutes responds to in_seconds); nil for anything else.
101
+ def seconds(value)
102
+ return value if value.is_a?(Integer)
103
+
104
+ value.respond_to?(:in_seconds) ? value.in_seconds.to_i : nil
105
+ end
106
+
107
+ # The task alone, or a slug of the whole command when it has arguments,
108
+ # so one task can be scheduled more than once with different flags.
109
+ def entry_name(task, args, as = nil)
110
+ name = (as || (args.empty? ? task : slug([task, *args].join(" ")))).to_s
111
+ raise ArgumentError, "schedule as: #{name.inspect} may only contain letters, digits, _ . -" unless name.match?(/\A[\w.-]+\z/)
112
+
113
+ name
114
+ end
115
+
116
+ def slug(name) = name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
117
+
118
+ # For display: the command line the job runs.
119
+ def command_line(task, args = []) = Shellwords.join(["asgard", task.to_s, *args])
120
+
121
+ def describe(at: nil, on: :daily, every: nil, **)
122
+ return "every #{every}s" if every
123
+
124
+ "#{Array(at).join(', ')} #{Array(on).join(', ')}"
125
+ end
126
+
127
+ # For the `list` table: like describe, but a run of hourly times
128
+ # collapses ("10:00, 11:00 ... 17:00" => "10:00-17:00 hourly").
129
+ def describe_compact(at: nil, on: :daily, every: nil, **)
130
+ return "every #{every}s" if every
131
+
132
+ "#{compact_times(Array(at))} #{Array(on).join(', ')}"
133
+ end
134
+
135
+ # The command and schedule columns of a `list` row for a declared entry.
136
+ def list_columns(spec) = { command: command_line(spec[:task], spec[:args]), schedule: describe_compact(**spec) }
137
+
138
+ # The command and schedule columns of a `list` row: the declaration's, or
139
+ # `missing` in the schedule column when there is none.
140
+ def declared_columns(spec, missing) = spec ? list_columns(spec) : { command: "-", schedule: missing }
141
+
142
+ # The state and last-exit columns of a `list` row, from a backend's live status.
143
+ def live_columns(backend, name)
144
+ state, last_exit = backend.status(name).values_at(:state, :last_exit)
145
+ { state: state.to_s.tr("_", " "), last_exit: state == :active ? (last_exit || "never run") : "-" }
146
+ end
147
+
148
+ # The command a job runs, from its program arguments: everything from
149
+ # `asgard` on, so a direnv wrapper and the asgard install path drop out.
150
+ def command_from_arguments(arguments)
151
+ start = arguments.index { |word| File.basename(word) == "asgard" }
152
+ Shellwords.join(["asgard", *arguments.drop(start ? start + 1 : 0)])
153
+ end
154
+
155
+ # describe_compact for calendar entries (the shape `calendar` returns), one
156
+ # group per distinct set of days: "17:30 weekdays; 09:00 saturday".
157
+ def describe_calendar(entries)
158
+ entries.group_by { |entry| entry[:days]&.sort }.map { |days, group| describe_days(days, group) }.join("; ")
159
+ end
160
+
161
+ def describe_days(days, group)
162
+ describe_compact(at: group.map { |entry| format("%<hour>02d:%<minute>02d", entry) }, on: day_names(days))
163
+ end
164
+
165
+ # The command and schedule columns for a job read back from its files.
166
+ def installed_columns(arguments:, calendar:, every:)
167
+ { command: command_from_arguments(arguments), schedule: every ? "every #{every}s" : describe_calendar(calendar) }
168
+ end
169
+
170
+ # Weekday numbers back to what on: took: nil => :daily, [1, 2, 3, 4, 5] => :weekdays, [5] => [:friday].
171
+ def day_names(days)
172
+ return :daily unless days
173
+
174
+ names = days.map { |day| DAYS.fetch(day) }
175
+ DAY_GROUPS.key(names) || names
176
+ end
177
+
178
+ def compact_times(times)
179
+ parsed = times.map { |time| parse_time(time) }
180
+ hourly = parsed.size >= 3 && parsed.map(&:last).uniq.size == 1 &&
181
+ parsed.each_cons(2).all? { |(hour, _), (next_hour, _)| next_hour == hour + 1 }
182
+ hourly ? "#{times.first}-#{times.last} hourly" : times.join(", ")
183
+ end
184
+
185
+ # "17:30" => [17, 30]
186
+ def parse_time(time)
187
+ shown = time.inspect
188
+ match = /\A(\d{1,2}):(\d{2})\z/.match(time.to_s) or
189
+ raise ArgumentError, %(at: expects "HH:MM" (got #{shown}))
190
+ hour = match[1].to_i
191
+ minute = match[2].to_i
192
+ raise ArgumentError, "at: #{shown} is not a valid time" unless hour <= 23 && minute <= 59
193
+
194
+ [hour, minute]
195
+ end
196
+
197
+ # :daily => nil (every day); :weekdays => [1, 2, 3, 4, 5]; :friday => [5];
198
+ # %i[monday thursday] => [1, 4]
199
+ def weekdays(on)
200
+ return nil if on.to_s == "daily"
201
+
202
+ names = DAY_GROUPS.fetch(on.is_a?(Array) ? nil : on.to_sym) { Array(on) }
203
+ names.map do |day|
204
+ DAYS.index(day.to_sym) or raise ArgumentError, "on: unknown day #{day.inspect}"
205
+ end
206
+ end
207
+
208
+ # One entry per time: [{ hour: 17, minute: 30, days: [1, 2, 3, 4, 5] }];
209
+ # days is nil for every day.
210
+ def calendar(at:, on: :daily)
211
+ days = weekdays(on)
212
+ Array(at).map do |time|
213
+ hour, minute = parse_time(time)
214
+ { hour:, minute:, days: }
215
+ end
216
+ end
217
+
218
+ # PATH captured at install time, plus the declaration's env:.
219
+ def environment(spec, path = ENV.fetch("PATH", nil)) = { "PATH" => path.to_s }.merge(spec[:env])
220
+
221
+ # First executable named +command+ on +path+ (a PATH-style String), or nil.
222
+ def which(command, path)
223
+ path.to_s.split(File::PATH_SEPARATOR)
224
+ .map { |dir| File.join(dir, command) }
225
+ .find { |file| File.file?(file) && File.executable?(file) }
226
+ end
227
+
228
+ # The job's argv. Schedulers need an absolute program. With direnv, the
229
+ # repo's .envrc (API keys, ...) is loaded at run time instead of being
230
+ # copied into the job definition, and direnv finds asgard on the job's
231
+ # PATH. Each argument is its own element, so no shell re-splits them.
232
+ def program_arguments(task, args = [], root:, asgard:, direnv: nil)
233
+ argv = [task.to_s, *args.map(&:to_s)]
234
+ direnv ? [direnv, "exec", root, "asgard", *argv] : [asgard, *argv]
235
+ end
236
+ end
237
+ end