jobpayload 0.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.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module JobPayload
6
+ # Helpers for JSON-compatible data: deterministic serialization and deep copies.
7
+ module Canonical
8
+ module_function
9
+
10
+ # Returns a copy of +value+ with every Hash's keys sorted (recursively), so
11
+ # output never depends on Hash insertion order.
12
+ def sort_keys(value)
13
+ case value
14
+ when Hash
15
+ value.keys.sort_by(&:to_s).each_with_object({}) { |key, sorted| sorted[key] = sort_keys(value[key]) }
16
+ when Array
17
+ value.map { |element| sort_keys(element) }
18
+ else
19
+ value
20
+ end
21
+ end
22
+
23
+ # Pretty printed, key-sorted, UTF-8 JSON with LF newlines and a final newline.
24
+ def generate(value)
25
+ pretty(sort_keys(value))
26
+ end
27
+
28
+ # Pretty prints JSON-compatible data with two-space indentation, keeping
29
+ # Hash key order as given. The layout is produced here rather than by
30
+ # JSON.pretty_generate so the bytes do not depend on the json gem version
31
+ # (for example, older versions render an empty object as "{\n}").
32
+ def pretty(value)
33
+ "#{dump(value, 0)}\n".encode(Encoding::UTF_8)
34
+ end
35
+
36
+ def dump(value, depth)
37
+ indent = " " * (depth + 1)
38
+ closing = " " * depth
39
+ case value
40
+ when Hash
41
+ return "{}" if value.empty?
42
+
43
+ members = value.map { |key, element| "#{indent}#{::JSON.generate(key.to_s)}: #{dump(element, depth + 1)}" }
44
+ "{\n#{members.join(",\n")}\n#{closing}}"
45
+ when Array
46
+ return "[]" if value.empty?
47
+
48
+ "[\n#{value.map { |element| "#{indent}#{dump(element, depth + 1)}" }.join(",\n")}\n#{closing}]"
49
+ when String, Integer, Float, true, false, nil
50
+ ::JSON.generate(value)
51
+ else
52
+ raise ArgumentError, "not a JSON-compatible value: #{value.class}"
53
+ end
54
+ end
55
+
56
+ # Recursive copy of a parsed JSON structure (Hash/Array/String/scalars).
57
+ # Each check phase gets its own copy so a deserializer that mutates its
58
+ # input cannot influence another phase.
59
+ def deep_copy(value)
60
+ case value
61
+ when Hash
62
+ value.each_with_object({}) { |(key, element), copy| copy[deep_copy(key)] = deep_copy(element) }
63
+ when Array
64
+ value.map { |element| deep_copy(element) }
65
+ when String
66
+ value.dup
67
+ else
68
+ value
69
+ end
70
+ end
71
+
72
+ # Writes +content+ to +path+ atomically (temporary file in the same
73
+ # directory, then rename).
74
+ def atomic_write(path, content)
75
+ dir = File.dirname(path)
76
+ temp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.#{rand(1 << 32)}.tmp")
77
+ File.open(temp, "wb") do |file|
78
+ file.write(content)
79
+ file.flush
80
+ file.fsync
81
+ end
82
+ File.rename(temp, path)
83
+ ensure
84
+ File.unlink(temp) if temp && File.exist?(temp)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobPayload
4
+ # Collects the fixture definitions declared by a cases file.
5
+ #
6
+ # A registry is only "current" while CaseRegistry.load is evaluating a cases
7
+ # file, so JobPayload.define never writes into process-wide state.
8
+ class CaseRegistry
9
+ Case = Struct.new(:name, :block, :location)
10
+
11
+ # Thread-local key holding the registry being populated.
12
+ CURRENT_KEY = :__jobpayload_case_registry
13
+
14
+ def self.current
15
+ Thread.current[CURRENT_KEY] or
16
+ raise ConfigurationError, "JobPayload.define can only be called from a cases file loaded by `jobpayload snapshot`"
17
+ end
18
+
19
+ # Loads the cases file at +path+ and returns the populated registry.
20
+ def self.load(path)
21
+ raise ConfigurationError, "cases file not found: #{path}" unless File.file?(path)
22
+
23
+ registry = new
24
+ previous = Thread.current[CURRENT_KEY]
25
+ Thread.current[CURRENT_KEY] = registry
26
+ begin
27
+ Kernel.load(File.expand_path(path))
28
+ rescue ConfigurationError
29
+ raise
30
+ rescue StandardError, ScriptError => e
31
+ raise ConfigurationError, "failed to load cases file #{path}: #{e.class}: #{e.message}"
32
+ ensure
33
+ Thread.current[CURRENT_KEY] = previous
34
+ end
35
+ raise ConfigurationError, "no fixtures defined in #{path}" if registry.cases.empty?
36
+
37
+ registry
38
+ end
39
+
40
+ def initialize
41
+ @cases = {}
42
+ end
43
+
44
+ # Fixtures sorted by name, independent of declaration order.
45
+ def cases
46
+ @cases.values.sort_by(&:name)
47
+ end
48
+
49
+ def define(&block)
50
+ raise ConfigurationError, "JobPayload.define requires a block" unless block
51
+
52
+ DSL.new(self).instance_eval(&block)
53
+ self
54
+ end
55
+
56
+ def add(name, location, &block)
57
+ unless JobPayload.valid_name?(name)
58
+ raise ConfigurationError,
59
+ "invalid fixture name #{name.inspect} (#{location}); names must match #{NAME_PATTERN.source}"
60
+ end
61
+ raise ConfigurationError, "fixture #{name.inspect} has no block (#{location})" unless block
62
+ # Names differing only in case would map to the same file on
63
+ # case-insensitive file systems (macOS, Windows), so they are duplicates too.
64
+ if (existing = @cases.values.find { |kase| kase.name.casecmp?(name) })
65
+ raise ConfigurationError,
66
+ "duplicate fixture name #{name.inspect} (#{location}; #{existing.name.inspect} first defined at #{existing.location}; " \
67
+ "names must be unique ignoring case)"
68
+ end
69
+
70
+ @cases[name] = Case.new(name, block, location)
71
+ end
72
+
73
+ # Evaluation context for JobPayload.define blocks.
74
+ class DSL
75
+ def initialize(registry)
76
+ @registry = registry
77
+ end
78
+
79
+ def fixture(name, &block)
80
+ caller_location = caller_locations(1, 1).first
81
+ @registry.add(name, "#{caller_location.path}:#{caller_location.lineno}", &block)
82
+ nil
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module JobPayload
6
+ # Checks that previously serialized job payloads can be deserialized by the
7
+ # currently loaded application.
8
+ #
9
+ # For each fixture, in order:
10
+ # A. the fixture wrapper must be valid (done by Fixture.parse)
11
+ # B. ActiveJob::Base.deserialize(job_data) must succeed
12
+ # C. ActiveJob::Arguments.deserialize(job_data["arguments"]) must succeed
13
+ #
14
+ # Each phase receives its own deep copy of the payload. The job is never
15
+ # performed, enqueued or retried.
16
+ class Checker
17
+ MESSAGES = {
18
+ "AJP101" => "Job class cannot be resolved by the current application.",
19
+ "AJP102" => "Job class exists but its deserialize(job_data) cannot restore the old payload.",
20
+ "AJP201" => "Old payload cannot be deserialized by the current application.",
21
+ "AJP202" => "GlobalID target record was not found in the current test environment.",
22
+ "AJP900" => "The check environment failed before compatibility could be determined."
23
+ }.freeze
24
+
25
+ RESCUED = [StandardError, ScriptError, SystemStackError].freeze
26
+
27
+ # Phase C codes from least to most severe.
28
+ ARGUMENT_CODE_RANK = { "AJP202" => 0, "AJP201" => 1, "AJP900" => 2 }.freeze
29
+
30
+ def call(fixtures)
31
+ fixture_results = fixtures.map { |fixture| check(fixture) }
32
+ fixture_results.sort_by! { |result| [result.name, result.path.to_s] }
33
+ Result.new(fixture_results)
34
+ end
35
+
36
+ def check(fixture)
37
+ if fixture.is_a?(Fixture::Invalid)
38
+ finding = Finding.build(code: "AJP001", fixture: fixture.name,
39
+ message: "Fixture is invalid: #{fixture.reason}.")
40
+ return Result::FixtureResult.new(fixture.name, fixture.path, [finding])
41
+ end
42
+
43
+ findings = []
44
+ findings.concat(check_job(fixture))
45
+ findings.concat(check_arguments(fixture))
46
+ Result::FixtureResult.new(fixture.name, fixture.path, findings)
47
+ end
48
+
49
+ private
50
+
51
+ # Phase B.
52
+ def check_job(fixture)
53
+ class_name = fixture.job_class
54
+ begin
55
+ klass = ::ActiveSupport::Inflector.safe_constantize(class_name)
56
+ rescue *RESCUED => e
57
+ return [finding("AJP101", fixture, exception: e)] unless environment?(e)
58
+
59
+ return [finding("AJP900", fixture, exception: e)]
60
+ end
61
+ unless klass.is_a?(Class) && klass <= ::ActiveJob::Base
62
+ detail = klass ? "#{class_name} is not an ActiveJob::Base subclass" : "uninitialized constant #{class_name}"
63
+ return [finding("AJP101", fixture, message: "#{MESSAGES.fetch('AJP101')} (#{detail})")]
64
+ end
65
+
66
+ ::ActiveJob::Base.deserialize(Canonical.deep_copy(fixture.job))
67
+ []
68
+ rescue *RESCUED => e
69
+ [finding(environment?(e) ? "AJP900" : "AJP102", fixture, exception: e)]
70
+ end
71
+
72
+ # Phase C. The full deserialize call is the source of truth for whether
73
+ # the arguments are readable. When it fails, each argument is retried on
74
+ # its own to point at every failing argument: Active Job stops at the first
75
+ # error, so a missing GlobalID record (inconclusive) in arguments[0] must
76
+ # not hide a broken serializer in arguments[1].
77
+ def check_arguments(fixture)
78
+ # Kept out of a rescue clause on purpose: exceptions raised while `$!` is
79
+ # set get it as their cause, which would leak the first failure into the
80
+ # cause chain (and classification) of every per-argument retry.
81
+ e = ArgumentLocator.deserialize_error(fixture.job["arguments"], wrap: false)
82
+ return [] unless e
83
+
84
+ findings = ArgumentLocator.call(fixture.job["arguments"]).map do |path, error, leaf|
85
+ finding(argument_code(error, leaf), fixture, exception: error, argument_path: path)
86
+ end
87
+ # Never report less than the full call did (for example when no single
88
+ # argument fails on its own). The full call has no single failing leaf:
89
+ # a record-missing error there is already explained by any per-argument
90
+ # finding, and is reported as breaking when there is none.
91
+ overall = argument_code(e, nil)
92
+ floor = ExceptionClassifier.call(e) == :record_missing ? 0 : ARGUMENT_CODE_RANK.fetch(overall)
93
+ unless findings.any? { |f| ARGUMENT_CODE_RANK.fetch(f.code) >= floor }
94
+ findings << finding(overall, fixture, exception: e, argument_path: "arguments")
95
+ end
96
+ findings
97
+ end
98
+
99
+ # AJP202 (inconclusive) only when the failing serialized leaf is an Active
100
+ # Job GlobalID reference and a record-missing error is in the cause chain.
101
+ # A custom serializer that raises RecordNotFound (for example a lookup by a
102
+ # renamed key) is a broken payload: AJP201.
103
+ def argument_code(exception, leaf)
104
+ case ExceptionClassifier.call(exception)
105
+ when :environment then "AJP900"
106
+ when :record_missing then ArgumentLocator.global_id?(leaf) ? "AJP202" : "AJP201"
107
+ else "AJP201"
108
+ end
109
+ end
110
+
111
+ def environment?(exception)
112
+ ExceptionClassifier.call(exception) == :environment
113
+ end
114
+
115
+ def finding(code, fixture, exception: nil, argument_path: nil, message: MESSAGES.fetch(code))
116
+ Finding.build(code: code, fixture: fixture.name, job_class: fixture.job_class,
117
+ argument_path: argument_path, message: message, exception: exception)
118
+ end
119
+
120
+ # Finds every failing argument. Inside plain arrays and hashes it descends
121
+ # to the nested values that fail on their own. Returns
122
+ # [[path, exception, failing_value]].
123
+ module ArgumentLocator
124
+ # Keys Active Job adds to a serialized plain hash. Any other key,
125
+ # including user keys such as "_aj_custom", holds a serialized value.
126
+ HASH_METADATA_KEYS = %w[_aj_symbol_keys _aj_ruby2_keywords _aj_hash_with_indifferent_access].freeze
127
+
128
+ module_function
129
+
130
+ def call(arguments)
131
+ arguments.each_with_index.flat_map { |argument, index| failures(argument, "arguments[#{index}]") }
132
+ end
133
+
134
+ def failures(value, path)
135
+ error = deserialize_error(value)
136
+ return [] unless error
137
+
138
+ failing = []
139
+ nested = children(value, path).flat_map do |child, child_path, key|
140
+ child_failures = failures(child, child_path)
141
+ failing << key unless child_failures.empty?
142
+ child_failures
143
+ end
144
+ return [[path, error, value]] if nested.empty?
145
+
146
+ # The container can also be broken on its own (for example malformed
147
+ # "_aj_" metadata). Retry it with the failing children blanked out so
148
+ # that a missing GlobalID inside cannot hide that.
149
+ own_error = deserialize_error(without(value, failing))
150
+ own_error ? nested << [path, own_error, value] : nested
151
+ end
152
+
153
+ def without(value, keys)
154
+ copy = Canonical.deep_copy(value)
155
+ keys.each { |key| copy[key] = nil }
156
+ copy
157
+ end
158
+
159
+ # Same test Active Job uses for a serialized GlobalID: a one-key hash.
160
+ def global_id?(value)
161
+ value.is_a?(Hash) && value.size == 1 && value["_aj_globalid"].is_a?(String)
162
+ end
163
+
164
+ def children(value, path)
165
+ case value
166
+ when Array
167
+ value.each_with_index.map { |child, index| [child, "#{path}[#{index}]", index] }
168
+ when Hash
169
+ return [] if value.key?("_aj_serialized") || value.key?("_aj_globalid")
170
+
171
+ value.reject { |key, _| HASH_METADATA_KEYS.include?(key) }.map { |key, child| [child, "#{path}[#{JSON.generate(key)}]", key] }
172
+ else
173
+ []
174
+ end
175
+ end
176
+
177
+ # Deserializes +value+ as a single argument (or, with wrap: false, as the
178
+ # full argument list) and returns the exception raised, if any.
179
+ def deserialize_error(value, wrap: true)
180
+ ::ActiveJob::Arguments.deserialize(Canonical.deep_copy(wrap ? [value] : value))
181
+ nil
182
+ rescue *RESCUED => e
183
+ e
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module JobPayload
6
+ # Command line interface.
7
+ #
8
+ # Order of operations is fixed: parse options, boot the host application
9
+ # (once), then use Active Job APIs. Results go to stdout; usage, configuration
10
+ # and boot errors go to stderr.
11
+ class CLI
12
+ EXIT_SUCCESS = 0
13
+ EXIT_FAILURE = 1
14
+ EXIT_ERROR = 2
15
+
16
+ DEFAULT_CASES = "test/jobpayload_cases.rb"
17
+ DEFAULT_FIXTURES = "test/jobpayload_fixtures"
18
+
19
+ USAGE = <<~TEXT
20
+ Usage: jobpayload COMMAND [options]
21
+
22
+ Commands:
23
+ snapshot Write baseline fixtures from snapshot cases (ActiveJob::Base#serialize)
24
+ check Check that fixtures can be deserialized by the current application
25
+
26
+ Options:
27
+ -v, --version Print the version
28
+ -h, --help Print this help
29
+
30
+ Run `jobpayload COMMAND --help` for command options.
31
+ TEXT
32
+
33
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
34
+ # An argument that is not valid in its encoding (for example a path
35
+ # with non-UTF-8 bytes under a UTF-8 locale) makes OptionParser's regexp
36
+ # matching raise. Keep its exact bytes as a binary string, which is safe
37
+ # for option parsing and file system calls; only output is scrubbed.
38
+ @argv = argv.map { |arg| arg.valid_encoding? ? arg.dup : arg.b }
39
+ @stdout = stdout
40
+ @stderr = stderr
41
+ @debug = false
42
+ end
43
+
44
+ def run
45
+ command = @argv.shift
46
+ case command
47
+ when "snapshot" then with_stdout_redirected { snapshot }
48
+ when "check" then with_stdout_redirected { check }
49
+ when "-v", "--version", "version"
50
+ @stdout.puts("jobpayload v#{VERSION}")
51
+ EXIT_SUCCESS
52
+ when "-h", "--help", "help"
53
+ @stdout.print(USAGE)
54
+ EXIT_SUCCESS
55
+ when nil
56
+ usage_error("no command given")
57
+ else
58
+ usage_error("unknown command #{command.inspect}")
59
+ end
60
+ rescue OptionParser::ParseError => e
61
+ usage_error(e.message)
62
+ rescue SystemExit => e
63
+ # Application code (boot file, initializers, serializers, case blocks)
64
+ # called exit/abort. Never let its status masquerade as exit 0 or 1.
65
+ error(Error.new("the application exited with status #{e.status} while jobpayload was running"))
66
+ rescue Error => e
67
+ error(e)
68
+ rescue Interrupt, SignalException
69
+ raise
70
+ rescue Exception => e # rubocop:disable Lint/RescueException
71
+ # Anything else (SystemStackError from pathologically deep fixtures, or
72
+ # an application exception that does not inherit from StandardError)
73
+ # is a tool error: exit 2, never the compatibility-failure status 1.
74
+ error(e, prefix: "internal error: #{e.class}: ")
75
+ end
76
+
77
+ private
78
+
79
+ # The host application (boot file, initializers, serializers, case blocks)
80
+ # may print with puts/print. Send that to stderr so stdout only carries
81
+ # jobpayload's own result (which is written to @stdout directly).
82
+ def with_stdout_redirected
83
+ saved = $stdout
84
+ $stdout = @stderr
85
+ yield
86
+ ensure
87
+ $stdout = saved
88
+ end
89
+
90
+ def snapshot
91
+ options = {
92
+ cases: DEFAULT_CASES, output: DEFAULT_FIXTURES, boot: Boot::DEFAULT_PATH,
93
+ environment: Boot::DEFAULT_ENVIRONMENT, update: false, format: "text"
94
+ }
95
+ parser = OptionParser.new do |opts|
96
+ opts.banner = "Usage: jobpayload snapshot [options]"
97
+ opts.on("--cases PATH", "Snapshot cases file (default: #{DEFAULT_CASES})") { |v| options[:cases] = v }
98
+ opts.on("--output DIR", "Fixture output directory (default: #{DEFAULT_FIXTURES})") { |v| options[:output] = v }
99
+ boot_options(opts, options)
100
+ opts.on("--update", "Replace existing fixtures whose content changed") { options[:update] = true }
101
+ format_option(opts, options)
102
+ end
103
+ return EXIT_SUCCESS if parse(parser)
104
+
105
+ formatter_name = options[:format]
106
+ raise ConfigurationError, "cases file not found: #{options[:cases]}" unless File.file?(options[:cases])
107
+ if File.exist?(options[:output]) && !File.directory?(options[:output])
108
+ raise ConfigurationError, "output path is not a directory: #{options[:output]}"
109
+ end
110
+
111
+ boot(options)
112
+ registry = CaseRegistry.load(options[:cases])
113
+ entries = Snapshotter.new(registry: registry, output_dir: options[:output], update: options[:update]).call
114
+ @stdout.print(formatter_name == "json" ? snapshot_json(entries) : snapshot_text(entries))
115
+ EXIT_SUCCESS
116
+ end
117
+
118
+ def check
119
+ options = {
120
+ fixtures: DEFAULT_FIXTURES, boot: Boot::DEFAULT_PATH, environment: Boot::DEFAULT_ENVIRONMENT,
121
+ format: "text", fail_on_inconclusive: false
122
+ }
123
+ parser = OptionParser.new do |opts|
124
+ opts.banner = "Usage: jobpayload check [options]"
125
+ opts.on("--fixtures PATH", "Fixture directory or file (default: #{DEFAULT_FIXTURES})") { |v| options[:fixtures] = v }
126
+ boot_options(opts, options)
127
+ format_option(opts, options)
128
+ opts.on("--fail-on-inconclusive", "Exit 1 when any fixture is inconclusive") { options[:fail_on_inconclusive] = true }
129
+ opts.on("--debug", "Show exception backtraces in text output") { @debug = true }
130
+ end
131
+ return EXIT_SUCCESS if parse(parser)
132
+
133
+ files = FixtureLoader.files(options[:fixtures])
134
+ boot(options)
135
+ formatter = Formatter.for(options[:format])
136
+ result = Checker.new.call(FixtureLoader.parse(files))
137
+ @stdout.print(formatter.call(result, fail_on_inconclusive: options[:fail_on_inconclusive], debug: @debug))
138
+ result.exit_code(fail_on_inconclusive: options[:fail_on_inconclusive])
139
+ end
140
+
141
+ # Returns true when help or the version was printed (nothing else to do).
142
+ def parse(parser)
143
+ done = false
144
+ parser.on("-h", "--help", "Print this help") do
145
+ @stdout.print(parser.help)
146
+ done = true
147
+ end
148
+ # Replaces OptionParser's built-in --version, which would print
149
+ # "version unknown" and exit 1 (the compatibility-failure status).
150
+ parser.on("-v", "--version", "Print the version") do
151
+ @stdout.puts("jobpayload v#{VERSION}")
152
+ done = true
153
+ end
154
+ # Reject abbreviations such as --fail or --up: the option surface is a contract.
155
+ parser.require_exact = true
156
+ rest = parser.parse(@argv)
157
+ raise UsageError, "unexpected argument #{rest.first.inspect}" unless rest.empty? || done
158
+
159
+ done
160
+ end
161
+
162
+ def boot_options(opts, options)
163
+ opts.on("--boot PATH", "Application boot file (default: #{Boot::DEFAULT_PATH})") { |v| options[:boot] = v }
164
+ opts.on("--environment NAME", "RAILS_ENV to boot (default: #{Boot::DEFAULT_ENVIRONMENT})") do |v|
165
+ options[:environment] = v
166
+ end
167
+ end
168
+
169
+ def format_option(opts, options)
170
+ opts.on("--format FORMAT", Formatter::FORMATS, "Output format: #{Formatter::FORMATS.join('|')} (default: text)") do |v|
171
+ options[:format] = v
172
+ end
173
+ end
174
+
175
+ def boot(options)
176
+ if options[:environment] == "production"
177
+ @stderr.puts("jobpayload: warning: booting the production environment")
178
+ end
179
+ Boot.call(path: options[:boot], environment: options[:environment])
180
+ end
181
+
182
+ def snapshot_text(entries)
183
+ lines = entries.map do |entry|
184
+ line = format("%-9s %s %s", entry.status, entry.name, JobPayload.scrub_utf8(entry.path))
185
+ entry.status == :skipped ? "#{line} (exists and differs; pass --update to replace)" : line
186
+ end
187
+ counts = entries.map(&:status).tally
188
+ summary = %i[created updated identical skipped].map { |status| "#{counts.fetch(status, 0)} #{status}" }.join(", ")
189
+ "#{lines.join("\n")}\n\n#{entries.size} fixtures: #{summary}\n"
190
+ end
191
+
192
+ def snapshot_json(entries)
193
+ counts = entries.map(&:status).tally
194
+ document = {
195
+ "schema_version" => 1,
196
+ "tool_version" => VERSION,
197
+ "summary" => %i[created updated identical skipped].to_h { |status| [status.to_s, counts.fetch(status, 0)] },
198
+ "fixtures" => entries.map { |e| { "name" => e.name, "path" => JobPayload.scrub_utf8(e.path), "status" => e.status.to_s } }
199
+ }
200
+ Canonical.pretty(document)
201
+ end
202
+
203
+ def usage_error(message)
204
+ @stderr.puts("jobpayload: #{message}")
205
+ @stderr.puts("Run `jobpayload --help` for usage.")
206
+ EXIT_ERROR
207
+ end
208
+
209
+ def error(exception, prefix: "")
210
+ label = exception.is_a?(BootError) ? "AJP900 environment_error: " : ""
211
+ @stderr.puts("jobpayload: error: #{label}#{prefix}#{exception.message}")
212
+ if @debug
213
+ ExceptionClassifier.cause_chain(exception).each do |e|
214
+ @stderr.puts(" #{e.class}: #{e.message}")
215
+ Array(e.backtrace).each { |frame| @stderr.puts(" #{frame}") }
216
+ end
217
+ end
218
+ EXIT_ERROR
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobPayload
4
+ # Base class for errors that abort a command with exit status 2
5
+ # (usage, configuration, boot or tool errors).
6
+ class Error < StandardError; end
7
+
8
+ # Invalid command line usage.
9
+ class UsageError < Error; end
10
+
11
+ # Invalid or missing input (cases file, fixture directory, case definitions).
12
+ class ConfigurationError < Error; end
13
+
14
+ # The host application could not be booted.
15
+ class BootError < Error; end
16
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobPayload
4
+ # Classifies an exception raised while deserializing a payload by looking at
5
+ # the classes in its cause chain (never primarily at message text).
6
+ #
7
+ # Classes are compared by name, including ancestors, so the classifier works
8
+ # whether or not a given library (Active Record, a database driver, ...) is
9
+ # loaded in the host application.
10
+ module ExceptionClassifier
11
+ # The referenced record does not exist. The payload may still be perfectly
12
+ # readable by the new code, so the result is inconclusive.
13
+ RECORD_MISSING = %w[
14
+ ActiveRecord::RecordNotFound
15
+ GlobalID::Locator::RecordNotFound
16
+ Mongoid::Errors::DocumentNotFound
17
+ ].freeze
18
+
19
+ # The check environment itself is broken (no database, no connection,
20
+ # schema not loaded...). ActiveRecord::StatementInvalid is included because
21
+ # SQL errors while locating a record mean the test database does not match
22
+ # the application, not that the payload is unreadable. SystemStackError is
23
+ # included because exhausting the Ruby stack (pathologically deep payloads)
24
+ # means jobpayload could not finish the check, not that the payload is
25
+ # incompatible.
26
+ ENVIRONMENT = %w[
27
+ ActiveRecord::ConnectionNotEstablished
28
+ ActiveRecord::NoDatabaseError
29
+ ActiveRecord::StatementInvalid
30
+ ActiveRecord::DatabaseConnectionError
31
+ ActiveRecord::PendingMigrationError
32
+ ActiveRecord::AdapterNotSpecified
33
+ ActiveRecord::AdapterNotFound
34
+ PG::ConnectionBad
35
+ Mysql2::Error::ConnectionError
36
+ Trilogy::ConnectionError
37
+ SQLite3::CantOpenException
38
+ Errno::ECONNREFUSED
39
+ SystemStackError
40
+ ].freeze
41
+
42
+ MAX_CHAIN_LENGTH = 32
43
+
44
+ module_function
45
+
46
+ # Returns :environment, :record_missing or :failure.
47
+ def call(exception)
48
+ names = cause_chain(exception).flat_map { |e| ancestor_names(e) }
49
+ return :environment if names.intersect?(ENVIRONMENT)
50
+ return :record_missing if names.intersect?(RECORD_MISSING)
51
+
52
+ :failure
53
+ end
54
+
55
+ # The exception followed by its causes, outermost first. Stops on cycles.
56
+ def cause_chain(exception)
57
+ chain = []
58
+ seen = {}.compare_by_identity
59
+ current = exception
60
+ while current && !seen.key?(current) && chain.size < MAX_CHAIN_LENGTH
61
+ seen[current] = true
62
+ chain << current
63
+ current = current.cause
64
+ end
65
+ chain
66
+ end
67
+
68
+ def ancestor_names(exception)
69
+ exception.class.ancestors.filter_map { |mod| mod.is_a?(Class) ? mod.name : nil }
70
+ end
71
+
72
+ # Name of the exception's class; anonymous classes fall back to the
73
+ # nearest named ancestor so reports never show an empty class.
74
+ def class_name(exception)
75
+ exception.class.ancestors.find { |mod| mod.is_a?(Class) && mod.name }&.name.to_s
76
+ end
77
+
78
+ # Exception#message can itself raise (or return non-strings) for badly
79
+ # behaved exception classes; never let that crash the report.
80
+ # Always valid UTF-8, so JSON output never fails on a message carrying
81
+ # binary or otherwise invalid bytes (they become U+FFFD).
82
+ def safe_message(exception)
83
+ JobPayload.scrub_utf8(exception.message.to_s)
84
+ rescue StandardError => e
85
+ "(message unavailable: #{e.class})"
86
+ end
87
+ end
88
+ end