ruby_raider 4.0.1 → 4.1.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/README.md +59 -1
- data/RELEASE.md +12 -0
- data/lib/commands/flow_commands.rb +104 -0
- data/lib/generators/templates/flows/flow_helper.tt +42 -0
- data/lib/generators/templates/flows/run.tt +332 -0
- data/lib/ruby_raider.rb +16 -0
- data/lib/utilities/flows/next_run.rb +61 -0
- data/lib/utilities/flows/notifier.rb +76 -0
- data/lib/utilities/flows/os/command_runner.rb +73 -0
- data/lib/utilities/flows/os/cron.rb +137 -0
- data/lib/utilities/flows/os/launchd.rb +164 -0
- data/lib/utilities/flows/os/linux.rb +63 -0
- data/lib/utilities/flows/os/systemd.rb +178 -0
- data/lib/utilities/flows/os/task_scheduler.rb +179 -0
- data/lib/utilities/flows/project.rb +170 -0
- data/lib/utilities/flows/run_log.rb +61 -0
- data/lib/utilities/flows/runner.rb +206 -0
- data/lib/utilities/flows/schedule.rb +221 -0
- data/lib/utilities/flows/schedule_store.rb +135 -0
- data/lib/utilities/flows/scheduler.rb +187 -0
- data/lib/version +1 -1
- data/sig/commands/flow_commands.rbs +13 -0
- data/sig/ruby_raider.rbs +2 -0
- data/sig/utilities/flows/next_run.rbs +15 -0
- data/sig/utilities/flows/notifier.rbs +29 -0
- data/sig/utilities/flows/os/command_runner.rbs +27 -0
- data/sig/utilities/flows/os/cron.rbs +34 -0
- data/sig/utilities/flows/os/launchd.rbs +45 -0
- data/sig/utilities/flows/os/linux.rbs +27 -0
- data/sig/utilities/flows/os/systemd.rbs +43 -0
- data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
- data/sig/utilities/flows/project.rbs +46 -0
- data/sig/utilities/flows/run_log.rbs +22 -0
- data/sig/utilities/flows/runner.rbs +35 -0
- data/sig/utilities/flows/schedule.rbs +61 -0
- data/sig/utilities/flows/schedule_store.rbs +34 -0
- data/sig/utilities/flows/scheduler.rbs +42 -0
- data/spec/commands/flow_commands_spec.rb +128 -0
- data/spec/generators/flow_helper_spec.rb +40 -0
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +63 -0
- data/spec/integration/flows/flow_project.rb +62 -0
- data/spec/integration/flows/run_command_spec.rb +91 -0
- data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
- data/spec/utilities/flows/next_run_spec.rb +119 -0
- data/spec/utilities/flows/notifier_spec.rb +121 -0
- data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
- data/spec/utilities/flows/os/cron_spec.rb +287 -0
- data/spec/utilities/flows/os/launchd_spec.rb +293 -0
- data/spec/utilities/flows/os/linux_spec.rb +149 -0
- data/spec/utilities/flows/os/systemd_spec.rb +329 -0
- data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
- data/spec/utilities/flows/project_spec.rb +178 -0
- data/spec/utilities/flows/run_log_spec.rb +47 -0
- data/spec/utilities/flows/runner_spec.rb +313 -0
- data/spec/utilities/flows/schedule_spec.rb +233 -0
- data/spec/utilities/flows/schedule_store_spec.rb +137 -0
- data/spec/utilities/flows/scheduler_spec.rb +329 -0
- metadata +53 -2
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require_relative 'schedule'
|
|
5
|
+
|
|
6
|
+
module RubyRaider
|
|
7
|
+
module Flows
|
|
8
|
+
# When a schedule next runs, in local time, as launchd, schtasks, systemd and cron count it.
|
|
9
|
+
# A day, weekday or week rule whose time falls in a spring-forward gap runs at the first minute
|
|
10
|
+
# after the gap; one that falls in a fall-back hour runs at its first occurrence only.
|
|
11
|
+
# An hour rule runs at every real instant whose local minute matches, so twice in a fall-back hour.
|
|
12
|
+
module NextRun
|
|
13
|
+
WDAYS = %w[sun mon tue wed thu fri sat].freeze
|
|
14
|
+
# The longest stretch to search: a week rule, plus a day of slack
|
|
15
|
+
SEARCH_DAYS = 8
|
|
16
|
+
# The minutes to search for an hour rule: an hour, plus an hour of offset change
|
|
17
|
+
SEARCH_MINUTES = 120
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def after(schedule, now)
|
|
22
|
+
local_now = now.getlocal
|
|
23
|
+
schedule.every == 'hour' ? next_minute(schedule.minute, local_now) : next_day(schedule, local_now)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def next_minute(minute, now)
|
|
27
|
+
time = Time.at(now.to_i - now.sec + 60).localtime
|
|
28
|
+
SEARCH_MINUTES.times do
|
|
29
|
+
return time if time.min == minute
|
|
30
|
+
|
|
31
|
+
time += 60
|
|
32
|
+
end
|
|
33
|
+
raise Error, "No local time with minute #{minute} after #{now}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def next_day(schedule, now)
|
|
37
|
+
today = Date.new(now.year, now.month, now.day)
|
|
38
|
+
(0..SEARCH_DAYS).each do |offset|
|
|
39
|
+
date = today + offset
|
|
40
|
+
next unless schedule.days.include?(WDAYS[date.wday])
|
|
41
|
+
|
|
42
|
+
time = local(date, schedule.hour, schedule.minute)
|
|
43
|
+
return time if time > now
|
|
44
|
+
end
|
|
45
|
+
raise Error, "No run for #{schedule.rule_text} after #{now}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The first instant whose local wall clock reaches date hour:minute
|
|
49
|
+
def local(date, hour, minute)
|
|
50
|
+
wanted = [date.year, date.month, date.day, hour, minute]
|
|
51
|
+
time = Time.local(*wanted)
|
|
52
|
+
time += 60 while (wall(time) <=> wanted).negative?
|
|
53
|
+
time -= 60 until (wall(time - 60) <=> wanted).negative?
|
|
54
|
+
earlier = time - 3600
|
|
55
|
+
wall(earlier) == wanted ? earlier : time
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def wall(time) = [time.year, time.month, time.day, time.hour, time.min]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
require_relative 'project'
|
|
5
|
+
require_relative 'os/command_runner'
|
|
6
|
+
|
|
7
|
+
module RubyRaider
|
|
8
|
+
module Flows
|
|
9
|
+
# A desktop notification for `raider flow run --notify`. The title and message only ever travel as argv
|
|
10
|
+
# items after `--` (osascript, notify-send: so a title like -e is not read as an option or a script) or as
|
|
11
|
+
# environment values (PowerShell), so a hostile flow name or step title can't run as AppleScript or PowerShell. Never raises: a notification
|
|
12
|
+
# that can't be shown must not change how the run ends.
|
|
13
|
+
# :reek:TooManyConstants { max_constants: 7 } -- the fixed command of each OS, the message cap and the timeout
|
|
14
|
+
# :reek:TooManyInstanceVariables { max_instance_variables: 5 } -- the injected runner, OS, stream and PowerShell path, and the warned flag
|
|
15
|
+
class Notifier
|
|
16
|
+
MAX = 200
|
|
17
|
+
TIMEOUT = 15
|
|
18
|
+
OSASCRIPT = ['/usr/bin/osascript', '-e', 'on run argv', '-e',
|
|
19
|
+
'display notification (item 2 of argv) with title (item 1 of argv)', '-e', 'end run', '--'].freeze
|
|
20
|
+
NOTIFY_SEND = ['notify-send', '--app-name=Ruby Raider', '--'].freeze
|
|
21
|
+
MARKUP = { '&' => '&', '<' => '<', '>' => '>' }.freeze
|
|
22
|
+
# Reads the text from the environment and sets it as XML text nodes; one line with no double quotes, so
|
|
23
|
+
# Windows command-line quoting can't split it. Shown under PowerShell's own app id, which Windows accepts.
|
|
24
|
+
TOAST_SCRIPT = [
|
|
25
|
+
"$ErrorActionPreference = 'Stop'",
|
|
26
|
+
'$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]',
|
|
27
|
+
'$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)',
|
|
28
|
+
"$texts = $xml.GetElementsByTagName('text')",
|
|
29
|
+
'$null = $texts.Item(0).AppendChild($xml.CreateTextNode($env:RAIDER_NOTIFY_TITLE))',
|
|
30
|
+
'$null = $texts.Item(1).AppendChild($xml.CreateTextNode($env:RAIDER_NOTIFY_MESSAGE))',
|
|
31
|
+
'$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)',
|
|
32
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($toast)"
|
|
33
|
+
].join('; ').freeze
|
|
34
|
+
POWERSHELL_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', TOAST_SCRIPT].freeze
|
|
35
|
+
|
|
36
|
+
def self.powershell = File.join(ENV.fetch('SystemRoot', 'C:\\Windows'), 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
37
|
+
|
|
38
|
+
def initialize(runner: Os::CommandRunner.new, host_os: RbConfig::CONFIG['host_os'], err: $stderr, powershell: Notifier.powershell)
|
|
39
|
+
@runner = runner
|
|
40
|
+
@host_os = host_os
|
|
41
|
+
@err = err
|
|
42
|
+
@powershell = powershell
|
|
43
|
+
@warned = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when the notification was handed to the desktop
|
|
47
|
+
def notify(title, message)
|
|
48
|
+
title = clean(title)
|
|
49
|
+
message = clean(message)
|
|
50
|
+
result = case @host_os
|
|
51
|
+
when /darwin/ then @runner.call(*OSASCRIPT, title, message, timeout: TIMEOUT)
|
|
52
|
+
when /linux/ then @runner.call(*NOTIFY_SEND, title, message.gsub(/[&<>]/, MARKUP), timeout: TIMEOUT)
|
|
53
|
+
when /mswin|mingw|cygwin/
|
|
54
|
+
@runner.call(@powershell, *POWERSHELL_ARGS, env: { 'RAIDER_NOTIFY_TITLE' => title, 'RAIDER_NOTIFY_MESSAGE' => message },
|
|
55
|
+
timeout: TIMEOUT)
|
|
56
|
+
end
|
|
57
|
+
return false unless result
|
|
58
|
+
return true if result.success?
|
|
59
|
+
|
|
60
|
+
failed(result.stderr)
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
failed(e.message)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def clean(text) = Flows.printable(text)[0, MAX].to_s
|
|
68
|
+
|
|
69
|
+
def failed(reason)
|
|
70
|
+
@err.puts("Could not show a desktop notification: #{Flows.printable(reason.strip)}") unless @warned
|
|
71
|
+
@warned = true
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module RubyRaider
|
|
6
|
+
module Flows
|
|
7
|
+
module Os
|
|
8
|
+
# Runs an OS scheduler command (launchctl, schtasks, systemctl, crontab) with no shell. Adapters
|
|
9
|
+
# take one, so tests pass a double and never touch the real scheduler.
|
|
10
|
+
class CommandRunner
|
|
11
|
+
MISSING = 127
|
|
12
|
+
TIMED_OUT = 124
|
|
13
|
+
|
|
14
|
+
# What a command printed and how it exited
|
|
15
|
+
class Result
|
|
16
|
+
attr_reader :stdout, :stderr, :exitstatus
|
|
17
|
+
|
|
18
|
+
def initialize(stdout:, stderr:, exitstatus:)
|
|
19
|
+
@stdout = stdout
|
|
20
|
+
@stderr = stderr
|
|
21
|
+
@exitstatus = exitstatus
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def success? = exitstatus.zero?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# [command, command] as the first item keeps even a lone string out of a shell; env adds to the inherited
|
|
29
|
+
# one. With a timeout (seconds) a command still running then is killed and fails.
|
|
30
|
+
def call(*argv, stdin_data: nil, env: {}, timeout: nil)
|
|
31
|
+
command = argv.first.to_s
|
|
32
|
+
Open3.popen3(env, [command, command], *argv.drop(1).map(&:to_s)) do |stdin, stdout, stderr, wait|
|
|
33
|
+
readers = [stdout, stderr].map { |io| reader(io) }
|
|
34
|
+
feed(stdin, stdin_data)
|
|
35
|
+
next timed_out(command, timeout, wait) unless wait.join(timeout)
|
|
36
|
+
|
|
37
|
+
status = wait.value
|
|
38
|
+
Result.new(stdout: readers[0].value, stderr: readers[1].value, exitstatus: status.exitstatus || (128 + status.termsig.to_i))
|
|
39
|
+
end
|
|
40
|
+
rescue SystemCallError => e
|
|
41
|
+
Result.new(stdout: '', stderr: "Could not run #{command}: #{e.message}", exitstatus: MISSING)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Read in a thread, so a full pipe can't block the command; quiet when the pipe is closed under it
|
|
47
|
+
def reader(io)
|
|
48
|
+
thread = Thread.new { io.read.to_s }
|
|
49
|
+
thread.report_on_exception = false
|
|
50
|
+
thread
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def feed(stdin, data)
|
|
54
|
+
stdin.write(data.to_s)
|
|
55
|
+
rescue Errno::EPIPE
|
|
56
|
+
nil
|
|
57
|
+
ensure
|
|
58
|
+
stdin.close
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def timed_out(command, timeout, wait)
|
|
62
|
+
begin
|
|
63
|
+
Process.kill('KILL', wait.pid)
|
|
64
|
+
rescue SystemCallError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
wait.join
|
|
68
|
+
Result.new(stdout: '', stderr: "#{command} took longer than #{timeout} s", exitstatus: TIMED_OUT)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require_relative 'command_runner'
|
|
5
|
+
require_relative '../schedule'
|
|
6
|
+
|
|
7
|
+
module RubyRaider
|
|
8
|
+
module Flows
|
|
9
|
+
module Os
|
|
10
|
+
# Linux without a systemd user manager: one tagged block per schedule in the user's crontab, read with
|
|
11
|
+
# `crontab -l` and written back with `crontab -`. Every value goes through Shellwords.escape, which also
|
|
12
|
+
# escapes % as \% (cron would turn a bare % into a newline). Lines that are not ours keep every byte.
|
|
13
|
+
class Cron
|
|
14
|
+
CRONTAB = 'crontab'
|
|
15
|
+
TAG = '# raider-flow '
|
|
16
|
+
# Debian's vixie cron cuts a command at MAX_COMMAND (1000) bytes without a word; stay under it
|
|
17
|
+
MAX_LINE = 990
|
|
18
|
+
OS_ID = /\A#{Regexp.escape(Schedule::OS_ID_PREFIX)}\.[0-9a-f]{10}\.([a-z0-9_]{1,60})\z/
|
|
19
|
+
|
|
20
|
+
def initialize(runner: CommandRunner.new)
|
|
21
|
+
@runner = runner
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def label = 'cron'
|
|
25
|
+
|
|
26
|
+
def missed_runs_note = '(cron: missed runs are skipped)'
|
|
27
|
+
|
|
28
|
+
# Minute hour day-of-month month day-of-week (the system time zone), then the command for /bin/sh
|
|
29
|
+
def line(schedule)
|
|
30
|
+
ruby = schedule.ruby
|
|
31
|
+
raise Error, "cron can not run ruby from #{Flows.printable(ruby)}: env would read the = as a variable" if ruby.include?('=')
|
|
32
|
+
|
|
33
|
+
env = schedule.env.map { |key, value| "#{key}=#{value}" }
|
|
34
|
+
command = ['cd', escape(schedule.working_directory), '&&', '/usr/bin/env', *[*env, *schedule.argv].map { |word| escape(word) },
|
|
35
|
+
'>>', escape(schedule.log_path), '2>&1']
|
|
36
|
+
"#{fields(schedule)} #{command.join(' ')}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Our block goes first: above any SHELL=, CRON_TZ= or MAILTO= line, and with no byte of the rest changed
|
|
40
|
+
def install(schedule)
|
|
41
|
+
os_id = schedule.os_id
|
|
42
|
+
entry = line(schedule)
|
|
43
|
+
raise Error, "cron can not run #{schedule.name}: its crontab line is #{entry.bytesize} bytes, over #{MAX_LINE}" if entry.bytesize > MAX_LINE
|
|
44
|
+
|
|
45
|
+
rest, = without(read, os_id)
|
|
46
|
+
save("#{TAG}#{os_id}\n#{entry}\n#{rest}")
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# True when our block was there and is gone, false when it was already gone
|
|
51
|
+
def remove(os_id)
|
|
52
|
+
tag = checked_os_id(os_id)
|
|
53
|
+
rest, found = without(read, tag)
|
|
54
|
+
save(rest) if found
|
|
55
|
+
found
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def present?(os_id)
|
|
59
|
+
tag = checked_os_id(os_id)
|
|
60
|
+
without(read, tag).last
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# True when crontab -l answers (an empty crontab counts)
|
|
64
|
+
def usable?
|
|
65
|
+
read
|
|
66
|
+
true
|
|
67
|
+
rescue Error
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def fields(schedule)
|
|
74
|
+
minute = schedule.minute
|
|
75
|
+
return "#{minute} * * * *" if schedule.every == 'hour'
|
|
76
|
+
|
|
77
|
+
day = case schedule.every
|
|
78
|
+
when 'weekday' then '1-5'
|
|
79
|
+
when 'week' then weekday(schedule.on.to_s).to_s
|
|
80
|
+
else '*'
|
|
81
|
+
end
|
|
82
|
+
"#{minute} #{schedule.hour} * * #{day}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# cron counts Sunday as 0
|
|
86
|
+
def weekday(day)
|
|
87
|
+
index = Schedule::DAYS.index(day)
|
|
88
|
+
raise Error, "Invalid day #{day.inspect}" unless index
|
|
89
|
+
|
|
90
|
+
(index + 1) % 7
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def escape(word) = Shellwords.escape(word)
|
|
94
|
+
|
|
95
|
+
# The crontab without any of our tag lines, each with the line after it when that line is our command,
|
|
96
|
+
# and whether a tag was there
|
|
97
|
+
def without(table, os_id)
|
|
98
|
+
tag = "#{TAG}#{os_id}"
|
|
99
|
+
name = OS_ID.match(os_id).to_a[1]
|
|
100
|
+
kept = []
|
|
101
|
+
found = after_tag = false
|
|
102
|
+
table.lines.each do |text|
|
|
103
|
+
ours = text.chomp == tag || (after_tag && our_command?(text, name))
|
|
104
|
+
after_tag = text.chomp == tag
|
|
105
|
+
found ||= after_tag
|
|
106
|
+
kept << text unless ours
|
|
107
|
+
end
|
|
108
|
+
[kept.join, found]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The line under our tag runs `raider flow run <name> --project ... --trigger schedule`
|
|
112
|
+
def our_command?(text, name) = text.include?(" exec raider flow run #{name} --project ") && text.include?(' --trigger schedule')
|
|
113
|
+
|
|
114
|
+
def read
|
|
115
|
+
result = @runner.call(CRONTAB, '-l')
|
|
116
|
+
return result.stdout if result.success?
|
|
117
|
+
|
|
118
|
+
stderr = result.stderr
|
|
119
|
+
return '' if stderr.match?(/no crontab/i)
|
|
120
|
+
|
|
121
|
+
raise Error, "cron could not read the crontab: #{Flows.printable(stderr.strip)}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def save(table)
|
|
125
|
+
result = @runner.call(CRONTAB, '-', stdin_data: table)
|
|
126
|
+
raise Error, "cron could not save the crontab: #{Flows.printable(result.stderr.strip)}" unless result.success?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def checked_os_id(os_id)
|
|
130
|
+
return os_id if os_id.is_a?(String) && (match = OS_ID.match(os_id)) && Flows.valid_name?(match[1])
|
|
131
|
+
|
|
132
|
+
raise Error, "Invalid os_id #{Flows.printable(os_id.inspect)}: not a flow schedule"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require_relative 'command_runner'
|
|
6
|
+
require_relative '../schedule'
|
|
7
|
+
|
|
8
|
+
module RubyRaider
|
|
9
|
+
module Flows
|
|
10
|
+
module Os
|
|
11
|
+
# macOS: a LaunchAgent plist in ~/Library/LaunchAgents per schedule, loaded into the user's GUI
|
|
12
|
+
# domain with launchctl. Every value in the plist goes through one XML escaper.
|
|
13
|
+
# :reek:TooManyConstants { max_constants: 6 } -- the two tools, NOFOLLOW, the XML escapes and header, the os_id rule
|
|
14
|
+
class Launchd
|
|
15
|
+
LAUNCHCTL = '/bin/launchctl'
|
|
16
|
+
PLUTIL = '/usr/bin/plutil'
|
|
17
|
+
NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
|
|
18
|
+
ESCAPES = { '&' => '&', '<' => '<', '>' => '>', '"' => '"', "'" => ''' }.freeze
|
|
19
|
+
HEADER = <<~XML
|
|
20
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
21
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
22
|
+
<plist version="1.0">
|
|
23
|
+
XML
|
|
24
|
+
OS_ID = /\A#{Regexp.escape(Schedule::OS_ID_PREFIX)}\.[0-9a-f]{10}\.([a-z0-9_]{1,60})\z/
|
|
25
|
+
|
|
26
|
+
def initialize(runner: CommandRunner.new, home: Dir.home, uid: Process.uid)
|
|
27
|
+
@runner = runner
|
|
28
|
+
@home = home
|
|
29
|
+
@uid = uid
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def label = 'launchd'
|
|
33
|
+
|
|
34
|
+
# The LaunchAgent plist for the schedule (local time, no RunAtLoad)
|
|
35
|
+
def plist(schedule)
|
|
36
|
+
path = schedule.log_path
|
|
37
|
+
entries = [
|
|
38
|
+
['Label', string(schedule.os_id)],
|
|
39
|
+
['ProgramArguments', array(schedule.argv.map { |arg| string(arg) })],
|
|
40
|
+
['WorkingDirectory', string(schedule.working_directory)],
|
|
41
|
+
['StandardOutPath', string(path)],
|
|
42
|
+
['StandardErrorPath', string(path)],
|
|
43
|
+
['EnvironmentVariables', dict(schedule.env.map { |key, value| [key, string(value)] })],
|
|
44
|
+
['StartCalendarInterval', calendar(schedule)]
|
|
45
|
+
]
|
|
46
|
+
"#{HEADER}#{dict(entries).join("\n")}\n</plist>\n"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def plist_path(os_id) = File.join(agents_dir, "#{checked_os_id(os_id)}.plist")
|
|
50
|
+
|
|
51
|
+
# Writes the plist (linted before it replaces anything), then reloads the service
|
|
52
|
+
def install(schedule)
|
|
53
|
+
os_id = schedule.os_id
|
|
54
|
+
path = plist_path(os_id)
|
|
55
|
+
write(path, plist(schedule))
|
|
56
|
+
launchctl('bootout', service(os_id))
|
|
57
|
+
result = launchctl('bootstrap', domain, path)
|
|
58
|
+
return true if result.success?
|
|
59
|
+
|
|
60
|
+
raise Error, "launchd could not load #{os_id}: #{Flows.printable(result.stderr.strip)}#{discard(path)}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# True when a plist or a loaded service was removed, false when it was already gone
|
|
64
|
+
def remove(os_id)
|
|
65
|
+
path = plist_path(os_id)
|
|
66
|
+
unloaded = launchctl('bootout', service(os_id)).success?
|
|
67
|
+
unlink(path) || unloaded
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def present?(os_id) = launchctl('print', service(checked_os_id(os_id))).success?
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def launchctl(*args) = @runner.call(LAUNCHCTL, *args)
|
|
75
|
+
|
|
76
|
+
def domain = "gui/#{@uid}"
|
|
77
|
+
|
|
78
|
+
def service(os_id) = "#{domain}/#{os_id}"
|
|
79
|
+
|
|
80
|
+
# True when the plist was there; a plist that stays behind is an error, not "already gone"
|
|
81
|
+
def unlink(path)
|
|
82
|
+
File.unlink(path)
|
|
83
|
+
true
|
|
84
|
+
rescue Errno::ENOENT
|
|
85
|
+
false
|
|
86
|
+
rescue SystemCallError => e
|
|
87
|
+
raise Error, "Could not remove #{path}: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def agents_dir = File.join(@home, 'Library', 'LaunchAgents')
|
|
91
|
+
|
|
92
|
+
def checked_os_id(os_id)
|
|
93
|
+
return os_id if os_id.is_a?(String) && (match = OS_ID.match(os_id)) && Flows.valid_name?(match[1])
|
|
94
|
+
|
|
95
|
+
raise Error, "Invalid os_id #{Flows.printable(os_id.inspect)}: not a flow schedule"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def escape(text) = text.to_s.gsub(/[&<>"']/, ESCAPES)
|
|
99
|
+
|
|
100
|
+
def string(text) = ["<string>#{escape(text)}</string>"]
|
|
101
|
+
|
|
102
|
+
def integer(number) = ["<integer>#{Integer(number)}</integer>"]
|
|
103
|
+
|
|
104
|
+
def array(items) = ['<array>', *indent(items.flatten), '</array>']
|
|
105
|
+
|
|
106
|
+
def dict(pairs)
|
|
107
|
+
body = pairs.flat_map { |key, value| ["<key>#{escape(key)}</key>", *value] }
|
|
108
|
+
['<dict>', *indent(body), '</dict>']
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def indent(lines) = lines.map { |line| "\t#{line}" }
|
|
112
|
+
|
|
113
|
+
# Schedule::DAYS runs mon..sun
|
|
114
|
+
def calendar(schedule)
|
|
115
|
+
time = [['Hour', integer(schedule.hour)], ['Minute', integer(schedule.minute)]]
|
|
116
|
+
case schedule.every
|
|
117
|
+
when 'hour' then dict([['Minute', integer(schedule.minute)]])
|
|
118
|
+
when 'day' then dict(time)
|
|
119
|
+
else
|
|
120
|
+
days = schedule.days.map { |day| dict([['Weekday', integer(weekday(day))], *time]) }
|
|
121
|
+
schedule.every == 'week' ? days.first : array(days)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# launchd counts Sunday as 0
|
|
126
|
+
def weekday(day)
|
|
127
|
+
index = Schedule::DAYS.index(day)
|
|
128
|
+
raise Error, "Invalid day #{day.inspect}" unless index
|
|
129
|
+
|
|
130
|
+
(index + 1) % 7
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Removes the plist a failed bootstrap left; returns what to add to the error when it stays behind
|
|
134
|
+
def discard(path)
|
|
135
|
+
unlink(path)
|
|
136
|
+
''
|
|
137
|
+
rescue Error => e
|
|
138
|
+
"; also: #{e.message}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Temp file in the same folder, checked with plutil, then renamed over the old plist
|
|
142
|
+
def write(path, text)
|
|
143
|
+
dir = File.dirname(path)
|
|
144
|
+
FileUtils.mkdir_p(dir)
|
|
145
|
+
temp = File.join(dir, ".#{File.basename(path)}.#{SecureRandom.hex(6)}.tmp")
|
|
146
|
+
File.open(temp, File::WRONLY | File::CREAT | File::EXCL | NOFOLLOW, 0o644) do |file|
|
|
147
|
+
file.write(text)
|
|
148
|
+
file.flush
|
|
149
|
+
file.fsync
|
|
150
|
+
file.chmod(0o644)
|
|
151
|
+
end
|
|
152
|
+
lint = @runner.call(PLUTIL, '-lint', temp)
|
|
153
|
+
raise Error, "launchd plist for #{File.basename(path)} is invalid: #{Flows.printable("#{lint.stdout} #{lint.stderr}".strip)}" unless lint.success?
|
|
154
|
+
|
|
155
|
+
File.rename(temp, path)
|
|
156
|
+
rescue SystemCallError => e
|
|
157
|
+
raise Error, "Could not write #{path}: #{e.message}"
|
|
158
|
+
ensure
|
|
159
|
+
FileUtils.rm_f(temp) if temp && File.exist?(temp)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'command_runner'
|
|
4
|
+
require_relative 'systemd'
|
|
5
|
+
require_relative 'cron'
|
|
6
|
+
|
|
7
|
+
module RubyRaider
|
|
8
|
+
module Flows
|
|
9
|
+
module Os
|
|
10
|
+
# Linux: systemd user timers when the user's systemd manager answers, cron otherwise. A schedule made over
|
|
11
|
+
# ssh (no user bus, so cron) must still go when unscheduled from a desktop session, so remove and present?
|
|
12
|
+
# also look in the other scheduler when it answers. The reverse (a systemd timer, unscheduled where no user
|
|
13
|
+
# bus answers) can only be seen from its unit files, and can only be removed where systemctl --user works.
|
|
14
|
+
class Linux
|
|
15
|
+
def self.pick(runner = CommandRunner.new) = new(runner: runner)
|
|
16
|
+
|
|
17
|
+
def initialize(runner: CommandRunner.new, systemd: Systemd.new(runner: runner), cron: Cron.new(runner: runner))
|
|
18
|
+
# systemd is the other one only when it answers, so with cron first there is no other one
|
|
19
|
+
@systemd = systemd
|
|
20
|
+
@primary, @other = systemd.usable? ? [systemd, cron] : [cron, nil]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def label = @primary.label
|
|
24
|
+
|
|
25
|
+
def missed_runs_note = @primary.missed_runs_note
|
|
26
|
+
|
|
27
|
+
def install(schedule) = @primary.install(schedule)
|
|
28
|
+
|
|
29
|
+
# True when either scheduler removed something
|
|
30
|
+
def remove(os_id)
|
|
31
|
+
removed = @primary.remove(os_id)
|
|
32
|
+
other = @other
|
|
33
|
+
other_removed = other&.usable? ? other.remove(os_id) : false
|
|
34
|
+
refuse_stranded(os_id)
|
|
35
|
+
removed || other_removed
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# True when either scheduler has it, or systemd unit files are there though systemd does not answer
|
|
39
|
+
def present?(os_id) = @primary.present?(os_id) || other_present?(os_id) || stranded?(os_id)
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def stranded?(os_id) = @primary != @systemd && @systemd.unit_files?(os_id)
|
|
44
|
+
|
|
45
|
+
# Raises so the schedules.yml entry stays until the timer can really go
|
|
46
|
+
def refuse_stranded(os_id)
|
|
47
|
+
return unless stranded?(os_id)
|
|
48
|
+
|
|
49
|
+
raise Error, "This schedule is a systemd timer; run raider flow unschedule #{os_id.split('.').last} " \
|
|
50
|
+
'from a session where systemctl --user works'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The other scheduler failing counts as not there
|
|
54
|
+
def other_present?(os_id)
|
|
55
|
+
other = @other
|
|
56
|
+
other&.usable? ? other.present?(os_id) : false
|
|
57
|
+
rescue Error
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|