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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +59 -1
  3. data/RELEASE.md +12 -0
  4. data/lib/commands/flow_commands.rb +104 -0
  5. data/lib/generators/templates/flows/flow_helper.tt +42 -0
  6. data/lib/generators/templates/flows/run.tt +332 -0
  7. data/lib/ruby_raider.rb +16 -0
  8. data/lib/utilities/flows/next_run.rb +61 -0
  9. data/lib/utilities/flows/notifier.rb +76 -0
  10. data/lib/utilities/flows/os/command_runner.rb +73 -0
  11. data/lib/utilities/flows/os/cron.rb +137 -0
  12. data/lib/utilities/flows/os/launchd.rb +164 -0
  13. data/lib/utilities/flows/os/linux.rb +63 -0
  14. data/lib/utilities/flows/os/systemd.rb +178 -0
  15. data/lib/utilities/flows/os/task_scheduler.rb +179 -0
  16. data/lib/utilities/flows/project.rb +170 -0
  17. data/lib/utilities/flows/run_log.rb +61 -0
  18. data/lib/utilities/flows/runner.rb +206 -0
  19. data/lib/utilities/flows/schedule.rb +221 -0
  20. data/lib/utilities/flows/schedule_store.rb +135 -0
  21. data/lib/utilities/flows/scheduler.rb +187 -0
  22. data/lib/version +1 -1
  23. data/sig/commands/flow_commands.rbs +13 -0
  24. data/sig/ruby_raider.rbs +2 -0
  25. data/sig/utilities/flows/next_run.rbs +15 -0
  26. data/sig/utilities/flows/notifier.rbs +29 -0
  27. data/sig/utilities/flows/os/command_runner.rbs +27 -0
  28. data/sig/utilities/flows/os/cron.rbs +34 -0
  29. data/sig/utilities/flows/os/launchd.rbs +45 -0
  30. data/sig/utilities/flows/os/linux.rbs +27 -0
  31. data/sig/utilities/flows/os/systemd.rbs +43 -0
  32. data/sig/utilities/flows/os/task_scheduler.rbs +53 -0
  33. data/sig/utilities/flows/project.rbs +46 -0
  34. data/sig/utilities/flows/run_log.rbs +22 -0
  35. data/sig/utilities/flows/runner.rbs +35 -0
  36. data/sig/utilities/flows/schedule.rbs +61 -0
  37. data/sig/utilities/flows/schedule_store.rbs +34 -0
  38. data/sig/utilities/flows/scheduler.rbs +42 -0
  39. data/spec/commands/flow_commands_spec.rb +128 -0
  40. data/spec/generators/flow_helper_spec.rb +40 -0
  41. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  42. data/spec/integration/end_to_end_spec.rb +63 -0
  43. data/spec/integration/flows/flow_project.rb +62 -0
  44. data/spec/integration/flows/run_command_spec.rb +91 -0
  45. data/spec/integration/flows/run_lifecycle_spec.rb +383 -0
  46. data/spec/utilities/flows/next_run_spec.rb +119 -0
  47. data/spec/utilities/flows/notifier_spec.rb +121 -0
  48. data/spec/utilities/flows/os/command_runner_spec.rb +56 -0
  49. data/spec/utilities/flows/os/cron_spec.rb +287 -0
  50. data/spec/utilities/flows/os/launchd_spec.rb +293 -0
  51. data/spec/utilities/flows/os/linux_spec.rb +149 -0
  52. data/spec/utilities/flows/os/systemd_spec.rb +329 -0
  53. data/spec/utilities/flows/os/task_scheduler_spec.rb +505 -0
  54. data/spec/utilities/flows/project_spec.rb +178 -0
  55. data/spec/utilities/flows/run_log_spec.rb +47 -0
  56. data/spec/utilities/flows/runner_spec.rb +313 -0
  57. data/spec/utilities/flows/schedule_spec.rb +233 -0
  58. data/spec/utilities/flows/schedule_store_spec.rb +137 -0
  59. data/spec/utilities/flows/scheduler_spec.rb +329 -0
  60. 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 = { '&' => '&amp;', '<' => '&lt;', '>' => '&gt;' }.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 = { '&' => '&amp;', '<' => '&lt;', '>' => '&gt;', '"' => '&quot;', "'" => '&apos;' }.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