ruby_raider 4.1.0 → 4.2.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/e2e_tests.yml +3 -0
  3. data/README.md +52 -53
  4. data/RELEASE.md +14 -1
  5. data/lib/commands/schedule_commands.rb +84 -0
  6. data/lib/generators/templates/common/git_ignore.tt +1 -0
  7. data/lib/ruby_raider/runtime/retry_reset.rb +32 -0
  8. data/lib/ruby_raider/runtime/rspec.rb +1 -0
  9. data/lib/ruby_raider/runtime/show_browser.rb +53 -0
  10. data/lib/ruby_raider/runtime.rb +2 -0
  11. data/lib/ruby_raider.rb +22 -7
  12. data/lib/utilities/schedules/adapter.rb +22 -0
  13. data/lib/utilities/schedules/base.rb +27 -0
  14. data/lib/utilities/schedules/kind.rb +74 -0
  15. data/lib/utilities/schedules/legacy.rb +196 -0
  16. data/lib/utilities/schedules/legacy_stub.rb +62 -0
  17. data/lib/utilities/{flows → schedules}/next_run.rb +2 -2
  18. data/lib/utilities/{flows → schedules}/notifier.rb +6 -6
  19. data/lib/utilities/{flows → schedules}/os/command_runner.rb +1 -1
  20. data/lib/utilities/{flows → schedules}/os/cron.rb +27 -17
  21. data/lib/utilities/{flows → schedules}/os/launchd.rb +32 -14
  22. data/lib/utilities/{flows → schedules}/os/linux.rb +23 -5
  23. data/lib/utilities/{flows → schedules}/os/systemd.rb +33 -17
  24. data/lib/utilities/{flows → schedules}/os/task_scheduler.rb +36 -20
  25. data/lib/utilities/schedules/os_enumerator.rb +30 -0
  26. data/lib/utilities/schedules/project.rb +70 -0
  27. data/lib/utilities/schedules/rule.rb +162 -0
  28. data/lib/utilities/schedules/run_lock.rb +160 -0
  29. data/lib/utilities/schedules/run_log.rb +122 -0
  30. data/lib/utilities/schedules/run_report.rb +139 -0
  31. data/lib/utilities/schedules/run_wrapper.rb +216 -0
  32. data/lib/utilities/schedules/schedule.rb +89 -0
  33. data/lib/utilities/schedules/schedule_store.rb +53 -0
  34. data/lib/utilities/schedules/scheduler.rb +178 -0
  35. data/lib/utilities/schedules/scheduler_steps.rb +69 -0
  36. data/lib/utilities/schedules/selection.rb +133 -0
  37. data/lib/utilities/schedules/spawn.rb +68 -0
  38. data/lib/utilities/{flows/schedule_store.rb → schedules/store.rb} +17 -13
  39. data/lib/version +1 -1
  40. data/ruby_raider.gemspec +1 -0
  41. data/sig/commands/schedule_commands.rbs +12 -0
  42. data/sig/ruby_raider/runtime/retry_reset.rbs +7 -0
  43. data/sig/ruby_raider/runtime/show_browser.rbs +27 -0
  44. data/sig/ruby_raider.rbs +4 -0
  45. data/sig/utilities/schedules/adapter.rbs +5 -0
  46. data/sig/utilities/schedules/base.rbs +15 -0
  47. data/sig/utilities/schedules/kind.rbs +32 -0
  48. data/sig/utilities/schedules/legacy.rbs +45 -0
  49. data/sig/utilities/schedules/legacy_stub.rbs +15 -0
  50. data/sig/utilities/{flows → schedules}/next_run.rbs +3 -3
  51. data/sig/utilities/{flows → schedules}/notifier.rbs +1 -1
  52. data/sig/utilities/{flows → schedules}/os/command_runner.rbs +1 -1
  53. data/sig/utilities/{flows → schedules}/os/cron.rbs +7 -7
  54. data/sig/utilities/{flows → schedules}/os/launchd.rbs +7 -6
  55. data/sig/utilities/{flows → schedules}/os/linux.rbs +10 -4
  56. data/sig/utilities/{flows → schedules}/os/systemd.rbs +8 -7
  57. data/sig/utilities/{flows → schedules}/os/task_scheduler.rbs +13 -10
  58. data/sig/utilities/schedules/os_enumerator.rbs +12 -0
  59. data/sig/utilities/schedules/project.rbs +22 -0
  60. data/sig/utilities/{flows/schedule.rbs → schedules/rule.rbs} +23 -12
  61. data/sig/utilities/schedules/run_lock.rbs +29 -0
  62. data/sig/utilities/schedules/run_log.rbs +33 -0
  63. data/sig/utilities/schedules/run_report.rbs +34 -0
  64. data/sig/utilities/schedules/run_wrapper.rbs +45 -0
  65. data/sig/utilities/schedules/schedule.rbs +27 -0
  66. data/sig/utilities/schedules/schedule_store.rbs +21 -0
  67. data/sig/utilities/{flows → schedules}/scheduler.rbs +22 -15
  68. data/sig/utilities/schedules/scheduler_steps.rbs +22 -0
  69. data/sig/utilities/schedules/selection.rbs +36 -0
  70. data/sig/utilities/schedules/spawn.rbs +10 -0
  71. data/sig/utilities/{flows/schedule_store.rbs → schedules/store.rbs} +14 -9
  72. data/spec/commands/schedule_commands_spec.rb +284 -0
  73. data/spec/integration/content/common_content_spec.rb +8 -2
  74. data/spec/integration/content/gemfile_content_spec.rb +2 -2
  75. data/spec/integration/end_to_end_spec.rb +0 -63
  76. data/spec/runtime/retry_reset_spec.rb +112 -0
  77. data/spec/runtime/show_browser_project_spec.rb +70 -0
  78. data/spec/runtime/show_browser_spec.rb +101 -0
  79. data/spec/utilities/schedules/kind_spec.rb +81 -0
  80. data/spec/utilities/schedules/legacy_spec.rb +433 -0
  81. data/spec/utilities/schedules/legacy_stub_spec.rb +144 -0
  82. data/spec/utilities/{flows → schedules}/next_run_spec.rb +5 -5
  83. data/spec/utilities/{flows → schedules}/notifier_spec.rb +3 -4
  84. data/spec/utilities/{flows → schedules}/os/command_runner_spec.rb +2 -3
  85. data/spec/utilities/{flows → schedules}/os/cron_spec.rb +34 -29
  86. data/spec/utilities/{flows → schedules}/os/launchd_spec.rb +42 -29
  87. data/spec/utilities/{flows → schedules}/os/linux_spec.rb +27 -13
  88. data/spec/utilities/schedules/os/list_ids_spec.rb +202 -0
  89. data/spec/utilities/{flows → schedules}/os/systemd_spec.rb +35 -31
  90. data/spec/utilities/{flows → schedules}/os/task_scheduler_spec.rb +40 -34
  91. data/spec/utilities/schedules/os_entry_spec.rb +54 -0
  92. data/spec/utilities/schedules/os_enumerator_spec.rb +57 -0
  93. data/spec/utilities/schedules/project_spec.rb +125 -0
  94. data/spec/utilities/schedules/run_lock_spec.rb +143 -0
  95. data/spec/utilities/schedules/run_log_spec.rb +83 -0
  96. data/spec/utilities/schedules/run_report_spec.rb +150 -0
  97. data/spec/utilities/schedules/run_wrapper_integration_spec.rb +166 -0
  98. data/spec/utilities/schedules/run_wrapper_spec.rb +430 -0
  99. data/spec/utilities/schedules/schedule_spec.rb +120 -0
  100. data/spec/utilities/schedules/schedule_store_spec.rb +151 -0
  101. data/spec/utilities/schedules/scheduler_launchd_spec.rb +77 -0
  102. data/spec/utilities/schedules/scheduler_spec.rb +445 -0
  103. data/spec/utilities/schedules/selection_spec.rb +176 -0
  104. data/spec/utilities/schedules/spawn_spec.rb +104 -0
  105. metadata +106 -53
  106. data/lib/commands/flow_commands.rb +0 -104
  107. data/lib/generators/templates/flows/flow_helper.tt +0 -42
  108. data/lib/generators/templates/flows/run.tt +0 -332
  109. data/lib/utilities/flows/project.rb +0 -170
  110. data/lib/utilities/flows/run_log.rb +0 -61
  111. data/lib/utilities/flows/runner.rb +0 -206
  112. data/lib/utilities/flows/schedule.rb +0 -221
  113. data/lib/utilities/flows/scheduler.rb +0 -187
  114. data/sig/commands/flow_commands.rbs +0 -13
  115. data/sig/utilities/flows/project.rbs +0 -46
  116. data/sig/utilities/flows/run_log.rbs +0 -22
  117. data/sig/utilities/flows/runner.rbs +0 -35
  118. data/spec/commands/flow_commands_spec.rb +0 -128
  119. data/spec/generators/flow_helper_spec.rb +0 -40
  120. data/spec/integration/flows/flow_project.rb +0 -62
  121. data/spec/integration/flows/run_command_spec.rb +0 -91
  122. data/spec/integration/flows/run_lifecycle_spec.rb +0 -383
  123. data/spec/utilities/flows/project_spec.rb +0 -178
  124. data/spec/utilities/flows/run_log_spec.rb +0 -47
  125. data/spec/utilities/flows/runner_spec.rb +0 -313
  126. data/spec/utilities/flows/schedule_spec.rb +0 -233
  127. data/spec/utilities/flows/schedule_store_spec.rb +0 -137
  128. data/spec/utilities/flows/scheduler_spec.rb +0 -329
