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,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
|