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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE +21 -0
- data/README.md +622 -0
- data/exe/jobpayload +6 -0
- data/lib/jobpayload/boot.rb +49 -0
- data/lib/jobpayload/canonical.rb +87 -0
- data/lib/jobpayload/case_registry.rb +86 -0
- data/lib/jobpayload/checker.rb +187 -0
- data/lib/jobpayload/cli.rb +221 -0
- data/lib/jobpayload/errors.rb +16 -0
- data/lib/jobpayload/exception_classifier.rb +88 -0
- data/lib/jobpayload/finding.rb +82 -0
- data/lib/jobpayload/fixture.rb +94 -0
- data/lib/jobpayload/fixture_loader.rb +36 -0
- data/lib/jobpayload/formatter/json.rb +27 -0
- data/lib/jobpayload/formatter/text.rb +82 -0
- data/lib/jobpayload/formatter.rb +19 -0
- data/lib/jobpayload/result.rb +60 -0
- data/lib/jobpayload/snapshotter.rb +122 -0
- data/lib/jobpayload/version.rb +5 -0
- data/lib/jobpayload.rb +61 -0
- metadata +89 -0
|
@@ -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
|