@@ -1,206 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'English'
4
- require 'securerandom'
5
- require 'time'
6
- require_relative 'project'
7
- require_relative 'run_log'
8
- require_relative 'notifier'
9
-
10
- module RubyRaider
11
- module Flows
12
- # `raider flow run`: a thin wrapper around `bundle exec rspec flows/<name>_flow.rb`. The flow's own
13
- # flows/support/run.rb records the run; this sets its env, retries the whole flow and maps the
14
- # recorded status to the exit code.
15
- # :reek:TooManyInstanceVariables { max_instance_variables: 6 } -- the project, injected collaborators and the last run line
16
- class Runner
17
- EXIT_CODES = { 'completed' => 0, 'failed' => 1, 'setup_error' => 2, 'busy' => 3 }.freeze
18
- INPUT = /\A[a-z][a-z0-9_]{0,39}\z/
19
- INPUT_RULE = 'use name=value, the name 1 to 40 of a-z, 0-9 and _ starting with a letter'
20
- TRIGGERS = %w[manual schedule desktop].freeze
21
- RETRY_WAIT = 30
22
-
23
- class << self
24
- # True once INT or TERM stopped the last spawned run: no retry after that
25
- attr_accessor :stopped
26
- end
27
-
28
- # Runs argv with no shell, under the project's own bundle rather than the one raider runs in.
29
- # The flow stays in the terminal's process group (it can read the terminal, and a closed terminal
30
- # ends it). Its flows/support/run.rb turns the first INT into an Interrupt that ends the step and
31
- # still records the run, and ignores the rest. nil when it could not start: no log line, so exit 2.
32
- def self.spawn(env, argv, chdir)
33
- self.stopped = false
34
- pid = nil
35
- forwarded = false
36
- forward = lambda do
37
- next if pid.nil? || forwarded || Gem.win_platform? # the Windows console already sent Ctrl-C to the flow
38
-
39
- forwarded = true
40
- interrupt(pid)
41
- end
42
- # Before spawning, so a Ctrl-C in between is not lost
43
- previous = %w[INT TERM].to_h do |signal|
44
- [signal, trap(signal) do
45
- self.stopped = true
46
- forward.call
47
- end]
48
- end
49
- run = lambda do
50
- pid = Process.spawn(env, *argv, chdir: chdir)
51
- forward.call if stopped
52
- Process.wait(pid)
53
- $CHILD_STATUS.exitstatus
54
- end
55
- defined?(Bundler) ? Bundler.with_unbundled_env(&run) : run.call
56
- rescue SystemCallError => e
57
- warn("Could not start #{argv.first}: #{e.message}")
58
- nil
59
- ensure
60
- previous&.each { |signal, handler| trap(signal, handler || 'DEFAULT') }
61
- end
62
-
63
- # INT, sent once: from a terminal the flow already has its own, which it ignores as a repeat
64
- def self.interrupt(pid)
65
- Process.kill('INT', pid)
66
- rescue SystemCallError
67
- nil
68
- end
69
-
70
- # notifier: nil makes a Notifier only when a run with notify: true needs one
71
- def initialize(project, out: $stdout, spawner: Runner.method(:spawn), sleeper: ->(seconds) { sleep(seconds) }, notifier: nil)
72
- @project = project
73
- @out = out
74
- @spawner = spawner
75
- @sleeper = sleeper
76
- @notifier = notifier
77
- @last_line = nil
78
- end
79
-
80
- # Returns the exit code: 0 completed, 1 a step failed, 2 setup failed, 3 already running.
81
- # notify: true sends one desktop notification when the run ends in neither completed nor busy.
82
- def run(name, headed: false, retries: 0, inputs: [], trigger: 'manual', notify: false)
83
- @project.flow_file(name)
84
- raise Error, 'Flows are not set up in this project: run raider flow init' unless @project.set_up?
85
-
86
- options = { headed: headed, inputs: input_env(inputs), trigger: checked_trigger(trigger) }
87
- attempts = checked_retries(retries) + 1
88
- status = attempt_runs(name, attempts, options)
89
- # Not after a stop: whoever pressed Ctrl-C already knows
90
- notify_failure(name, status, options[:trigger]) if notify && !Runner.stopped && !%w[completed busy].include?(status)
91
- EXIT_CODES.fetch(status, 2)
92
- end
93
-
94
- private
95
-
96
- def attempt_runs(name, attempts, options)
97
- (1..attempts).each do |attempt|
98
- status = attempt_run(name, attempt, options)
99
- return stopped(name, status) if Runner.stopped
100
- return status if %w[completed busy].include?(status) || attempt == attempts
101
-
102
- @out.puts("Retrying in #{RETRY_WAIT} s (attempt #{attempt + 1} of #{attempts})")
103
- @sleeper.call(RETRY_WAIT)
104
- end
105
- 'setup_error'
106
- end
107
-
108
- # The notifier never raises, so a notification can't change the exit code
109
- def notify_failure(name, status, trigger)
110
- message = status == 'failed' ? "Failed at #{failed_step(@last_line&.fetch('failed_step', nil))}" : "Could not start: see #{start_output(name, trigger)}"
111
- (@notifier ||= Notifier.new).notify("Ruby Raider flow #{name}", message)
112
- end
113
-
114
- # Only a scheduled run outside Windows has a scheduler.log: Windows tasks keep none
115
- def start_output(name, trigger)
116
- trigger == 'schedule' && !Gem.win_platform? ? ".raider/flows/#{name}/scheduler.log" : 'the run output'
117
- end
118
-
119
- def stopped(name, status)
120
- @out.puts("Flow #{name} was stopped: no retry")
121
- status
122
- end
123
-
124
- def attempt_run(name, attempt, options)
125
- run_id = SecureRandom.hex(8)
126
- # The literal name-built path ([a-z0-9_] only), not the realpath flow_file checked
127
- argv = ['bundle', 'exec', 'rspec', "flows/#{name}_flow.rb", '--tag', 'flow']
128
- code = @spawner.call(run_env(name, attempt, run_id, options), argv, @project.root)
129
- line = RunLog.new(@project, name).find(run_id)
130
- @last_line = line
131
- report(name, line, code)
132
- line ? line['status'].to_s : 'setup_error'
133
- end
134
-
135
- # Every RAIDER_FLOW_* and RAIDER_INPUT_* is set (or unset) here, so a value left in the shell never leaks into a run
136
- def run_env(name, attempt, run_id, options)
137
- headed = options[:headed]
138
- stale_inputs = ENV.keys.grep(/\ARAIDER_INPUT_/).to_h { |key| [key, nil] }
139
- stale_inputs.merge(
140
- 'BUNDLE_GEMFILE' => @project.path('Gemfile'),
141
- 'RAIDER_FLOW_NAME' => name,
142
- 'RAIDER_FLOW_ATTEMPT' => attempt.to_s,
143
- 'RAIDER_FLOW_TRIGGER' => options[:trigger],
144
- 'RAIDER_FLOW_RUN_ID' => run_id,
145
- 'RAIDER_FLOW_HEADED' => headed ? '1' : nil,
146
- 'HEADLESS' => headed ? nil : 'true',
147
- 'RETRY_COUNT' => '0'
148
- ).merge(options[:inputs])
149
- end
150
-
151
- # name=value pairs; values reach the flow only through RAIDER_INPUT_<NAME>, never argv
152
- def input_env(inputs)
153
- Array(inputs).to_h do |pair|
154
- key, value = pair.to_s.split('=', 2)
155
- raise Error, "Invalid input #{key.to_s.inspect}: #{INPUT_RULE}" unless value && INPUT.match?(key.to_s)
156
- raise Error, "Invalid input #{key.inspect}: the value contains a NUL byte" if value.include?("\0")
157
-
158
- ["RAIDER_INPUT_#{key.to_s.upcase}", value]
159
- end
160
- end
161
-
162
- def checked_trigger(trigger)
163
- return trigger if TRIGGERS.include?(trigger)
164
-
165
- raise Error, "Invalid trigger #{trigger.inspect}: use #{TRIGGERS.join(', ')}"
166
- end
167
-
168
- def checked_retries(retries)
169
- return retries if retries.is_a?(Integer) && retries.between?(0, 3)
170
-
171
- raise Error, "Invalid retries #{retries.inspect}: use 0 to 3"
172
- end
173
-
174
- def report(name, line, code)
175
- return @out.puts("Flow #{name} did not record a run (exit #{code || 'none'}): see the output above") unless line
176
-
177
- @out.puts("Flow #{name} #{Flows.printable(outcome(line))}")
178
- results = line['results_dir']
179
- @out.puts("Results: #{Flows.printable(results)}") if results.is_a?(String) && File.directory?(@project.path(results))
180
- end
181
-
182
- def outcome(line)
183
- case line['status']
184
- when 'completed' then "completed in #{duration(line)} s"
185
- when 'failed' then "failed at #{failed_step(line['failed_step'])}"
186
- when 'busy' then 'is already running'
187
- else 'could not start: see the output above'
188
- end
189
- end
190
-
191
- def failed_step(step)
192
- step = {} unless step.is_a?(Hash)
193
- number = step['number']
194
- where = number ? "step #{number}" : "line #{step['line'] || '?'}"
195
- title = step['title']
196
- title ? "#{where} (#{title})" : where
197
- end
198
-
199
- def duration(line)
200
- (Time.iso8601(line['finished_at'].to_s) - Time.iso8601(line['started_at'].to_s)).round(1)
201
- rescue ArgumentError
202
- '?'
203
- end
204
- end
205
- end
206
- end
@@ -1,221 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'digest'
4
- require 'rbconfig'
5
- require 'time'
6
- require_relative 'project'
7
- require_relative 'runner'
8
-
9
- module RubyRaider
10
- module Flows
11
- # One scheduled flow: the rule (every/at/on), how it runs (retries, inputs) and what the OS entry
12
- # runs (absolute ruby and bundle, GEM_HOME/GEM_PATH captured when it was scheduled). Immutable.
13
- # :reek:TooManyInstanceVariables { max_instance_variables: 13 } -- the fields of a schedules.yml entry
14
- # :reek:TooManyConstants { max_constants: 9 } -- the rule vocabulary, the OS entry parts and the yml keys
15
- class Schedule
16
- EVERY = %w[hour day weekday week].freeze
17
- DAYS = %w[mon tue wed thu fri sat sun].freeze
18
- DAYS_RULE = 'use mon, tue, wed, thu, fri, sat or sun'
19
- CLOCK = /\A([01]\d|2[0-3]):([0-5]\d)\z/
20
- MINUTE = /\A(?:[01]\d|2[0-3])?:([0-5]\d)\z/
21
- GEM_ENV = %w[GEM_HOME GEM_PATH].freeze
22
- SYSTEM_PATH = %w[/usr/bin /bin].freeze
23
- OS_ID_PREFIX = 'dev.rubyraider.flow'
24
- KEYS = %w[name every at on retries inputs notify os_id ruby bundle gem_env created_at].freeze
25
-
26
- attr_reader :name, :every, :at, :on, :retries, :inputs, :notify, :os_id, :ruby, :bundle, :gem_env, :project_root, :created_at
27
-
28
- def self.build(project:, name:, every:, at: nil, on: nil, retries: 0, inputs: [], notify: false, now: Time.now)
29
- root = project.root
30
- new(name: name, every: every, at: at, on: on, retries: retries, inputs: inputs, notify: notify, os_id: os_id_for(root, name),
31
- ruby: RbConfig.ruby, bundle: bundle_path, gem_env: captured_gem_env,
32
- project_root: root, created_at: now.getutc.iso8601)
33
- end
34
-
35
- # A schedules.yml entry; raises Error when it is not one this class wrote
36
- def self.from_h(hash, project_root)
37
- raise Error, 'A schedule entry must be a mapping' unless hash.is_a?(Hash)
38
-
39
- fields = KEYS.to_h { |key| [key.to_sym, hash[key]] }
40
- schedule = new(**fields, project_root: project_root)
41
- # Strict: an entry copied from another project, or a moved project, is not this project's entry
42
- raise Error, "Invalid os_id #{schedule.os_id.inspect} for #{schedule.name} in #{project_root}" unless schedule.os_id == os_id_for(project_root, schedule.name)
43
-
44
- schedule
45
- end
46
-
47
- def self.os_id_for(project_root, name) = "#{OS_ID_PREFIX}.#{Digest::SHA256.hexdigest(project_root)[0, 10]}.#{name}"
48
-
49
- def self.bundle_path
50
- Gem.bin_path('bundler', 'bundle')
51
- rescue Gem::GemNotFoundException, Gem::MissingSpecError => e
52
- raise Error, "Bundler not found for this Ruby (#{e.message}): gem install bundler, then try again"
53
- end
54
-
55
- # The GEM_HOME and GEM_PATH raider started with (before bundle exec changed them), only when set
56
- def self.captured_gem_env
57
- env = defined?(Bundler) ? Bundler.original_env : ENV
58
- GEM_ENV.each_with_object({}) do |key, captured|
59
- value = env[key]
60
- captured[key] = value if value.is_a?(String) && !value.empty?
61
- end
62
- end
63
-
64
- def initialize(name:, every:, at:, on:, retries:, inputs:, os_id:, ruby:, bundle:, gem_env:, project_root:, created_at:, notify: false)
65
- @name = checked_name(name)
66
- @every = checked_every(every)
67
- @at = checked_at(at)
68
- @on = checked_on(on)
69
- @retries = checked_retries(retries)
70
- @inputs = checked_inputs(inputs)
71
- @notify = checked_notify(notify)
72
- @os_id = checked_os_id(os_id)
73
- @ruby = checked_path('ruby', ruby)
74
- @bundle = checked_path('bundle', bundle)
75
- @gem_env = checked_gem_env(gem_env)
76
- @project_root = checked_path('project_root', project_root)
77
- @created_at = checked_text('created_at', created_at)
78
- freeze
79
- end
80
-
81
- def hour = at[0, 2].to_i
82
-
83
- def minute = at[3, 2].to_i
84
-
85
- def days
86
- case every
87
- when 'weekday' then DAYS.first(5)
88
- when 'week' then [on.to_s]
89
- else DAYS
90
- end
91
- end
92
-
93
- def argv
94
- command = [ruby, bundle, 'exec', 'raider', 'flow', 'run', name, '--project', project_root, '--trigger', 'schedule']
95
- command += ['--retries', retries.to_s] if retries.positive?
96
- command << '--notify' if notify
97
- command += ['--input', *inputs] unless inputs.empty?
98
- command
99
- end
100
-
101
- def env
102
- path = [File.dirname(ruby), File.dirname(bundle), *SYSTEM_PATH].uniq.join(File::PATH_SEPARATOR)
103
- { 'PATH' => path, 'LANG' => 'en_US.UTF-8' }.merge(gem_env)
104
- end
105
-
106
- def working_directory = project_root
107
-
108
- def log_path = File.join(project_root, '.raider', 'flows', name, 'scheduler.log')
109
-
110
- def rule_text
111
- case every
112
- when 'hour' then "every hour at #{at[2, 3]}"
113
- when 'week' then "every week on #{on} at #{at}"
114
- else "every #{every} at #{at}"
115
- end
116
- end
117
-
118
- def to_h
119
- { 'name' => name, 'every' => every, 'at' => at, 'on' => on, 'retries' => retries, 'inputs' => inputs.dup, 'notify' => notify,
120
- 'os_id' => os_id, 'ruby' => ruby, 'bundle' => bundle, 'gem_env' => gem_env.dup, 'created_at' => created_at }
121
- end
122
-
123
- private
124
-
125
- def checked_name(name)
126
- return name.dup.freeze if name.is_a?(String) && Flows.valid_name?(name)
127
-
128
- raise Error, "Invalid flow name #{name.inspect}: use 1 to 60 of a-z, 0-9 and _ (not env)"
129
- end
130
-
131
- def checked_every(every)
132
- return every.dup.freeze if EVERY.include?(every)
133
-
134
- raise Error, "Invalid every #{every.inspect}: use hour, day, weekday or week"
135
- end
136
-
137
- # HH:MM, or for every hour :MM or HH:MM (the hour ignored, :00 when left out), kept as HH:MM
138
- def checked_at(at)
139
- return clock(at) unless every == 'hour'
140
- return '00:00' if at.nil?
141
-
142
- match = MINUTE.match(at.to_s)
143
- raise Error, "Invalid at #{at.inspect}: use :MM or HH:MM for every hour" unless match
144
-
145
- "00:#{match[1]}"
146
- end
147
-
148
- def clock(at)
149
- raise Error, "Every #{every} needs at: use HH:MM (24h)" if at.nil?
150
- return at.dup.freeze if at.is_a?(String) && CLOCK.match?(at)
151
-
152
- raise Error, "Invalid at #{at.inspect}: use HH:MM (24h)"
153
- end
154
-
155
- def checked_on(on)
156
- unless every == 'week'
157
- raise Error, 'on is only for every week' unless on.nil?
158
-
159
- return nil
160
- end
161
- raise Error, "Every week needs on: #{DAYS_RULE}" if on.nil?
162
- return on.dup.freeze if DAYS.include?(on)
163
-
164
- raise Error, "Invalid on #{on.inspect}: #{DAYS_RULE}"
165
- end
166
-
167
- def checked_retries(retries)
168
- return retries if retries.is_a?(Integer) && retries.between?(0, 3)
169
-
170
- raise Error, "Invalid retries #{retries.inspect}: use 0 to 3"
171
- end
172
-
173
- # name=value pairs as `raider flow run --input` takes them; a control character could break an OS entry
174
- def checked_inputs(inputs)
175
- raise Error, 'Invalid inputs: use a list of name=value' unless inputs.is_a?(Array)
176
-
177
- inputs.map do |pair|
178
- raise Error, "Invalid input #{pair.inspect}: use name=value in valid UTF-8" unless pair.is_a?(String) && pair.valid_encoding?
179
-
180
- key, value = pair.split('=', 2)
181
- raise Error, "Invalid input #{key.to_s.inspect}: #{Runner::INPUT_RULE}" unless value && Runner::INPUT.match?(key.to_s)
182
- raise Error, "Invalid input #{key.inspect}: the value contains a control character" if value.match?(/[[:cntrl:]]/)
183
-
184
- pair.dup.freeze
185
- end.freeze
186
- end
187
-
188
- # An entry written before notify existed has no key: off
189
- def checked_notify(notify)
190
- return notify == true if [true, false, nil].include?(notify)
191
-
192
- raise Error, "Invalid notify #{notify.inspect}: use true or false"
193
- end
194
-
195
- def checked_os_id(os_id)
196
- pattern = /\A#{Regexp.escape(OS_ID_PREFIX)}\.[0-9a-f]{10}\.#{Regexp.escape(name)}\z/
197
- return os_id.dup.freeze if os_id.is_a?(String) && pattern.match?(os_id)
198
-
199
- raise Error, "Invalid os_id #{os_id.inspect} for #{name}"
200
- end
201
-
202
- def checked_path(label, path)
203
- return checked_text(label, path) if path.is_a?(String) && File.absolute_path?(path)
204
-
205
- raise Error, "Invalid #{label} #{path.inspect}: use an absolute path"
206
- end
207
-
208
- def checked_gem_env(gem_env)
209
- raise Error, 'Invalid gem_env: use a mapping of GEM_HOME and GEM_PATH' unless gem_env.is_a?(Hash) && (gem_env.keys - GEM_ENV).empty?
210
-
211
- gem_env.to_h { |key, value| [key.dup.freeze, checked_text(key, value)] }.freeze
212
- end
213
-
214
- def checked_text(label, text)
215
- return text.dup.freeze if text.is_a?(String) && text.valid_encoding? && !text.empty? && !text.match?(/[[:cntrl:]]/)
216
-
217
- raise Error, "Invalid #{label} #{text.inspect}"
218
- end
219
- end
220
- end
221
- end
@@ -1,187 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'fileutils'
4
- require 'json'
5
- require 'rbconfig'
6
- require_relative 'next_run'
7
- require_relative 'run_log'
8
- require_relative 'schedule'
9
- require_relative 'schedule_store'
10
- require_relative 'os/command_runner'
11
- require_relative 'os/launchd'
12
- require_relative 'os/task_scheduler'
13
- require_relative 'os/linux'
14
-
15
- module RubyRaider
16
- module Flows
17
- # `raider flow schedule | unschedule | schedules`: keeps the OS scheduler entries and
18
- # .raider/flows/schedules.yml in step. The OS entry is installed first and the yml written after,
19
- # so the yml never names an entry the OS scheduler refused.
20
- # :reek:TooManyInstanceVariables { max_instance_variables: 6 } -- the project, its store, the adapter and the injected streams and clock
21
- class Scheduler
22
- NOT_SET_UP = 'Flows are not set up in this project: run raider flow init'
23
- RUBY_NOT_FOUND = 'Ruby not found: re-schedule'
24
- FLOW_NOT_FOUND = 'flow not found: unschedule'
25
- LAST = %w[status finished_at trigger failed_step results_dir].freeze
26
- TIME = '%a %-d %b %H:%M'
27
-
28
- # The OS scheduler for this machine; only Linux asks the system (is the user's systemd there?)
29
- def self.adapter(runner: Os::CommandRunner.new, host_os: RbConfig::CONFIG['host_os'])
30
- case host_os
31
- when /darwin/ then Os::Launchd.new(runner: runner)
32
- when /mswin|mingw|cygwin/ then Os::TaskScheduler.new(runner: runner)
33
- when /linux/ then Os::Linux.pick(runner)
34
- else raise Error, "Scheduling flows is not supported on #{host_os}"
35
- end
36
- end
37
-
38
- # adapter: nil picks the OS scheduler on first use, so a command refused earlier never touches it
39
- def initialize(project, adapter: nil, store: ScheduleStore.new(project), out: $stdout, err: $stderr, now: -> { Time.now })
40
- @project = project
41
- @adapter = adapter
42
- @store = store
43
- @out = out
44
- @err = err
45
- @now = now
46
- end
47
-
48
- def schedule(name, every:, at: nil, on: nil, retries: 0, inputs: [], notify: false)
49
- @project.flow_file(name)
50
- raise Error, NOT_SET_UP unless @project.set_up?
51
-
52
- now = @now.call
53
- schedule = Schedule.build(project: @project, name: name, every: every, at: at, on: on, retries: retries,
54
- inputs: inputs, notify: notify, now: now)
55
- flow_dir(name)
56
- @store.writable!
57
- old = @store.find(name)
58
- adapter.install(schedule)
59
- save(schedule)
60
- prefix = old ? "Replaced the schedule for #{name}" : "Scheduled #{name}"
61
- @out.puts("#{prefix}: #{rule(schedule)}. Next run #{NextRun.after(schedule, now).strftime(TIME)}.")
62
- end
63
-
64
- def unschedule(name)
65
- raise Error, "Invalid flow name #{name.inspect}: use 1 to 60 of a-z, 0-9 and _ (not env)" unless Flows.valid_name?(name)
66
-
67
- # The OS entry first: when the scheduler refuses, the yml entry stays to retry with.
68
- # from_h is strict, so a stored entry's os_id is always this one.
69
- label = adapter.label
70
- was_there = adapter.remove(Schedule.os_id_for(@project.root, name))
71
- removed = delete(name, label)
72
- raise Error, "No schedule for #{name}" unless removed || was_there
73
-
74
- @out.puts(was_there ? "Unscheduled #{name}" : "Unscheduled #{name} (it was already gone from #{label})")
75
- return unless removed.is_a?(ScheduleStore::Removed)
76
-
77
- @err.puts("The schedules.yml entry for #{name} could not be read: a #{label} entry made from a different project path may remain")
78
- end
79
-
80
- def schedules(json: false)
81
- now = @now.call
82
- listed = @store.all
83
- rows = listed.map { |schedule| row(schedule, now) } + unlisted_rows(listed.map(&:name))
84
- return @out.puts(JSON.generate(rows.map { |entry| entry[:json] })) if json
85
- return @out.puts('No schedules yet: raider flow schedule <name> --every day --at 08:00') if rows.empty?
86
-
87
- rows.each { |entry| @out.puts(Flows.printable(entry[:text])) }
88
- note = adapter.respond_to?(:missed_runs_note) ? adapter.missed_runs_note : nil
89
- @out.puts(note) if note
90
- end
91
-
92
- private
93
-
94
- def adapter = @adapter ||= Scheduler.adapter
95
-
96
- # Writes the yml; when that fails the OS entry just installed is removed, so neither names the flow
97
- def save(schedule)
98
- @store.save(schedule)
99
- rescue Error, SystemCallError => e
100
- label = adapter.label
101
- begin
102
- adapter.remove(schedule.os_id)
103
- rescue Error => remove_error
104
- raise Error, "#{e.message} (and removing the #{label} entry failed: #{remove_error.message})"
105
- end
106
- raise Error, "#{e.message}: the #{label} entry was removed too, so #{schedule.name} is not scheduled"
107
- end
108
-
109
- # The OS entry is gone by now: say so when the yml can't be written
110
- def delete(name, label)
111
- @store.delete(name)
112
- rescue Error, SystemCallError => e
113
- raise Error, "#{e.message}: the #{label} entry was already removed, but schedules.yml still lists #{name}"
114
- end
115
-
116
- # .raider/flows/<name>/ for scheduler.log: 0700 folders that are not links, as flows/support/run.rb makes them
117
- def flow_dir(name)
118
- [ScheduleStore::DIR, "#{ScheduleStore::DIR}/#{name}"].each { |relative| private_dir(relative) }
119
- end
120
-
121
- def private_dir(relative)
122
- dir = @project.path(relative)
123
- FileUtils.mkdir_p(dir)
124
- raise Error, "#{relative} must be a folder, not a link" unless File.lstat(dir).directory?
125
-
126
- File.chmod(0o700, dir)
127
- rescue SystemCallError => e
128
- raise Error, "Could not make #{relative}: #{e.message}"
129
- end
130
-
131
- def row(schedule, now)
132
- name = schedule.name
133
- next_run = NextRun.after(schedule, now)
134
- state = os_state(schedule)
135
- last = last_run(name)
136
- json = { 'name' => name, 'every' => schedule.every, 'at' => schedule.at, 'on' => schedule.on,
137
- 'retries' => schedule.retries, 'inputs' => schedule.inputs, 'notify' => schedule.notify, 'next_run_at' => next_run.iso8601,
138
- 'os_state' => state, 'last' => last }
139
- { json: json, text: line(name, rule(schedule), "next #{next_run.strftime(TIME)}", last, state) }
140
- end
141
-
142
- # Flows the OS scheduler still runs for this project although schedules.yml has no entry for them
143
- def unlisted_rows(listed)
144
- (@project.flow_names - listed).filter_map do |name|
145
- next unless present?(Schedule.os_id_for(@project.root, name))
146
-
147
- state = 'missing from schedules.yml'
148
- last = last_run(name)
149
- json = { 'name' => name, 'every' => nil, 'at' => nil, 'on' => nil, 'retries' => nil, 'inputs' => nil, 'notify' => nil,
150
- 'next_run_at' => nil, 'os_state' => state, 'last' => last }
151
- { json: json, text: line(name, '-', '-', last, state) }
152
- end
153
- end
154
-
155
- # A flow whose state can't be read is left out: only a listed schedule gets an unknown row
156
- def present?(os_id)
157
- adapter.present?(os_id)
158
- rescue Error
159
- false
160
- end
161
-
162
- # The OS entry runs `<ruby> <bundle> ...`: ruby is executed, bundle is only read by it
163
- def os_state(schedule)
164
- ruby = schedule.ruby
165
- bundle = schedule.bundle
166
- return RUBY_NOT_FOUND unless File.file?(ruby) && File.executable?(ruby) && File.file?(bundle) && File.readable?(bundle)
167
- return FLOW_NOT_FOUND unless File.file?(@project.path("flows/#{schedule.name}_flow.rb"))
168
-
169
- adapter.present?(schedule.os_id) ? 'ok' : "missing from #{adapter.label}"
170
- rescue Error => e
171
- "unknown (#{e.message})"
172
- end
173
-
174
- def rule(schedule) = schedule.notify ? "#{schedule.rule_text} (notify)" : schedule.rule_text
175
-
176
- def last_run(name)
177
- last = RunLog.new(@project, name).last_result
178
- last && LAST.to_h { |key| [key, last[key]] }
179
- end
180
-
181
- def line(name, rule, next_run, last, state)
182
- result = last ? "#{last['status']} #{last['finished_at']}" : 'never run'
183
- "#{name.ljust(30)} #{rule} #{next_run} #{result} #{state}"
184
- end
185
- end
186
- end
187
- end
@@ -1,13 +0,0 @@
1
- # Type definitions for FlowCommands
2
- class FlowCommands < Thor
3
- def init: () -> void
4
- def run_flow: (String name) -> void
5
- def list: () -> void
6
- def schedule: (String name) -> void
7
- def unschedule: (String name) -> void
8
- def schedules: () -> void
9
- def project: () -> RubyRaider::Flows::Project
10
- def scheduler: () -> RubyRaider::Flows::Scheduler
11
- def list_line: (Hash[String, untyped] flow) -> String
12
- def stop: (RubyRaider::Flows::Error error) -> bot
13
- end
@@ -1,46 +0,0 @@
1
- module RubyRaider
2
- module Flows
3
- NAME: Regexp
4
- RESERVED: Array[String]
5
-
6
- def self.valid_name?: (untyped name) -> bool
7
- def self.printable: (untyped text) -> String
8
-
9
- class Error < StandardError
10
- attr_reader exit_code: Integer
11
-
12
- def initialize: (String message, ?exit_code: Integer) -> void
13
- end
14
-
15
- class Project
16
- UNSUPPORTED: String
17
- GITIGNORE_LINES: Array[String]
18
- TEMPLATES: Hash[String, String]
19
- PIN: Regexp
20
- FLOWS_PIN: Array[Integer]
21
-
22
- attr_reader root: String
23
-
24
- def initialize: (String path) -> void
25
- def path: (String relative) -> String
26
- def flow_file: (String name) -> String
27
- def flow_names: () -> Array[String]
28
- def set_up?: () -> bool
29
- def unsupported_reason: () -> String?
30
- def init: (?out: IO | StringIO, ?err: IO | StringIO) -> Integer
31
-
32
- private
33
-
34
- def template_dir: () -> String
35
- def read: (String relative) -> String?
36
- def selenium?: (String gemfile) -> bool
37
- def runtime?: () -> bool
38
- def old_pin?: (String gemfile) -> bool
39
- def write_templates: () -> Array[String]
40
- def add_gitignore_lines: () -> Array[String]
41
- def pin_gem: () -> bool
42
- def warn_unknown_pin: (IO | StringIO err) -> void
43
- def report: (IO | StringIO out, Array[String] created, Array[String] updated, bool pinned) -> void
44
- end
45
- end
46
- end