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,206 @@
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
@@ -0,0 +1,221 @@
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
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'securerandom'
5
+ require 'yaml'
6
+ require_relative 'schedule'
7
+
8
+ module RubyRaider
9
+ module Flows
10
+ # .raider/flows/schedules.yml: the project's scheduled flows, sorted by name. The OS entries are made
11
+ # from it; it is written whole through a temp file and a rename, so a reader never sees half a file.
12
+ # :reek:MissingSafeMethod { exclude: [ writable! ] } -- a check that raises, with no safe twin to offer
13
+ class ScheduleStore
14
+ DIR = '.raider/flows'
15
+ FILE = 'schedules.yml'
16
+ NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
17
+ NONBLOCK = defined?(File::NONBLOCK) ? File::NONBLOCK : 0
18
+
19
+ # What delete returns for an entry it removed but could not read: enough to remove its OS entry
20
+ class Removed
21
+ attr_reader :name, :os_id
22
+
23
+ def initialize(name, os_id)
24
+ @name = name
25
+ @os_id = os_id
26
+ freeze
27
+ end
28
+ end
29
+
30
+ def initialize(project)
31
+ @project = project
32
+ @file = project.path("#{DIR}/#{FILE}")
33
+ end
34
+
35
+ # Every valid entry, sorted by name; a broken entry or file is skipped
36
+ def all
37
+ entries = read_entries
38
+ return [] unless entries
39
+
40
+ entries.filter_map { |entry| from_entry(entry) }.sort_by(&:name)
41
+ end
42
+
43
+ def find(name) = all.find { |schedule| schedule.name == name }
44
+
45
+ # Adds the schedule, or replaces the one with its name
46
+ def save(schedule)
47
+ write(writable_entries.reject { |entry| named?(entry, schedule.name) } + [schedule.to_h])
48
+ schedule
49
+ end
50
+
51
+ # Raises the Error save and delete would, before anything else is changed
52
+ def writable!
53
+ writable_entries
54
+ nil
55
+ end
56
+
57
+ # Removes every entry with the name, readable or not. Returns the removed schedule, a Removed
58
+ # (name and os_id) when the entry could not be read, or nil when there was none.
59
+ def delete(name)
60
+ entries = writable_entries
61
+ named, kept = entries.partition { |entry| named?(entry, name) }
62
+ return nil if named.empty?
63
+
64
+ write(kept)
65
+ named.filter_map { |entry| from_entry(entry) }.first || Removed.new(name, Schedule.os_id_for(@project.root, name))
66
+ end
67
+
68
+ private
69
+
70
+ def from_entry(entry)
71
+ Schedule.from_h(entry, @project.root)
72
+ rescue Error
73
+ nil
74
+ end
75
+
76
+ def named?(entry, name) = entry.is_a?(Hash) && entry['name'] == name
77
+
78
+ # The raw entries, nil when the file is not a schedules file or can't be read (missing reads as empty)
79
+ def read_entries
80
+ text = read_text
81
+ return [] if text.nil?
82
+ return nil unless text
83
+
84
+ data = YAML.safe_load(text)
85
+ return [] if data.nil?
86
+
87
+ entries = data.is_a?(Hash) ? data['schedules'] : nil
88
+ entries.is_a?(Array) ? entries : nil
89
+ rescue Psych::Exception
90
+ nil
91
+ end
92
+
93
+ # The entries to rewrite, keeping ones this version can't read; refuses to overwrite a broken file
94
+ def writable_entries
95
+ read_entries || raise(Error, "#{DIR}/#{FILE} is not a schedules file: fix or remove it, then try again")
96
+ end
97
+
98
+ # Read through one handle that is not a link; nil when there is no file, false when it is a link,
99
+ # not a regular file or can't be read (so a write never replaces what it could not see)
100
+ def read_text
101
+ File.open(@file, File::RDONLY | NONBLOCK | NOFOLLOW) do |file|
102
+ file.stat.file? ? file.read.to_s.force_encoding(Encoding::UTF_8) : false
103
+ end
104
+ rescue Errno::ENOENT
105
+ nil
106
+ rescue SystemCallError
107
+ false
108
+ end
109
+
110
+ def write(entries)
111
+ dir = private_dir
112
+ sorted = entries.sort_by { |entry| entry.is_a?(Hash) ? entry['name'].to_s : '' }
113
+ temp = File.join(dir, ".#{FILE}.#{SecureRandom.hex(6)}.tmp")
114
+ File.open(temp, File::WRONLY | File::CREAT | File::EXCL | NOFOLLOW, 0o600) do |file|
115
+ file.write(YAML.dump({ 'schedules' => sorted }))
116
+ file.flush
117
+ file.fsync
118
+ end
119
+ File.rename(temp, @file)
120
+ ensure
121
+ FileUtils.rm_f(temp) if temp && File.exist?(temp)
122
+ end
123
+
124
+ # A 0700 folder inside the project that is not a link, as flows/support/run.rb makes it
125
+ def private_dir
126
+ dir = @project.path(DIR)
127
+ FileUtils.mkdir_p(dir)
128
+ raise Error, "#{DIR} must be a folder, not a link" unless File.lstat(dir).directory?
129
+
130
+ File.chmod(0o700, dir)
131
+ dir
132
+ end
133
+ end
134
+ end
135
+ end