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.
- checksums.yaml +4 -4
- data/.github/workflows/e2e_tests.yml +3 -0
- data/README.md +52 -53
- data/RELEASE.md +14 -1
- data/lib/commands/schedule_commands.rb +84 -0
- data/lib/generators/templates/common/git_ignore.tt +1 -0
- data/lib/ruby_raider/runtime/retry_reset.rb +32 -0
- data/lib/ruby_raider/runtime/rspec.rb +1 -0
- data/lib/ruby_raider/runtime/show_browser.rb +53 -0
- data/lib/ruby_raider/runtime.rb +2 -0
- data/lib/ruby_raider.rb +22 -7
- data/lib/utilities/schedules/adapter.rb +22 -0
- data/lib/utilities/schedules/base.rb +27 -0
- data/lib/utilities/schedules/kind.rb +74 -0
- data/lib/utilities/schedules/legacy.rb +196 -0
- data/lib/utilities/schedules/legacy_stub.rb +62 -0
- data/lib/utilities/{flows → schedules}/next_run.rb +2 -2
- data/lib/utilities/{flows → schedules}/notifier.rb +6 -6
- data/lib/utilities/{flows → schedules}/os/command_runner.rb +1 -1
- data/lib/utilities/{flows → schedules}/os/cron.rb +27 -17
- data/lib/utilities/{flows → schedules}/os/launchd.rb +32 -14
- data/lib/utilities/{flows → schedules}/os/linux.rb +23 -5
- data/lib/utilities/{flows → schedules}/os/systemd.rb +33 -17
- data/lib/utilities/{flows → schedules}/os/task_scheduler.rb +36 -20
- data/lib/utilities/schedules/os_enumerator.rb +30 -0
- data/lib/utilities/schedules/project.rb +70 -0
- data/lib/utilities/schedules/rule.rb +162 -0
- data/lib/utilities/schedules/run_lock.rb +160 -0
- data/lib/utilities/schedules/run_log.rb +122 -0
- data/lib/utilities/schedules/run_report.rb +139 -0
- data/lib/utilities/schedules/run_wrapper.rb +216 -0
- data/lib/utilities/schedules/schedule.rb +89 -0
- data/lib/utilities/schedules/schedule_store.rb +53 -0
- data/lib/utilities/schedules/scheduler.rb +178 -0
- data/lib/utilities/schedules/scheduler_steps.rb +69 -0
- data/lib/utilities/schedules/selection.rb +133 -0
- data/lib/utilities/schedules/spawn.rb +68 -0
- data/lib/utilities/{flows/schedule_store.rb → schedules/store.rb} +17 -13
- data/lib/version +1 -1
- data/ruby_raider.gemspec +1 -0
- data/sig/commands/schedule_commands.rbs +12 -0
- data/sig/ruby_raider/runtime/retry_reset.rbs +7 -0
- data/sig/ruby_raider/runtime/show_browser.rbs +27 -0
- data/sig/ruby_raider.rbs +4 -0
- data/sig/utilities/schedules/adapter.rbs +5 -0
- data/sig/utilities/schedules/base.rbs +15 -0
- data/sig/utilities/schedules/kind.rbs +32 -0
- data/sig/utilities/schedules/legacy.rbs +45 -0
- data/sig/utilities/schedules/legacy_stub.rbs +15 -0
- data/sig/utilities/{flows → schedules}/next_run.rbs +3 -3
- data/sig/utilities/{flows → schedules}/notifier.rbs +1 -1
- data/sig/utilities/{flows → schedules}/os/command_runner.rbs +1 -1
- data/sig/utilities/{flows → schedules}/os/cron.rbs +7 -7
- data/sig/utilities/{flows → schedules}/os/launchd.rbs +7 -6
- data/sig/utilities/{flows → schedules}/os/linux.rbs +10 -4
- data/sig/utilities/{flows → schedules}/os/systemd.rbs +8 -7
- data/sig/utilities/{flows → schedules}/os/task_scheduler.rbs +13 -10
- data/sig/utilities/schedules/os_enumerator.rbs +12 -0
- data/sig/utilities/schedules/project.rbs +22 -0
- data/sig/utilities/{flows/schedule.rbs → schedules/rule.rbs} +23 -12
- data/sig/utilities/schedules/run_lock.rbs +29 -0
- data/sig/utilities/schedules/run_log.rbs +33 -0
- data/sig/utilities/schedules/run_report.rbs +34 -0
- data/sig/utilities/schedules/run_wrapper.rbs +45 -0
- data/sig/utilities/schedules/schedule.rbs +27 -0
- data/sig/utilities/schedules/schedule_store.rbs +21 -0
- data/sig/utilities/{flows → schedules}/scheduler.rbs +22 -15
- data/sig/utilities/schedules/scheduler_steps.rbs +22 -0
- data/sig/utilities/schedules/selection.rbs +36 -0
- data/sig/utilities/schedules/spawn.rbs +10 -0
- data/sig/utilities/{flows/schedule_store.rbs → schedules/store.rbs} +14 -9
- data/spec/commands/schedule_commands_spec.rb +284 -0
- data/spec/integration/content/common_content_spec.rb +8 -2
- data/spec/integration/content/gemfile_content_spec.rb +2 -2
- data/spec/integration/end_to_end_spec.rb +0 -63
- data/spec/runtime/retry_reset_spec.rb +112 -0
- data/spec/runtime/show_browser_project_spec.rb +70 -0
- data/spec/runtime/show_browser_spec.rb +101 -0
- data/spec/utilities/schedules/kind_spec.rb +81 -0
- data/spec/utilities/schedules/legacy_spec.rb +433 -0
- data/spec/utilities/schedules/legacy_stub_spec.rb +144 -0
- data/spec/utilities/{flows → schedules}/next_run_spec.rb +5 -5
- data/spec/utilities/{flows → schedules}/notifier_spec.rb +3 -4
- data/spec/utilities/{flows → schedules}/os/command_runner_spec.rb +2 -3
- data/spec/utilities/{flows → schedules}/os/cron_spec.rb +34 -29
- data/spec/utilities/{flows → schedules}/os/launchd_spec.rb +42 -29
- data/spec/utilities/{flows → schedules}/os/linux_spec.rb +27 -13
- data/spec/utilities/schedules/os/list_ids_spec.rb +202 -0
- data/spec/utilities/{flows → schedules}/os/systemd_spec.rb +35 -31
- data/spec/utilities/{flows → schedules}/os/task_scheduler_spec.rb +40 -34
- data/spec/utilities/schedules/os_entry_spec.rb +54 -0
- data/spec/utilities/schedules/os_enumerator_spec.rb +57 -0
- data/spec/utilities/schedules/project_spec.rb +125 -0
- data/spec/utilities/schedules/run_lock_spec.rb +143 -0
- data/spec/utilities/schedules/run_log_spec.rb +83 -0
- data/spec/utilities/schedules/run_report_spec.rb +150 -0
- data/spec/utilities/schedules/run_wrapper_integration_spec.rb +166 -0
- data/spec/utilities/schedules/run_wrapper_spec.rb +430 -0
- data/spec/utilities/schedules/schedule_spec.rb +120 -0
- data/spec/utilities/schedules/schedule_store_spec.rb +151 -0
- data/spec/utilities/schedules/scheduler_launchd_spec.rb +77 -0
- data/spec/utilities/schedules/scheduler_spec.rb +445 -0
- data/spec/utilities/schedules/selection_spec.rb +176 -0
- data/spec/utilities/schedules/spawn_spec.rb +104 -0
- metadata +106 -53
- data/lib/commands/flow_commands.rb +0 -104
- data/lib/generators/templates/flows/flow_helper.tt +0 -42
- data/lib/generators/templates/flows/run.tt +0 -332
- data/lib/utilities/flows/project.rb +0 -170
- data/lib/utilities/flows/run_log.rb +0 -61
- data/lib/utilities/flows/runner.rb +0 -206
- data/lib/utilities/flows/schedule.rb +0 -221
- data/lib/utilities/flows/scheduler.rb +0 -187
- data/sig/commands/flow_commands.rbs +0 -13
- data/sig/utilities/flows/project.rbs +0 -46
- data/sig/utilities/flows/run_log.rbs +0 -22
- data/sig/utilities/flows/runner.rbs +0 -35
- data/spec/commands/flow_commands_spec.rb +0 -128
- data/spec/generators/flow_helper_spec.rb +0 -40
- data/spec/integration/flows/flow_project.rb +0 -62
- data/spec/integration/flows/run_command_spec.rb +0 -91
- data/spec/integration/flows/run_lifecycle_spec.rb +0 -383
- data/spec/utilities/flows/project_spec.rb +0 -178
- data/spec/utilities/flows/run_log_spec.rb +0 -47
- data/spec/utilities/flows/runner_spec.rb +0 -313
- data/spec/utilities/flows/schedule_spec.rb +0 -233
- data/spec/utilities/flows/schedule_store_spec.rb +0 -137
- data/spec/utilities/flows/scheduler_spec.rb +0 -329
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require_relative 'adapter'
|
|
5
|
+
require_relative 'os_enumerator'
|
|
6
|
+
require_relative 'rule'
|
|
7
|
+
|
|
8
|
+
module RubyRaider
|
|
9
|
+
module Schedules
|
|
10
|
+
# Removes the schedules Ruby Raider 4.1 made in a project (Kind::FLOW_LEGACY): they can't run in 4.2.
|
|
11
|
+
# Every `raider schedule` command runs it first, and so does the hidden stub an old OS entry reaches.
|
|
12
|
+
# It only ever acts on this project's os_ids, each recomputed from the project's real path and a name
|
|
13
|
+
# (from the old schedules.yml or the OS scheduler); an os_id stored in the yml is never used. It deletes
|
|
14
|
+
# the old schedules.yml once every entry is gone, and nothing else, and only when the file is a 4.1
|
|
15
|
+
# schedules file all of whose entries are this project's: a file it can't read, or one with an entry
|
|
16
|
+
# of another path (a moved project), stays, since its os_ids are what the user needs to remove by hand.
|
|
17
|
+
# An adapter's error text is passed through as is on purpose: it names the os_id the README says to look for.
|
|
18
|
+
# Never raises.
|
|
19
|
+
# :reek:TooManyConstants { max_constants: 6 } -- the kind, file, size cap, open flags and the old rule
|
|
20
|
+
# :reek:TooManyInstanceVariables { max_instance_variables: 5 } -- the project, its root, adapter, stream and whether the yml may go
|
|
21
|
+
class Legacy
|
|
22
|
+
KIND = Kind::FLOW_LEGACY
|
|
23
|
+
FILE = 'schedules.yml'
|
|
24
|
+
MAX_BYTES = 1_000_000
|
|
25
|
+
NOFOLLOW = defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0
|
|
26
|
+
NONBLOCK = defined?(File::NONBLOCK) ? File::NONBLOCK : 0
|
|
27
|
+
|
|
28
|
+
# The 4.1 rule, for its os_ids only (os_id_for, os_id_prefix_for)
|
|
29
|
+
class OldRule < Rule
|
|
30
|
+
def self.kind = KIND
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# project: a Project (its real root); adapter: nil picks this machine's OS scheduler when first needed
|
|
34
|
+
def initialize(project, adapter: nil, err: $stderr)
|
|
35
|
+
@project = project
|
|
36
|
+
@root = project.root
|
|
37
|
+
@adapter = adapter
|
|
38
|
+
@err = err
|
|
39
|
+
@yml_clean = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# True when the project has a real .raider/flows folder (4.1 always made one; this never deletes it):
|
|
43
|
+
# without one there is nothing to clean, so `raider schedule` skips the cleanup for one lstat
|
|
44
|
+
def self.folder?(project)
|
|
45
|
+
File.lstat(File.join(project.root, KIND.dir)).directory?
|
|
46
|
+
rescue SystemCallError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Removes every 4.1 schedule of the project, then its schedules.yml. Prints and returns the notice, nil
|
|
51
|
+
# when there was nothing to remove. last: the name of the entry running this (the stub's own), removed
|
|
52
|
+
# after the notice is written and the yml deleted, since removing it may end this process.
|
|
53
|
+
def cleanup(last: nil)
|
|
54
|
+
current = adapter
|
|
55
|
+
return nil unless current
|
|
56
|
+
|
|
57
|
+
names = (yml_names | OsEnumerator.new(current, schedule_class: OldRule, root: @root).names).sort
|
|
58
|
+
own = names.delete(last) if last
|
|
59
|
+
removed, failed = remove_all(current, names)
|
|
60
|
+
removed << own if own
|
|
61
|
+
delete_yml if failed.empty?
|
|
62
|
+
notice = notice(removed, failed)
|
|
63
|
+
say(notice) if notice
|
|
64
|
+
remove_own(current, own) if own
|
|
65
|
+
notice
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
say("Could not remove the schedules made with Ruby Raider 4.1: #{e.message}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def adapter
|
|
73
|
+
@adapter ||= Schedules.adapter(kind: KIND)
|
|
74
|
+
rescue Error
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# [removed names, [name, message] of each that failed]; one already gone is neither
|
|
79
|
+
def remove_all(current, names)
|
|
80
|
+
removed = []
|
|
81
|
+
failed = []
|
|
82
|
+
names.each do |name|
|
|
83
|
+
removed << name if current.remove(OldRule.os_id_for(@root, name))
|
|
84
|
+
rescue Error, SystemCallError => e
|
|
85
|
+
failed << [name, e.message]
|
|
86
|
+
end
|
|
87
|
+
[removed, failed]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Its own entry last: the notice is out (and flushed) and the yml gone before this may end the process
|
|
91
|
+
def remove_own(current, name)
|
|
92
|
+
current.remove(OldRule.os_id_for(@root, name))
|
|
93
|
+
rescue Error, SystemCallError => e
|
|
94
|
+
say("Could not remove #{name}: #{e.message}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def notice(removed, failed)
|
|
98
|
+
lines = []
|
|
99
|
+
lines << removed_text(removed) unless removed.empty?
|
|
100
|
+
lines << failed_text(failed) unless failed.empty?
|
|
101
|
+
lines.empty? ? nil : Schedules.printable(lines.join(' '))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def removed_text(names)
|
|
105
|
+
one = names.size == 1
|
|
106
|
+
"Removed #{counted(names.size)} made with Ruby Raider 4.1 (#{names.join(', ')}); #{one ? 'it' : 'they'} can't run in 4.2. " \
|
|
107
|
+
"Add #{one ? 'it' : 'them'} again with raider schedule add."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def failed_text(failed)
|
|
111
|
+
reasons = failed.map { |name, message| "#{name}: #{message}" }.join(', ')
|
|
112
|
+
"Could not remove #{counted(failed.size)} made with Ruby Raider 4.1 (#{reasons}); " \
|
|
113
|
+
"#{failed.size == 1 ? 'it is' : 'they are'} tried again next time."
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def counted(count) = count == 1 ? '1 schedule' : "#{count} schedules"
|
|
117
|
+
|
|
118
|
+
# One printable line on err, flushed; returns it
|
|
119
|
+
def say(text)
|
|
120
|
+
line = Schedules.printable(text)
|
|
121
|
+
err = @err
|
|
122
|
+
err.puts(line)
|
|
123
|
+
err.flush if err.respond_to?(:flush)
|
|
124
|
+
line
|
|
125
|
+
rescue StandardError
|
|
126
|
+
line
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def dir = File.join(@root, KIND.dir)
|
|
130
|
+
|
|
131
|
+
def yml = File.join(dir, FILE)
|
|
132
|
+
|
|
133
|
+
# .raider and .raider/flows are real folders, not links (a missing one is one failed lstat)
|
|
134
|
+
def real_dirs? = [File.join(@root, '.raider'), dir].all? { |path| File.lstat(path).directory? }
|
|
135
|
+
|
|
136
|
+
# The names of the yml's entries that 4.1 would read as this project's: valid, with the os_id it
|
|
137
|
+
# computes for this project; anything else in the file, or a file that is not a schedules file, gives
|
|
138
|
+
# none. The yml may go only when it was a schedules file and every entry was one of these.
|
|
139
|
+
def yml_names
|
|
140
|
+
entries = read_entries
|
|
141
|
+
return [] unless entries
|
|
142
|
+
|
|
143
|
+
names = entries.map { |entry| entry_name(entry) }
|
|
144
|
+
@yml_clean = names.all?
|
|
145
|
+
names.compact.uniq
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def entry_name(entry)
|
|
149
|
+
return nil unless entry.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
name = entry['name']
|
|
152
|
+
name if name.is_a?(String) && KIND.valid_name?(name) && entry['os_id'] == OldRule.os_id_for(@root, name)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The entries of a schedules file ([] for an empty document); nil when there is none or it is not one
|
|
156
|
+
def read_entries
|
|
157
|
+
text = read_text
|
|
158
|
+
return nil unless text
|
|
159
|
+
|
|
160
|
+
data = YAML.safe_load(text)
|
|
161
|
+
return [] unless data
|
|
162
|
+
|
|
163
|
+
entries = data.is_a?(Hash) ? data['schedules'] : nil
|
|
164
|
+
entries.is_a?(Array) ? entries : nil
|
|
165
|
+
rescue Psych::Exception
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The yml through one handle that is not a link, a regular file up to MAX_BYTES; nil otherwise.
|
|
170
|
+
# The folders are lstat'ed and then the file opened (and later unlinked) by path, so a folder swapped
|
|
171
|
+
# for a link in between is followed: that takes write access to the project, and Ruby has no
|
|
172
|
+
# openat/unlinkat to close the window.
|
|
173
|
+
def read_text
|
|
174
|
+
return nil unless real_dirs?
|
|
175
|
+
|
|
176
|
+
File.open(yml, File::RDONLY | NONBLOCK | NOFOLLOW) do |file|
|
|
177
|
+
next nil unless file.stat.file?
|
|
178
|
+
|
|
179
|
+
text = file.read(MAX_BYTES + 1) || +'' # an empty file reads nil
|
|
180
|
+
text.bytesize > MAX_BYTES ? nil : text.force_encoding(Encoding::UTF_8)
|
|
181
|
+
end
|
|
182
|
+
rescue SystemCallError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The yml only, when it was read as a clean schedules file and the folders and it still are real
|
|
187
|
+
# (a link or folder stays)
|
|
188
|
+
def delete_yml
|
|
189
|
+
path = yml
|
|
190
|
+
File.unlink(path) if @yml_clean && real_dirs? && File.lstat(path).file?
|
|
191
|
+
rescue SystemCallError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'legacy'
|
|
4
|
+
require_relative 'project'
|
|
5
|
+
|
|
6
|
+
module RubyRaider
|
|
7
|
+
module Schedules
|
|
8
|
+
# What a Ruby Raider 4.1 OS entry reaches now: `raider flow run NAME --project ROOT --trigger schedule ...`.
|
|
9
|
+
# Raider.start sends it here before Thor, so any argv works. It removes the project's 4.1 schedules (its
|
|
10
|
+
# own entry last), writes one line to stderr (the entry's scheduler.log) and always exits 2. It never
|
|
11
|
+
# sends a notification and never takes the project from the current folder.
|
|
12
|
+
module LegacyStub
|
|
13
|
+
EXIT = 2
|
|
14
|
+
REMOVED = 'This command was removed in Ruby Raider 4.2. To run tests on a schedule, use raider schedule add.'
|
|
15
|
+
OPTION = '--project'
|
|
16
|
+
|
|
17
|
+
# The exit code: always 2
|
|
18
|
+
def self.run(argv, err: $stderr)
|
|
19
|
+
root = project_root(argv)
|
|
20
|
+
root ? clean(root, own_name(argv), err) : err.puts(REMOVED)
|
|
21
|
+
EXIT
|
|
22
|
+
rescue StandardError
|
|
23
|
+
EXIT
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.clean(root, name, err)
|
|
27
|
+
return if Legacy.new(Project.new(root), err: err).cleanup(last: name)
|
|
28
|
+
|
|
29
|
+
err.puts(Schedules.printable("This schedule was made with Ruby Raider 4.1 and can't run in 4.2, and none was found for #{root} " \
|
|
30
|
+
'to remove: see Upgrading from 4.1 in the README.'))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The real path of the first --project value: valid UTF-8, absolute, a folder with a regular Gemfile
|
|
34
|
+
def self.project_root(argv)
|
|
35
|
+
path = project_value(argv)
|
|
36
|
+
return nil unless path.is_a?(String) && path.valid_encoding? && !path.include?("\0") && File.absolute_path?(path)
|
|
37
|
+
|
|
38
|
+
root = File.realpath(path)
|
|
39
|
+
File.directory?(root) && File.lstat(File.join(root, 'Gemfile')).file? ? root : nil
|
|
40
|
+
rescue SystemCallError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# --project VALUE or --project=VALUE, whichever comes first
|
|
45
|
+
def self.project_value(argv)
|
|
46
|
+
argv.each_with_index do |arg, index|
|
|
47
|
+
next unless arg.is_a?(String) && arg.valid_encoding?
|
|
48
|
+
return argv[index + 1] if arg == OPTION
|
|
49
|
+
return arg.delete_prefix("#{OPTION}=") if arg.start_with?("#{OPTION}=")
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The name after the first run, when it is a valid 4.1 name
|
|
55
|
+
def self.own_name(argv)
|
|
56
|
+
index = argv.index('run')
|
|
57
|
+
name = index && argv[index + 1]
|
|
58
|
+
name if name.is_a?(String) && name.valid_encoding? && Legacy::KIND.valid_name?(name)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'date'
|
|
4
|
-
require_relative '
|
|
4
|
+
require_relative 'base'
|
|
5
5
|
|
|
6
6
|
module RubyRaider
|
|
7
|
-
module
|
|
7
|
+
module Schedules
|
|
8
8
|
# When a schedule next runs, in local time, as launchd, schtasks, systemd and cron count it.
|
|
9
9
|
# A day, weekday or week rule whose time falls in a spring-forward gap runs at the first minute
|
|
10
10
|
# after the gap; one that falls in a fall-back hour runs at its first occurrence only.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'rbconfig'
|
|
4
|
-
require_relative '
|
|
4
|
+
require_relative 'base'
|
|
5
5
|
require_relative 'os/command_runner'
|
|
6
6
|
|
|
7
7
|
module RubyRaider
|
|
8
|
-
module
|
|
9
|
-
# A desktop notification for
|
|
8
|
+
module Schedules
|
|
9
|
+
# A desktop notification for a scheduled run with --notify. The title and message only ever travel as argv
|
|
10
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
|
|
11
|
+
# environment values (PowerShell), so a hostile schedule name or test title can't run as AppleScript or PowerShell. Never raises: a notification
|
|
12
12
|
# that can't be shown must not change how the run ends.
|
|
13
13
|
# :reek:TooManyConstants { max_constants: 7 } -- the fixed command of each OS, the message cap and the timeout
|
|
14
14
|
# :reek:TooManyInstanceVariables { max_instance_variables: 5 } -- the injected runner, OS, stream and PowerShell path, and the warned flag
|
|
@@ -64,10 +64,10 @@ module RubyRaider
|
|
|
64
64
|
|
|
65
65
|
private
|
|
66
66
|
|
|
67
|
-
def clean(text) =
|
|
67
|
+
def clean(text) = Schedules.printable(text)[0, MAX].to_s
|
|
68
68
|
|
|
69
69
|
def failed(reason)
|
|
70
|
-
@err.puts("Could not show a desktop notification: #{
|
|
70
|
+
@err.puts("Could not show a desktop notification: #{Schedules.printable(reason.strip)}") unless @warned
|
|
71
71
|
@warned = true
|
|
72
72
|
false
|
|
73
73
|
end
|
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
require 'shellwords'
|
|
4
4
|
require_relative 'command_runner'
|
|
5
|
-
require_relative '../
|
|
5
|
+
require_relative '../base'
|
|
6
6
|
|
|
7
7
|
module RubyRaider
|
|
8
|
-
module
|
|
8
|
+
module Schedules
|
|
9
9
|
module Os
|
|
10
10
|
# Linux without a systemd user manager: one tagged block per schedule in the user's crontab, read with
|
|
11
11
|
# `crontab -l` and written back with `crontab -`. Every value goes through Shellwords.escape, which also
|
|
12
12
|
# escapes % as \% (cron would turn a bare % into a newline). Lines that are not ours keep every byte.
|
|
13
13
|
class Cron
|
|
14
14
|
CRONTAB = 'crontab'
|
|
15
|
-
TAG = '# raider-flow '
|
|
16
15
|
# Debian's vixie cron cuts a command at MAX_COMMAND (1000) bytes without a word; stay under it
|
|
17
16
|
MAX_LINE = 990
|
|
18
|
-
OS_ID = /\A#{Regexp.escape(Schedule::OS_ID_PREFIX)}\.[0-9a-f]{10}\.([a-z0-9_]{1,60})\z/
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
# kind: the Kind whose entries this crontab block holds
|
|
19
|
+
def initialize(kind:, runner: CommandRunner.new)
|
|
20
|
+
@kind = kind
|
|
21
21
|
@runner = runner
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -28,7 +28,7 @@ module RubyRaider
|
|
|
28
28
|
# Minute hour day-of-month month day-of-week (the system time zone), then the command for /bin/sh
|
|
29
29
|
def line(schedule)
|
|
30
30
|
ruby = schedule.ruby
|
|
31
|
-
raise Error, "cron can not run ruby from #{
|
|
31
|
+
raise Error, "cron can not run ruby from #{Schedules.printable(ruby)}: env would read the = as a variable" if ruby.include?('=')
|
|
32
32
|
|
|
33
33
|
env = schedule.env.map { |key, value| "#{key}=#{value}" }
|
|
34
34
|
command = ['cd', escape(schedule.working_directory), '&&', '/usr/bin/env', *[*env, *schedule.argv].map { |word| escape(word) },
|
|
@@ -43,7 +43,7 @@ module RubyRaider
|
|
|
43
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
44
|
|
|
45
45
|
rest, = without(read, os_id)
|
|
46
|
-
save("#{
|
|
46
|
+
save("#{@kind.cron_tag}#{os_id}\n#{entry}\n#{rest}")
|
|
47
47
|
true
|
|
48
48
|
end
|
|
49
49
|
|
|
@@ -60,6 +60,16 @@ module RubyRaider
|
|
|
60
60
|
without(read, tag).last
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# The os_ids of this kind starting with prefix on our tag lines, sorted
|
|
64
|
+
def list_ids(prefix)
|
|
65
|
+
tag = @kind.cron_tag
|
|
66
|
+
read.dup.force_encoding(Encoding::UTF_8).scrub.each_line.filter_map do |text|
|
|
67
|
+
line = text.chomp
|
|
68
|
+
id = line.delete_prefix(tag)
|
|
69
|
+
id if id != line && @kind.listed?(id, prefix)
|
|
70
|
+
end.uniq.sort
|
|
71
|
+
end
|
|
72
|
+
|
|
63
73
|
# True when crontab -l answers (an empty crontab counts)
|
|
64
74
|
def usable?
|
|
65
75
|
read
|
|
@@ -84,7 +94,7 @@ module RubyRaider
|
|
|
84
94
|
|
|
85
95
|
# cron counts Sunday as 0
|
|
86
96
|
def weekday(day)
|
|
87
|
-
index =
|
|
97
|
+
index = DAYS.index(day)
|
|
88
98
|
raise Error, "Invalid day #{day.inspect}" unless index
|
|
89
99
|
|
|
90
100
|
(index + 1) % 7
|
|
@@ -95,8 +105,8 @@ module RubyRaider
|
|
|
95
105
|
# The crontab without any of our tag lines, each with the line after it when that line is our command,
|
|
96
106
|
# and whether a tag was there
|
|
97
107
|
def without(table, os_id)
|
|
98
|
-
tag = "#{
|
|
99
|
-
name =
|
|
108
|
+
tag = "#{@kind.cron_tag}#{os_id}"
|
|
109
|
+
name = @kind.os_id_pattern.match(os_id).to_a[1]
|
|
100
110
|
kept = []
|
|
101
111
|
found = after_tag = false
|
|
102
112
|
table.lines.each do |text|
|
|
@@ -108,28 +118,28 @@ module RubyRaider
|
|
|
108
118
|
[kept.join, found]
|
|
109
119
|
end
|
|
110
120
|
|
|
111
|
-
# The line under our tag runs `raider
|
|
112
|
-
def our_command?(text, name) =
|
|
121
|
+
# The line under our tag runs `raider <command> run <name> --project ... --trigger schedule`
|
|
122
|
+
def our_command?(text, name) = @kind.our_command?(text, name)
|
|
113
123
|
|
|
114
124
|
def read
|
|
115
125
|
result = @runner.call(CRONTAB, '-l')
|
|
116
126
|
return result.stdout if result.success?
|
|
117
127
|
|
|
118
|
-
stderr = result.stderr
|
|
128
|
+
stderr = result.stderr.scrub('?')
|
|
119
129
|
return '' if stderr.match?(/no crontab/i)
|
|
120
130
|
|
|
121
|
-
raise Error, "cron could not read the crontab: #{
|
|
131
|
+
raise Error, "cron could not read the crontab: #{Schedules.printable(stderr.strip)}"
|
|
122
132
|
end
|
|
123
133
|
|
|
124
134
|
def save(table)
|
|
125
135
|
result = @runner.call(CRONTAB, '-', stdin_data: table)
|
|
126
|
-
raise Error, "cron could not save the crontab: #{
|
|
136
|
+
raise Error, "cron could not save the crontab: #{Schedules.printable(result.stderr.strip)}" unless result.success?
|
|
127
137
|
end
|
|
128
138
|
|
|
129
139
|
def checked_os_id(os_id)
|
|
130
|
-
return os_id if
|
|
140
|
+
return os_id if @kind.name_in(os_id)
|
|
131
141
|
|
|
132
|
-
raise Error, "Invalid os_id #{
|
|
142
|
+
raise Error, "Invalid os_id #{Schedules.printable(os_id.inspect)}: #{@kind.not_ours}"
|
|
133
143
|
end
|
|
134
144
|
end
|
|
135
145
|
end
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'securerandom'
|
|
5
5
|
require_relative 'command_runner'
|
|
6
|
-
require_relative '../
|
|
6
|
+
require_relative '../base'
|
|
7
7
|
|
|
8
8
|
module RubyRaider
|
|
9
|
-
module
|
|
9
|
+
module Schedules
|
|
10
10
|
module Os
|
|
11
11
|
# macOS: a LaunchAgent plist in ~/Library/LaunchAgents per schedule, loaded into the user's GUI
|
|
12
12
|
# domain with launchctl. Every value in the plist goes through one XML escaper.
|
|
13
|
-
# :reek:TooManyConstants { max_constants:
|
|
13
|
+
# :reek:TooManyConstants { max_constants: 5 } -- the two tools, NOFOLLOW, the XML escapes and header
|
|
14
14
|
class Launchd
|
|
15
15
|
LAUNCHCTL = '/bin/launchctl'
|
|
16
16
|
PLUTIL = '/usr/bin/plutil'
|
|
@@ -21,9 +21,10 @@ module RubyRaider
|
|
|
21
21
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
22
22
|
<plist version="1.0">
|
|
23
23
|
XML
|
|
24
|
-
OS_ID = /\A#{Regexp.escape(Schedule::OS_ID_PREFIX)}\.[0-9a-f]{10}\.([a-z0-9_]{1,60})\z/
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
# kind: the Kind whose LaunchAgents this adapter writes and removes
|
|
26
|
+
def initialize(kind:, runner: CommandRunner.new, home: Dir.home, uid: Process.uid)
|
|
27
|
+
@kind = kind
|
|
27
28
|
@runner = runner
|
|
28
29
|
@home = home
|
|
29
30
|
@uid = uid
|
|
@@ -57,18 +58,35 @@ module RubyRaider
|
|
|
57
58
|
result = launchctl('bootstrap', domain, path)
|
|
58
59
|
return true if result.success?
|
|
59
60
|
|
|
60
|
-
raise Error, "launchd could not load #{os_id}: #{
|
|
61
|
+
raise Error, "launchd could not load #{os_id}: #{Schedules.printable(result.stderr.strip)}#{discard(path)}"
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
# True when a plist or a loaded service was removed, false when it was already gone
|
|
64
|
+
# True when a plist or a loaded service was removed, false when it was already gone. The plist goes
|
|
65
|
+
# first: a job removing itself is ended by its own bootout, and its plist must be gone by then.
|
|
66
|
+
# If the unlink succeeds and the bootout fails, the job stays loaded with no plist until logout.
|
|
64
67
|
def remove(os_id)
|
|
65
|
-
|
|
68
|
+
unlinked = unlink(plist_path(os_id))
|
|
66
69
|
unloaded = launchctl('bootout', service(os_id)).success?
|
|
67
|
-
|
|
70
|
+
unlinked || unloaded
|
|
68
71
|
end
|
|
69
72
|
|
|
70
73
|
def present?(os_id) = launchctl('print', service(checked_os_id(os_id))).success?
|
|
71
74
|
|
|
75
|
+
# The os_ids of this kind starting with prefix that have a plist, sorted; runs nothing
|
|
76
|
+
def list_ids(prefix)
|
|
77
|
+
dir = agents_dir
|
|
78
|
+
Dir.children(dir).filter_map do |file|
|
|
79
|
+
next unless file.valid_encoding? && file.end_with?('.plist')
|
|
80
|
+
|
|
81
|
+
id = file.delete_suffix('.plist')
|
|
82
|
+
id if @kind.listed?(id, prefix)
|
|
83
|
+
end.sort
|
|
84
|
+
rescue Errno::ENOENT
|
|
85
|
+
[]
|
|
86
|
+
rescue SystemCallError => e
|
|
87
|
+
raise Error, "Could not read #{dir}: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
|
|
72
90
|
private
|
|
73
91
|
|
|
74
92
|
def launchctl(*args) = @runner.call(LAUNCHCTL, *args)
|
|
@@ -90,9 +108,9 @@ module RubyRaider
|
|
|
90
108
|
def agents_dir = File.join(@home, 'Library', 'LaunchAgents')
|
|
91
109
|
|
|
92
110
|
def checked_os_id(os_id)
|
|
93
|
-
return os_id if
|
|
111
|
+
return os_id if @kind.name_in(os_id)
|
|
94
112
|
|
|
95
|
-
raise Error, "Invalid os_id #{
|
|
113
|
+
raise Error, "Invalid os_id #{Schedules.printable(os_id.inspect)}: #{@kind.not_ours}"
|
|
96
114
|
end
|
|
97
115
|
|
|
98
116
|
def escape(text) = text.to_s.gsub(/[&<>"']/, ESCAPES)
|
|
@@ -110,7 +128,7 @@ module RubyRaider
|
|
|
110
128
|
|
|
111
129
|
def indent(lines) = lines.map { |line| "\t#{line}" }
|
|
112
130
|
|
|
113
|
-
#
|
|
131
|
+
# DAYS runs mon..sun
|
|
114
132
|
def calendar(schedule)
|
|
115
133
|
time = [['Hour', integer(schedule.hour)], ['Minute', integer(schedule.minute)]]
|
|
116
134
|
case schedule.every
|
|
@@ -124,7 +142,7 @@ module RubyRaider
|
|
|
124
142
|
|
|
125
143
|
# launchd counts Sunday as 0
|
|
126
144
|
def weekday(day)
|
|
127
|
-
index =
|
|
145
|
+
index = DAYS.index(day)
|
|
128
146
|
raise Error, "Invalid day #{day.inspect}" unless index
|
|
129
147
|
|
|
130
148
|
(index + 1) % 7
|
|
@@ -150,7 +168,7 @@ module RubyRaider
|
|
|
150
168
|
file.chmod(0o644)
|
|
151
169
|
end
|
|
152
170
|
lint = @runner.call(PLUTIL, '-lint', temp)
|
|
153
|
-
raise Error, "launchd plist for #{File.basename(path)} is invalid: #{
|
|
171
|
+
raise Error, "launchd plist for #{File.basename(path)} is invalid: #{Schedules.printable("#{lint.stdout} #{lint.stderr}".strip)}" unless lint.success?
|
|
154
172
|
|
|
155
173
|
File.rename(temp, path)
|
|
156
174
|
rescue SystemCallError => e
|
|
@@ -5,16 +5,18 @@ require_relative 'systemd'
|
|
|
5
5
|
require_relative 'cron'
|
|
6
6
|
|
|
7
7
|
module RubyRaider
|
|
8
|
-
module
|
|
8
|
+
module Schedules
|
|
9
9
|
module Os
|
|
10
10
|
# Linux: systemd user timers when the user's systemd manager answers, cron otherwise. A schedule made over
|
|
11
11
|
# ssh (no user bus, so cron) must still go when unscheduled from a desktop session, so remove and present?
|
|
12
12
|
# also look in the other scheduler when it answers. The reverse (a systemd timer, unscheduled where no user
|
|
13
13
|
# bus answers) can only be seen from its unit files, and can only be removed where systemctl --user works.
|
|
14
14
|
class Linux
|
|
15
|
-
def self.pick(runner = CommandRunner.new) = new(runner: runner)
|
|
15
|
+
def self.pick(runner = CommandRunner.new, kind:) = new(kind: kind, runner: runner)
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
# kind: the Kind of the schedules this adapter installs and removes
|
|
18
|
+
def initialize(kind:, runner: CommandRunner.new, systemd: Systemd.new(kind: kind, runner: runner), cron: Cron.new(kind: kind, runner: runner))
|
|
19
|
+
@kind = kind
|
|
18
20
|
# systemd is the other one only when it answers, so with cron first there is no other one
|
|
19
21
|
@systemd = systemd
|
|
20
22
|
@primary, @other = systemd.usable? ? [systemd, cron] : [cron, nil]
|
|
@@ -38,16 +40,32 @@ module RubyRaider
|
|
|
38
40
|
# True when either scheduler has it, or systemd unit files are there though systemd does not answer
|
|
39
41
|
def present?(os_id) = @primary.present?(os_id) || other_present?(os_id) || stranded?(os_id)
|
|
40
42
|
|
|
43
|
+
# Both schedulers' ids plus systemd unit files (there even when systemd does not answer); only the
|
|
44
|
+
# primary scheduler failing is an error
|
|
45
|
+
def list_ids(prefix)
|
|
46
|
+
(@primary.list_ids(prefix) + other_ids(prefix) + quietly { @systemd.list_ids(prefix) }).uniq.sort
|
|
47
|
+
end
|
|
48
|
+
|
|
41
49
|
private
|
|
42
50
|
|
|
51
|
+
def other_ids(prefix)
|
|
52
|
+
other = @other
|
|
53
|
+
other&.usable? ? quietly { other.list_ids(prefix) } : []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def quietly
|
|
57
|
+
yield
|
|
58
|
+
rescue Error
|
|
59
|
+
[]
|
|
60
|
+
end
|
|
61
|
+
|
|
43
62
|
def stranded?(os_id) = @primary != @systemd && @systemd.unit_files?(os_id)
|
|
44
63
|
|
|
45
64
|
# Raises so the schedules.yml entry stays until the timer can really go
|
|
46
65
|
def refuse_stranded(os_id)
|
|
47
66
|
return unless stranded?(os_id)
|
|
48
67
|
|
|
49
|
-
raise Error,
|
|
50
|
-
'from a session where systemctl --user works'
|
|
68
|
+
raise Error, @kind.stranded(os_id.split('.').last)
|
|
51
69
|
end
|
|
52
70
|
|
|
53
71
|
# The other scheduler failing counts as not there
|