kube_schema 1.11.0 → 2.0.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/.envrc +1 -0
- data/.github/FUNDING.yml +3 -0
- data/.rubocop.yml +226 -5984
- data/CHANGELOG.md +127 -2
- data/DEPRECATIONS.md +172 -0
- data/Gemfile +13 -3
- data/Gemfile.lock +41 -31
- data/RELEASE.md +161 -0
- data/Rakefile +1 -1
- data/bin/rubocop +3 -0
- data/bin/test +2 -9
- data/cops/begin_block_assignment.rb +34 -0
- data/cops/comment_structure.rb +307 -0
- data/cops/constant_matches_path.rb +142 -0
- data/cops/multiline_method_arguments.rb +89 -0
- data/cops/no_conditional_assignment.rb +186 -0
- data/cops/no_console_logging.rb +62 -0
- data/cops/no_modifier_if.rb +74 -0
- data/cops/no_return.rb +152 -0
- data/cops/support/prism_source.rb +234 -0
- data/cops/use_tap.rb +100 -0
- data/flake.lock +42 -5
- data/flake.nix +28 -21
- data/gemset.nix +983 -0
- data/kube_schema.gemspec +4 -2
- data/lib/kube/errors.rb +652 -645
- data/lib/kube/monkey_patches.rb +5 -6
- data/lib/kube/schema/instance.rb +344 -363
- data/lib/kube/schema/lazy.rb +370 -0
- data/lib/kube/schema/manifest.rb +289 -310
- data/lib/kube/schema/resource.rb +352 -356
- data/lib/kube/schema/sub_spec.rb +261 -293
- data/lib/kube/schema.rb +173 -172
- data/lib/{kube/schema → kube_schema}/version.rb +1 -1
- data/lib/kube_schema.rb +6 -0
- data/readme.md +5 -0
- metadata +50 -9
- data/bin/increment-version +0 -45
- data/bin/release-gem +0 -32
- data/bin/tag-version +0 -19
- data/lib/kube/schema/version.rb.erb +0 -7
data/lib/kube/errors.rb
CHANGED
|
@@ -3,734 +3,749 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
5
|
module Kube
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@manifest = manifest
|
|
51
|
-
super(build_message)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Ruby 3.2+ calls detailed_message for uncaught exceptions.
|
|
55
|
-
# We override it so the message ends at column 0, preventing
|
|
56
|
-
# Ruby from indenting the backtrace to match YAML indentation.
|
|
57
|
-
def detailed_message(highlight: false, **)
|
|
58
|
-
out = "\n#{BOLD}#{UNDERLINE}#{RED}#{header_line}#{RESET}\n\n"
|
|
59
|
-
out += error_lines.join("\n")
|
|
60
|
-
if @manifest
|
|
61
|
-
out += "\n\n#{annotate_manifest(@manifest, @errors)}"
|
|
62
|
-
end
|
|
63
|
-
out += "\n\n"
|
|
64
|
-
out
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
private
|
|
68
|
-
|
|
69
|
-
def header_line
|
|
70
|
-
header = "Schema validation failed"
|
|
71
|
-
header += " for #{@kind}" if @kind
|
|
72
|
-
header += " #{@name.inspect}" if @name
|
|
73
|
-
header
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def error_lines
|
|
77
|
-
@errors.flat_map { |e| format_error(e) }
|
|
6
|
+
module Errors
|
|
7
|
+
# Base error for all Kube gems.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Raised when a schema version is unknown.
|
|
11
|
+
class UnknownVersionError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when a version string is formatted incorrectly (e.g. "v1.33.6").
|
|
14
|
+
class IncorrectVersionFormat < Error; end
|
|
15
|
+
|
|
16
|
+
class MissingSchemaError < Error; end
|
|
17
|
+
|
|
18
|
+
# Raised by Resource#valid! when schema validation fails.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# Schema validation failed for Deployment "web":
|
|
22
|
+
# - spec.selector is required but missing
|
|
23
|
+
# - spec.replicas = "not_a_number" — expected integer, got String
|
|
24
|
+
# ---
|
|
25
|
+
# apiVersion: apps/v1
|
|
26
|
+
# kind: Deployment
|
|
27
|
+
# spec:
|
|
28
|
+
# selector: # MISSING — required
|
|
29
|
+
# replicas: "nah" # expected integer, got String
|
|
30
|
+
class ValidationError < Error
|
|
31
|
+
attr_reader :errors
|
|
32
|
+
|
|
33
|
+
RED = "\033[31m"
|
|
34
|
+
YELLOW = "\033[33m"
|
|
35
|
+
RESET = "\033[0m"
|
|
36
|
+
|
|
37
|
+
BOLD = "\033[1m"
|
|
38
|
+
UNDERLINE = "\033[4m"
|
|
39
|
+
|
|
40
|
+
# @param errors [Array<Hash>] raw JSONSchemer error hashes
|
|
41
|
+
# @param kind [String, nil] Kubernetes resource kind (e.g. "Deployment")
|
|
42
|
+
# @param name [String, nil] resource metadata.name (e.g. "web")
|
|
43
|
+
# @param manifest [Hash, nil] the full resource data that failed validation
|
|
44
|
+
def initialize(errors, kind: nil, name: nil, manifest: nil)
|
|
45
|
+
@errors = errors
|
|
46
|
+
@kind = kind
|
|
47
|
+
@name = name
|
|
48
|
+
@manifest = manifest
|
|
49
|
+
super(build_message)
|
|
78
50
|
end
|
|
79
51
|
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
52
|
+
# Keeps the backtrace from indenting past column 0.
|
|
53
|
+
# @returns [String] the message, unwrapped for the backtrace.
|
|
54
|
+
def detailed_message(highlight: false, **)
|
|
55
|
+
out = "\n#{BOLD}#{UNDERLINE}#{RED}#{header_line}#{RESET}\n\n"
|
|
56
|
+
out += error_lines.join("\n")
|
|
83
57
|
if @manifest
|
|
84
|
-
|
|
58
|
+
out += "\n\n#{annotate_manifest(@manifest, @errors)}"
|
|
85
59
|
end
|
|
86
|
-
|
|
60
|
+
out += "\n\n"
|
|
61
|
+
out
|
|
87
62
|
end
|
|
88
63
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
# Format a single JSONSchemer error hash into one or more human-readable lines.
|
|
92
|
-
def format_error(error)
|
|
93
|
-
path = pointer_to_dot(error["data_pointer"])
|
|
94
|
-
type = error["type"]
|
|
95
|
-
data = error["data"]
|
|
96
|
-
|
|
97
|
-
case type
|
|
98
|
-
when "required"
|
|
99
|
-
format_required(error, path)
|
|
100
|
-
when "string", "integer", "number", "boolean", "array", "object", "null"
|
|
101
|
-
"❌ #{path} = #{truncate(data.inspect)} — expected #{type}, got #{data.class}"
|
|
102
|
-
when "minimum"
|
|
103
|
-
limit = error.dig("schema", "minimum")
|
|
104
|
-
"❌ #{path} = #{truncate(data.inspect)} — must be >= #{limit}"
|
|
105
|
-
when "maximum"
|
|
106
|
-
limit = error.dig("schema", "maximum")
|
|
107
|
-
"❌ #{path} = #{truncate(data.inspect)} — must be <= #{limit}"
|
|
108
|
-
when "exclusiveMinimum"
|
|
109
|
-
limit = error.dig("schema", "exclusiveMinimum")
|
|
110
|
-
"❌ #{path} = #{truncate(data.inspect)} — must be > #{limit}"
|
|
111
|
-
when "exclusiveMaximum"
|
|
112
|
-
limit = error.dig("schema", "exclusiveMaximum")
|
|
113
|
-
"❌ #{path} = #{truncate(data.inspect)} — must be < #{limit}"
|
|
114
|
-
when "minLength"
|
|
115
|
-
limit = error.dig("schema", "minLength")
|
|
116
|
-
"❌ #{path} = #{truncate(data.inspect)} — length must be >= #{limit}"
|
|
117
|
-
when "maxLength"
|
|
118
|
-
limit = error.dig("schema", "maxLength")
|
|
119
|
-
"❌ #{path} = #{truncate(data.inspect)} — length must be <= #{limit}"
|
|
120
|
-
when "minItems"
|
|
121
|
-
limit = error.dig("schema", "minItems")
|
|
122
|
-
"❌ #{path} — array must have >= #{limit} items, got #{data.is_a?(Array) ? data.length : data.inspect}"
|
|
123
|
-
when "maxItems"
|
|
124
|
-
limit = error.dig("schema", "maxItems")
|
|
125
|
-
"❌ #{path} — array must have <= #{limit} items, got #{data.is_a?(Array) ? data.length : data.inspect}"
|
|
126
|
-
when "enum"
|
|
127
|
-
allowed = error.dig("schema", "enum")
|
|
128
|
-
"❌ #{path} = #{truncate(data.inspect)} — must be one of: #{truncate(allowed.inspect)}"
|
|
129
|
-
when "const"
|
|
130
|
-
expected = error.dig("schema", "const")
|
|
131
|
-
"❌ #{path} = #{truncate(data.inspect)} — must be #{truncate(expected.inspect)}"
|
|
132
|
-
when "pattern"
|
|
133
|
-
pattern = error.dig("schema", "pattern")
|
|
134
|
-
"❌ #{path} = #{truncate(data.inspect)} — does not match pattern: #{pattern}"
|
|
135
|
-
when "format"
|
|
136
|
-
fmt = error.dig("schema", "format")
|
|
137
|
-
"❌ #{path} = #{truncate(data.inspect)} — invalid #{fmt} format"
|
|
138
|
-
when "multipleOf"
|
|
139
|
-
factor = error.dig("schema", "multipleOf")
|
|
140
|
-
"❌ #{path} = #{truncate(data.inspect)} — must be a multiple of #{factor}"
|
|
141
|
-
when "uniqueItems"
|
|
142
|
-
"❌ #{path} — array items must be unique"
|
|
143
|
-
when "additionalProperties"
|
|
144
|
-
"❌ #{path}: #{error["error"] || "has additional properties that are not allowed"}"
|
|
145
|
-
else
|
|
146
|
-
msg = error["error"] || type
|
|
147
|
-
"❌ #{path}: #{msg}"
|
|
148
|
-
end
|
|
149
|
-
end
|
|
64
|
+
private
|
|
150
65
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
66
|
+
def header_line
|
|
67
|
+
header = "Schema validation failed"
|
|
68
|
+
if @kind
|
|
69
|
+
header += " for #{@kind}"
|
|
70
|
+
end
|
|
71
|
+
if @name
|
|
72
|
+
header += " #{@name.inspect}"
|
|
73
|
+
end
|
|
74
|
+
header
|
|
156
75
|
end
|
|
157
76
|
|
|
158
|
-
|
|
159
|
-
|
|
77
|
+
def error_lines
|
|
78
|
+
@errors.flat_map { |e| format_error(e) }
|
|
160
79
|
end
|
|
161
|
-
end
|
|
162
80
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
81
|
+
# Plain-text message for rescue => e; e.message.
|
|
82
|
+
# @returns [String] the header and formatted error lines.
|
|
83
|
+
def build_message
|
|
84
|
+
msg = "#{header_line}\n#{error_lines.join("\n")}"
|
|
85
|
+
if @manifest
|
|
86
|
+
msg += "\n\n#{annotate_manifest(@manifest, @errors)}"
|
|
87
|
+
end
|
|
88
|
+
msg
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ── Error line formatting ────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
# Format one JSONSchemer error into readable lines.
|
|
94
|
+
# @parameter error [Hash] a raw JSONSchemer error hash.
|
|
95
|
+
# @returns [String, Array(String)] one or more readable lines.
|
|
96
|
+
def format_error(error)
|
|
97
|
+
path = pointer_to_dot(error["data_pointer"])
|
|
98
|
+
type = error["type"]
|
|
99
|
+
data = error["data"]
|
|
100
|
+
|
|
101
|
+
case type
|
|
102
|
+
when "required"
|
|
103
|
+
format_required(error, path)
|
|
104
|
+
when "string", "integer", "number", "boolean", "array", "object", "null"
|
|
105
|
+
"❌ #{path} = #{truncate(data.inspect)} — expected #{type}, got #{data.class}"
|
|
106
|
+
when "minimum"
|
|
107
|
+
limit = error.dig("schema", "minimum")
|
|
108
|
+
"❌ #{path} = #{truncate(data.inspect)} — must be >= #{limit}"
|
|
109
|
+
when "maximum"
|
|
110
|
+
limit = error.dig("schema", "maximum")
|
|
111
|
+
"❌ #{path} = #{truncate(data.inspect)} — must be <= #{limit}"
|
|
112
|
+
when "exclusiveMinimum"
|
|
113
|
+
limit = error.dig("schema", "exclusiveMinimum")
|
|
114
|
+
"❌ #{path} = #{truncate(data.inspect)} — must be > #{limit}"
|
|
115
|
+
when "exclusiveMaximum"
|
|
116
|
+
limit = error.dig("schema", "exclusiveMaximum")
|
|
117
|
+
"❌ #{path} = #{truncate(data.inspect)} — must be < #{limit}"
|
|
118
|
+
when "minLength"
|
|
119
|
+
limit = error.dig("schema", "minLength")
|
|
120
|
+
"❌ #{path} = #{truncate(data.inspect)} — length must be >= #{limit}"
|
|
121
|
+
when "maxLength"
|
|
122
|
+
limit = error.dig("schema", "maxLength")
|
|
123
|
+
"❌ #{path} = #{truncate(data.inspect)} — length must be <= #{limit}"
|
|
124
|
+
when "minItems"
|
|
125
|
+
limit = error.dig("schema", "minItems")
|
|
126
|
+
"❌ #{path} — array must have >= #{limit} items, got #{data.is_a?(Array) ? data.length : data.inspect}"
|
|
127
|
+
when "maxItems"
|
|
128
|
+
limit = error.dig("schema", "maxItems")
|
|
129
|
+
"❌ #{path} — array must have <= #{limit} items, got #{data.is_a?(Array) ? data.length : data.inspect}"
|
|
130
|
+
when "enum"
|
|
131
|
+
allowed = error.dig("schema", "enum")
|
|
132
|
+
"❌ #{path} = #{truncate(data.inspect)} — must be one of: #{truncate(allowed.inspect)}"
|
|
133
|
+
when "const"
|
|
134
|
+
expected = error.dig("schema", "const")
|
|
135
|
+
"❌ #{path} = #{truncate(data.inspect)} — must be #{truncate(expected.inspect)}"
|
|
136
|
+
when "pattern"
|
|
137
|
+
pattern = error.dig("schema", "pattern")
|
|
138
|
+
"❌ #{path} = #{truncate(data.inspect)} — does not match pattern: #{pattern}"
|
|
139
|
+
when "format"
|
|
140
|
+
fmt = error.dig("schema", "format")
|
|
141
|
+
"❌ #{path} = #{truncate(data.inspect)} — invalid #{fmt} format"
|
|
142
|
+
when "multipleOf"
|
|
143
|
+
factor = error.dig("schema", "multipleOf")
|
|
144
|
+
"❌ #{path} = #{truncate(data.inspect)} — must be a multiple of #{factor}"
|
|
145
|
+
when "uniqueItems"
|
|
146
|
+
"❌ #{path} — array items must be unique"
|
|
147
|
+
when "additionalProperties"
|
|
148
|
+
"❌ #{path}: #{error["error"] || "has additional properties that are not allowed"}"
|
|
149
|
+
else
|
|
150
|
+
msg = error["error"] || type
|
|
151
|
+
"❌ #{path}: #{msg}"
|
|
152
|
+
end
|
|
199
153
|
end
|
|
200
|
-
end
|
|
201
154
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
error_at = {}
|
|
211
|
-
missing_at = {}
|
|
212
|
-
|
|
213
|
-
errors.each do |e|
|
|
214
|
-
ptr = e["data_pointer"]
|
|
215
|
-
if e["type"] == "required"
|
|
216
|
-
keys = e.dig("details", "missing_keys") || []
|
|
217
|
-
(missing_at[ptr] ||= []).concat(keys)
|
|
155
|
+
# Format a required error, one line per missing key.
|
|
156
|
+
# @parameter error [Hash] the required-type JSONSchemer error.
|
|
157
|
+
# @parameter path [String] the dot-notation path to the error.
|
|
158
|
+
# @returns [String, Array(String)] one line per missing key.
|
|
159
|
+
def format_required(error, path)
|
|
160
|
+
missing = error.dig("details", "missing_keys") || []
|
|
161
|
+
if missing.empty?
|
|
162
|
+
"❌ #{path}: #{error["error"] || "required"}"
|
|
218
163
|
else
|
|
219
|
-
|
|
164
|
+
missing.map do |key|
|
|
165
|
+
"❌ #{join_path(path, key)} is required but missing"
|
|
166
|
+
end
|
|
220
167
|
end
|
|
221
168
|
end
|
|
222
169
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
170
|
+
# Short inline description for annotating YAML lines.
|
|
171
|
+
# @parameter error [Hash] a raw JSONSchemer error hash.
|
|
172
|
+
# @returns [String] a short description of the violation.
|
|
173
|
+
def format_error_short(error)
|
|
174
|
+
type = error["type"]
|
|
175
|
+
data = error["data"]
|
|
176
|
+
|
|
177
|
+
case type
|
|
178
|
+
when "string", "integer", "number", "boolean", "array", "object", "null"
|
|
179
|
+
"expected #{type}, got #{data.class}"
|
|
180
|
+
when "minimum"
|
|
181
|
+
"must be >= #{error.dig("schema", "minimum")}"
|
|
182
|
+
when "maximum"
|
|
183
|
+
"must be <= #{error.dig("schema", "maximum")}"
|
|
184
|
+
when "exclusiveMinimum"
|
|
185
|
+
"must be > #{error.dig("schema", "exclusiveMinimum")}"
|
|
186
|
+
when "exclusiveMaximum"
|
|
187
|
+
"must be < #{error.dig("schema", "exclusiveMaximum")}"
|
|
188
|
+
when "minLength"
|
|
189
|
+
"length must be >= #{error.dig("schema", "minLength")}"
|
|
190
|
+
when "maxLength"
|
|
191
|
+
"length must be <= #{error.dig("schema", "maxLength")}"
|
|
192
|
+
when "minItems"
|
|
193
|
+
"array must have >= #{error.dig("schema", "minItems")} items"
|
|
194
|
+
when "maxItems"
|
|
195
|
+
"array must have <= #{error.dig("schema", "maxItems")} items"
|
|
196
|
+
when "enum"
|
|
197
|
+
"must be one of: #{truncate(error.dig("schema", "enum").inspect, max: 40)}"
|
|
198
|
+
when "pattern"
|
|
199
|
+
"does not match pattern: #{error.dig("schema", "pattern")}"
|
|
200
|
+
when "format"
|
|
201
|
+
"invalid #{error.dig("schema", "format")} format"
|
|
202
|
+
when "multipleOf"
|
|
203
|
+
"must be a multiple of #{error.dig("schema", "multipleOf")}"
|
|
204
|
+
when "uniqueItems"
|
|
205
|
+
"array items must be unique"
|
|
206
|
+
else
|
|
207
|
+
error["error"] || type
|
|
208
|
+
end
|
|
209
|
+
end
|
|
230
210
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
211
|
+
# ── Annotated YAML rendering ────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
# Render the manifest as annotated, colorized YAML.
|
|
214
|
+
# @parameter manifest [Hash] the resource data that failed validation.
|
|
215
|
+
# @parameter errors [Array(Hash)] the raw JSONSchemer error hashes.
|
|
216
|
+
# @returns [String] the YAML with errors highlighted inline.
|
|
217
|
+
def annotate_manifest(manifest, errors)
|
|
218
|
+
# Maps each error to its formatted message.
|
|
219
|
+
# @example
|
|
220
|
+
# error_at["/spec/replicas"] => "expected integer, got String"
|
|
221
|
+
# missing_at["/spec"] => ["selector", "template"]
|
|
222
|
+
error_at = {}
|
|
223
|
+
missing_at = {}
|
|
224
|
+
|
|
225
|
+
errors.each do |e|
|
|
226
|
+
ptr = e["data_pointer"]
|
|
227
|
+
if e["type"] == "required"
|
|
228
|
+
keys = e.dig("details", "missing_keys") || []
|
|
229
|
+
(missing_at[ptr] ||= []).concat(keys)
|
|
230
|
+
else
|
|
231
|
+
error_at[ptr] = format_error_short(e)
|
|
232
|
+
end
|
|
234
233
|
end
|
|
235
234
|
|
|
236
|
-
|
|
237
|
-
|
|
235
|
+
yaml_lines = manifest.to_yaml.lines
|
|
236
|
+
path_stack = [] # Each entry is [segment, indent_level].
|
|
237
|
+
array_indices = {} # Current 0-based index per indent level.
|
|
238
|
+
result = []
|
|
238
239
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
# Pop anything strictly deeper, but keep the parent.
|
|
242
|
-
path_stack.pop while path_stack.any? && path_stack.last[1] > indent
|
|
243
|
-
array_indices.delete_if { |level, _| level > indent }
|
|
240
|
+
yaml_lines.each do |line|
|
|
241
|
+
raw = line.chomp
|
|
244
242
|
|
|
245
|
-
|
|
246
|
-
|
|
243
|
+
if raw.match?(/\A---\s*$/)
|
|
244
|
+
result << raw
|
|
245
|
+
next
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
indent = line[/\A\s*/].length
|
|
249
|
+
is_array_item = raw.match?(/\A\s*-\s/)
|
|
250
|
+
|
|
251
|
+
if is_array_item
|
|
252
|
+
# Array items sit at the same indent as their parent key.
|
|
253
|
+
# Pop anything strictly deeper, but keep the parent.
|
|
254
|
+
path_stack.pop while path_stack.any? && path_stack.last[1] > indent
|
|
255
|
+
array_indices.delete_if { |level, _| level > indent }
|
|
256
|
+
|
|
257
|
+
idx = array_indices[indent] || 0
|
|
258
|
+
array_indices[indent] = idx + 1
|
|
259
|
+
|
|
260
|
+
# Push the index one level deeper than the "- " prefix
|
|
261
|
+
path_stack.push([idx.to_s, indent + 1])
|
|
262
|
+
|
|
263
|
+
# Inline key on the array item: "- name: app"
|
|
264
|
+
if (m = raw.match(/\A\s*-\s+(\w[\w.-]*):/))
|
|
265
|
+
path_stack.push([m[1], indent + 2])
|
|
266
|
+
end
|
|
267
|
+
else
|
|
268
|
+
# Regular key — pop entries at >= this indent
|
|
269
|
+
path_stack.pop while path_stack.any? && path_stack.last[1] >= indent
|
|
270
|
+
array_indices.delete_if { |level, _| level >= indent }
|
|
271
|
+
|
|
272
|
+
if (m = raw.match(/\A\s*(\w[\w.-]*):/))
|
|
273
|
+
path_stack.push([m[1], indent])
|
|
274
|
+
end
|
|
275
|
+
end
|
|
247
276
|
|
|
248
|
-
|
|
249
|
-
path_stack.push([idx.to_s, indent + 1])
|
|
277
|
+
pointer = "/" + path_stack.map(&:first).join("/")
|
|
250
278
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
279
|
+
if error_at.key?(pointer)
|
|
280
|
+
result << "#{RED}#{raw}#{RESET} #{YELLOW}# #{error_at[pointer]}#{RESET}"
|
|
281
|
+
else
|
|
282
|
+
result << raw
|
|
254
283
|
end
|
|
255
|
-
else
|
|
256
|
-
# Regular key — pop entries at >= this indent
|
|
257
|
-
path_stack.pop while path_stack.any? && path_stack.last[1] >= indent
|
|
258
|
-
array_indices.delete_if { |level, _| level >= indent }
|
|
259
284
|
|
|
260
|
-
|
|
261
|
-
|
|
285
|
+
# Inject missing required keys as red lines right after their parent
|
|
286
|
+
if missing_at.key?(pointer)
|
|
287
|
+
child_indent = indent + 2
|
|
288
|
+
missing_at[pointer].each do |mk|
|
|
289
|
+
result << "#{RED}#{" " * child_indent}#{mk}:#{RESET} #{YELLOW}# MISSING — required#{RESET}"
|
|
290
|
+
end
|
|
262
291
|
end
|
|
263
292
|
end
|
|
264
293
|
|
|
265
|
-
|
|
294
|
+
result.join("\n")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# ── Helpers ──────────────────────────────────────────────────────
|
|
266
298
|
|
|
267
|
-
|
|
268
|
-
|
|
299
|
+
def pointer_to_dot(pointer)
|
|
300
|
+
if pointer.nil? || pointer.empty?
|
|
301
|
+
"root"
|
|
269
302
|
else
|
|
270
|
-
|
|
303
|
+
pointer.delete_prefix("/").gsub("/", ".")
|
|
271
304
|
end
|
|
305
|
+
end
|
|
272
306
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
child_indent = indent + 2
|
|
276
|
-
missing_at[pointer].each do |mk|
|
|
277
|
-
result << "#{RED}#{" " * child_indent}#{mk}:#{RESET} #{YELLOW}# MISSING — required#{RESET}"
|
|
278
|
-
end
|
|
279
|
-
end
|
|
307
|
+
def join_path(base, key)
|
|
308
|
+
base == "root" ? key.to_s : "#{base}.#{key}"
|
|
280
309
|
end
|
|
281
310
|
|
|
282
|
-
|
|
283
|
-
|
|
311
|
+
def truncate(str, max: 60)
|
|
312
|
+
str.length > max ? "#{str[0...max]}..." : str
|
|
313
|
+
end
|
|
314
|
+
end
|
|
284
315
|
|
|
285
|
-
|
|
316
|
+
# Raised when a schema download fails.
|
|
317
|
+
class DownloadError < Error
|
|
318
|
+
attr_reader :url, :status_code
|
|
286
319
|
|
|
287
|
-
def
|
|
288
|
-
|
|
289
|
-
|
|
320
|
+
def initialize(message = nil, url: nil, status_code: nil)
|
|
321
|
+
@url = url
|
|
322
|
+
@status_code = status_code
|
|
323
|
+
super(message || "Failed to download schema: #{@url} (HTTP #{@status_code})")
|
|
290
324
|
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
291
327
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
328
|
+
# Flat aliases for backward compatibility with pre-nesting code.
|
|
329
|
+
# Local/ConstantMatchesPath requires nesting under Kube::Errors now.
|
|
330
|
+
Error = Errors::Error
|
|
331
|
+
UnknownVersionError = Errors::UnknownVersionError
|
|
332
|
+
IncorrectVersionFormat = Errors::IncorrectVersionFormat
|
|
333
|
+
MissingSchemaError = Errors::MissingSchemaError
|
|
334
|
+
ValidationError = Errors::ValidationError
|
|
335
|
+
DownloadError = Errors::DownloadError
|
|
336
|
+
end
|
|
295
337
|
|
|
296
|
-
def truncate(str, max: 60)
|
|
297
|
-
str.length > max ? "#{str[0...max]}..." : str
|
|
298
|
-
end
|
|
299
|
-
end
|
|
300
338
|
|
|
301
|
-
|
|
302
|
-
class DownloadError < Error
|
|
303
|
-
attr_reader :url, :status_code
|
|
339
|
+
__END__
|
|
304
340
|
|
|
305
|
-
|
|
306
|
-
@url = url
|
|
307
|
-
@status_code = status_code
|
|
308
|
-
super(message || "Failed to download schema: #{@url} (HTTP #{@status_code})")
|
|
309
|
-
end
|
|
310
|
-
end
|
|
311
|
-
end
|
|
341
|
+
require "kube/schema"
|
|
312
342
|
|
|
313
|
-
|
|
314
|
-
require "bundler/setup"
|
|
315
|
-
require "rspec/autorun"
|
|
316
|
-
require "kube/schema"
|
|
317
|
-
|
|
318
|
-
RSpec.describe Kube::ValidationError do
|
|
319
|
-
# Helper to build a minimal JSONSchemer-style error hash.
|
|
320
|
-
def error_hash(overrides = {})
|
|
321
|
-
{
|
|
322
|
-
"data" => nil,
|
|
323
|
-
"data_pointer" => "",
|
|
324
|
-
"schema" => {},
|
|
325
|
-
"schema_pointer" => "",
|
|
326
|
-
"root_schema" => {},
|
|
327
|
-
"type" => "unknown",
|
|
328
|
-
"error" => "something went wrong"
|
|
329
|
-
}.merge(overrides)
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
describe "#message" do
|
|
333
|
-
context "header" do
|
|
334
|
-
it "includes kind and name when both are provided" do
|
|
335
|
-
err = described_class.new(
|
|
336
|
-
[error_hash],
|
|
337
|
-
kind: "Deployment",
|
|
338
|
-
name: "web"
|
|
339
|
-
)
|
|
340
|
-
expect(err.message).to include('Schema validation failed for Deployment "web"')
|
|
341
|
-
end
|
|
343
|
+
validation_error = Kube::ValidationError
|
|
342
344
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
345
|
+
# Helper to build a minimal JSONSchemer-style error hash.
|
|
346
|
+
error_hash = lambda { |overrides = {}|
|
|
347
|
+
{
|
|
348
|
+
"data" => nil,
|
|
349
|
+
"data_pointer" => "",
|
|
350
|
+
"schema" => {},
|
|
351
|
+
"schema_pointer" => "",
|
|
352
|
+
"root_schema" => {},
|
|
353
|
+
"type" => "unknown",
|
|
354
|
+
"error" => "something went wrong"
|
|
355
|
+
}.merge(overrides)
|
|
356
|
+
}
|
|
350
357
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
358
|
+
describe "Kube::ValidationError" do
|
|
359
|
+
describe "#message header" do
|
|
360
|
+
it "includes kind and name when both are provided" do
|
|
361
|
+
err = validation_error.new([error_hash.()], kind: "Deployment", name: "web")
|
|
362
|
+
err.message.should.include 'Schema validation failed for Deployment "web"'
|
|
363
|
+
end
|
|
356
364
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
error_hash(
|
|
362
|
-
"data_pointer" => "/spec/replicas",
|
|
363
|
-
"type" => type,
|
|
364
|
-
"data" => "bad_value"
|
|
365
|
-
)
|
|
366
|
-
])
|
|
367
|
-
expect(err.message).to include("spec.replicas = \"bad_value\" — expected #{type}, got String")
|
|
368
|
-
end
|
|
369
|
-
end
|
|
365
|
+
it "includes kind without name" do
|
|
366
|
+
err = validation_error.new([error_hash.()], kind: "Service")
|
|
367
|
+
err.message.should.include "Schema validation failed for Service"
|
|
368
|
+
end
|
|
370
369
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
370
|
+
it "shows a generic header when no kind or name" do
|
|
371
|
+
validation_error.new([error_hash.()]).message.should.include "Schema validation failed"
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
describe "#message type errors" do
|
|
376
|
+
%w[string integer number boolean array object null].each do |type|
|
|
377
|
+
it "formats #{type} type mismatch with the actual value" do
|
|
378
|
+
err = validation_error.new([
|
|
379
|
+
error_hash.(
|
|
380
|
+
"data_pointer" => "/spec/replicas",
|
|
381
|
+
"type" => type,
|
|
382
|
+
"data" => "bad_value"
|
|
383
|
+
)
|
|
384
|
+
])
|
|
385
|
+
err.message.should.include "spec.replicas = \"bad_value\" — expected #{type}, got String"
|
|
381
386
|
end
|
|
387
|
+
end
|
|
382
388
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
end
|
|
389
|
+
it "shows integer value for string type error" do
|
|
390
|
+
err = validation_error.new([
|
|
391
|
+
error_hash.(
|
|
392
|
+
"data_pointer" => "/spec/template/image",
|
|
393
|
+
"type" => "string",
|
|
394
|
+
"data" => 2343
|
|
395
|
+
)
|
|
396
|
+
])
|
|
397
|
+
err.message.should.include "spec.template.image = 2343 — expected string, got Integer"
|
|
398
|
+
end
|
|
399
|
+
end
|
|
395
400
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
401
|
+
describe "#message required errors" do
|
|
402
|
+
it "expands each missing key onto its own line" do
|
|
403
|
+
err = validation_error.new([
|
|
404
|
+
error_hash.(
|
|
405
|
+
"data_pointer" => "/spec",
|
|
406
|
+
"type" => "required",
|
|
407
|
+
"details" => { "missing_keys" => %w[selector template] }
|
|
408
|
+
)
|
|
409
|
+
])
|
|
410
|
+
err.message.should.include "spec.selector is required but missing"
|
|
411
|
+
err.message.should.include "spec.template is required but missing"
|
|
412
|
+
end
|
|
406
413
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
end
|
|
418
|
-
end
|
|
414
|
+
it "handles required at root level" do
|
|
415
|
+
err = validation_error.new([
|
|
416
|
+
error_hash.(
|
|
417
|
+
"data_pointer" => "",
|
|
418
|
+
"type" => "required",
|
|
419
|
+
"details" => { "missing_keys" => ["spec"] }
|
|
420
|
+
)
|
|
421
|
+
])
|
|
422
|
+
err.message.should.include "spec is required but missing"
|
|
423
|
+
end
|
|
419
424
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
425
|
+
it "falls back gracefully if missing_keys is absent" do
|
|
426
|
+
err = validation_error.new([
|
|
427
|
+
error_hash.(
|
|
428
|
+
"data_pointer" => "/spec",
|
|
429
|
+
"type" => "required",
|
|
430
|
+
"details" => {}
|
|
431
|
+
)
|
|
432
|
+
])
|
|
433
|
+
# Should not raise, should produce some message
|
|
434
|
+
err.message.should.include "spec"
|
|
435
|
+
end
|
|
436
|
+
end
|
|
432
437
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
438
|
+
describe "#message numeric constraint errors" do
|
|
439
|
+
it "formats minimum violations" do
|
|
440
|
+
err = validation_error.new([
|
|
441
|
+
error_hash.(
|
|
442
|
+
"data_pointer" => "/spec/replicas",
|
|
443
|
+
"type" => "minimum",
|
|
444
|
+
"data" => -1,
|
|
445
|
+
"schema" => { "minimum" => 0 }
|
|
446
|
+
)
|
|
447
|
+
])
|
|
448
|
+
err.message.should.include "spec.replicas = -1 — must be >= 0"
|
|
449
|
+
end
|
|
444
450
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
451
|
+
it "formats maximum violations" do
|
|
452
|
+
err = validation_error.new([
|
|
453
|
+
error_hash.(
|
|
454
|
+
"data_pointer" => "/spec/replicas",
|
|
455
|
+
"type" => "maximum",
|
|
456
|
+
"data" => 999,
|
|
457
|
+
"schema" => { "maximum" => 100 }
|
|
458
|
+
)
|
|
459
|
+
])
|
|
460
|
+
err.message.should.include "spec.replicas = 999 — must be <= 100"
|
|
461
|
+
end
|
|
456
462
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
463
|
+
it "formats exclusiveMinimum violations" do
|
|
464
|
+
err = validation_error.new([
|
|
465
|
+
error_hash.(
|
|
466
|
+
"data_pointer" => "/spec/value",
|
|
467
|
+
"type" => "exclusiveMinimum",
|
|
468
|
+
"data" => 0,
|
|
469
|
+
"schema" => { "exclusiveMinimum" => 0 }
|
|
470
|
+
)
|
|
471
|
+
])
|
|
472
|
+
err.message.should.include "spec.value = 0 — must be > 0"
|
|
473
|
+
end
|
|
468
474
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
end
|
|
475
|
+
it "formats exclusiveMaximum violations" do
|
|
476
|
+
err = validation_error.new([
|
|
477
|
+
error_hash.(
|
|
478
|
+
"data_pointer" => "/spec/value",
|
|
479
|
+
"type" => "exclusiveMaximum",
|
|
480
|
+
"data" => 100,
|
|
481
|
+
"schema" => { "exclusiveMaximum" => 100 }
|
|
482
|
+
)
|
|
483
|
+
])
|
|
484
|
+
err.message.should.include "spec.value = 100 — must be < 100"
|
|
485
|
+
end
|
|
481
486
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
487
|
+
it "formats multipleOf violations" do
|
|
488
|
+
err = validation_error.new([
|
|
489
|
+
error_hash.(
|
|
490
|
+
"data_pointer" => "/spec/count",
|
|
491
|
+
"type" => "multipleOf",
|
|
492
|
+
"data" => 7,
|
|
493
|
+
"schema" => { "multipleOf" => 3 }
|
|
494
|
+
)
|
|
495
|
+
])
|
|
496
|
+
err.message.should.include "spec.count = 7 — must be a multiple of 3"
|
|
497
|
+
end
|
|
498
|
+
end
|
|
494
499
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
500
|
+
describe "#message string constraint errors" do
|
|
501
|
+
it "formats pattern violations" do
|
|
502
|
+
err = validation_error.new([
|
|
503
|
+
error_hash.(
|
|
504
|
+
"data_pointer" => "/metadata/name",
|
|
505
|
+
"type" => "pattern",
|
|
506
|
+
"data" => "INVALID",
|
|
507
|
+
"schema" => { "pattern" => "^[a-z0-9-]+$" }
|
|
508
|
+
)
|
|
509
|
+
])
|
|
510
|
+
err.message.should.include 'metadata.name = "INVALID" — does not match pattern: ^[a-z0-9-]+$'
|
|
511
|
+
end
|
|
506
512
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
end
|
|
519
|
-
end
|
|
513
|
+
it "formats minLength violations" do
|
|
514
|
+
err = validation_error.new([
|
|
515
|
+
error_hash.(
|
|
516
|
+
"data_pointer" => "/metadata/name",
|
|
517
|
+
"type" => "minLength",
|
|
518
|
+
"data" => "",
|
|
519
|
+
"schema" => { "minLength" => 1 }
|
|
520
|
+
)
|
|
521
|
+
])
|
|
522
|
+
err.message.should.include 'metadata.name = "" — length must be >= 1'
|
|
523
|
+
end
|
|
520
524
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
end
|
|
525
|
+
it "formats maxLength violations" do
|
|
526
|
+
err = validation_error.new([
|
|
527
|
+
error_hash.(
|
|
528
|
+
"data_pointer" => "/metadata/name",
|
|
529
|
+
"type" => "maxLength",
|
|
530
|
+
"data" => "a" * 300,
|
|
531
|
+
"schema" => { "maxLength" => 253 }
|
|
532
|
+
)
|
|
533
|
+
])
|
|
534
|
+
err.message.should.include "metadata.name ="
|
|
535
|
+
err.message.should.include "— length must be <= 253"
|
|
536
|
+
end
|
|
537
|
+
end
|
|
535
538
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
539
|
+
describe "#message enum errors" do
|
|
540
|
+
it "shows allowed values" do
|
|
541
|
+
err = validation_error.new([
|
|
542
|
+
error_hash.(
|
|
543
|
+
"data_pointer" => "/spec/type",
|
|
544
|
+
"type" => "enum",
|
|
545
|
+
"data" => "InvalidType",
|
|
546
|
+
"schema" => { "enum" => %w[ClusterIP NodePort LoadBalancer] }
|
|
547
|
+
)
|
|
548
|
+
])
|
|
549
|
+
err.message.should.include '"InvalidType" — must be one of:'
|
|
550
|
+
err.message.should.include "ClusterIP"
|
|
551
|
+
end
|
|
552
|
+
end
|
|
549
553
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
554
|
+
describe "#message format errors" do
|
|
555
|
+
it "shows the expected format" do
|
|
556
|
+
err = validation_error.new([
|
|
557
|
+
error_hash.(
|
|
558
|
+
"data_pointer" => "/spec/startTime",
|
|
559
|
+
"type" => "format",
|
|
560
|
+
"data" => "not-a-date",
|
|
561
|
+
"schema" => { "format" => "date-time" }
|
|
562
|
+
)
|
|
563
|
+
])
|
|
564
|
+
err.message.should.include 'spec.startTime = "not-a-date" — invalid date-time format'
|
|
565
|
+
end
|
|
566
|
+
end
|
|
562
567
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
568
|
+
describe "#message array constraint errors" do
|
|
569
|
+
it "formats minItems violations" do
|
|
570
|
+
err = validation_error.new([
|
|
571
|
+
error_hash.(
|
|
572
|
+
"data_pointer" => "/spec/containers",
|
|
573
|
+
"type" => "minItems",
|
|
574
|
+
"data" => [],
|
|
575
|
+
"schema" => { "minItems" => 1 }
|
|
576
|
+
)
|
|
577
|
+
])
|
|
578
|
+
err.message.should.include "spec.containers — array must have >= 1 items, got 0"
|
|
579
|
+
end
|
|
574
580
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
581
|
+
it "formats uniqueItems violations" do
|
|
582
|
+
err = validation_error.new([
|
|
583
|
+
error_hash.(
|
|
584
|
+
"data_pointer" => "/spec/ports",
|
|
585
|
+
"type" => "uniqueItems",
|
|
586
|
+
"data" => [80, 80]
|
|
587
|
+
)
|
|
588
|
+
])
|
|
589
|
+
err.message.should.include "spec.ports — array items must be unique"
|
|
590
|
+
end
|
|
591
|
+
end
|
|
586
592
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
593
|
+
describe "#message unknown error types" do
|
|
594
|
+
it "falls back to JSONSchemer's error message" do
|
|
595
|
+
err = validation_error.new([
|
|
596
|
+
error_hash.(
|
|
597
|
+
"data_pointer" => "/spec/something",
|
|
598
|
+
"type" => "oneOf",
|
|
599
|
+
"error" => "value at `/spec/something` does not match exactly one schema"
|
|
600
|
+
)
|
|
601
|
+
])
|
|
602
|
+
err.message.should.include "spec.something: value at `/spec/something` does not match exactly one schema"
|
|
603
|
+
end
|
|
598
604
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
expect(err.message.length).to be < 500
|
|
611
|
-
end
|
|
612
|
-
end
|
|
605
|
+
it "falls back to the type name when no error message exists" do
|
|
606
|
+
err = validation_error.new([
|
|
607
|
+
error_hash.(
|
|
608
|
+
"data_pointer" => "/spec/x",
|
|
609
|
+
"type" => "customKeyword",
|
|
610
|
+
"error" => nil
|
|
611
|
+
)
|
|
612
|
+
])
|
|
613
|
+
err.message.should.include "spec.x: customKeyword"
|
|
614
|
+
end
|
|
615
|
+
end
|
|
613
616
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
617
|
+
describe "#message value truncation" do
|
|
618
|
+
it "truncates long values" do
|
|
619
|
+
err = validation_error.new([
|
|
620
|
+
error_hash.(
|
|
621
|
+
"data_pointer" => "/spec/data",
|
|
622
|
+
"type" => "integer",
|
|
623
|
+
"data" => "a" * 200
|
|
624
|
+
)
|
|
625
|
+
])
|
|
626
|
+
err.message.should.include "..."
|
|
627
|
+
err.message.length.should.be < 500
|
|
628
|
+
end
|
|
629
|
+
end
|
|
625
630
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
end
|
|
631
|
+
describe "#message dot-notation path conversion" do
|
|
632
|
+
it "converts JSON pointers to dot notation" do
|
|
633
|
+
err = validation_error.new([
|
|
634
|
+
error_hash.(
|
|
635
|
+
"data_pointer" => "/spec/template/spec/containers/0/image",
|
|
636
|
+
"type" => "string",
|
|
637
|
+
"data" => 123
|
|
638
|
+
)
|
|
639
|
+
])
|
|
640
|
+
err.message.should.include "spec.template.spec.containers.0.image = 123"
|
|
637
641
|
end
|
|
638
642
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
643
|
+
it "uses 'root' for empty pointer" do
|
|
644
|
+
err = validation_error.new([
|
|
645
|
+
error_hash.(
|
|
646
|
+
"data_pointer" => "",
|
|
647
|
+
"type" => "object",
|
|
648
|
+
"data" => "not_an_object"
|
|
649
|
+
)
|
|
650
|
+
])
|
|
651
|
+
err.message.should.include "root = "
|
|
645
652
|
end
|
|
653
|
+
end
|
|
646
654
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
655
|
+
describe "#errors" do
|
|
656
|
+
it "exposes the raw JSONSchemer error hashes" do
|
|
657
|
+
raw = [error_hash.("type" => "string")]
|
|
658
|
+
validation_error.new(raw).errors.should.be.identical_to raw
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
describe "annotated manifest output" do
|
|
663
|
+
it "includes the resource YAML when manifest is provided" do
|
|
664
|
+
err = validation_error.new(
|
|
665
|
+
[error_hash.("data_pointer" => "/spec/replicas", "type" => "integer", "data" => "bad")],
|
|
666
|
+
kind: "Deployment",
|
|
667
|
+
name: "web",
|
|
668
|
+
manifest: {
|
|
650
669
|
"apiVersion" => "apps/v1",
|
|
651
670
|
"kind" => "Deployment",
|
|
652
671
|
"metadata" => { "name" => "web", "namespace" => "prod" },
|
|
653
672
|
"spec" => { "replicas" => "bad" }
|
|
654
673
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
expect(err.message).to include("kind: Deployment")
|
|
663
|
-
expect(err.message).to include("name: web")
|
|
664
|
-
expect(err.message).to include("namespace: prod")
|
|
665
|
-
expect(err.message).to include("replicas: bad")
|
|
666
|
-
end
|
|
674
|
+
)
|
|
675
|
+
err.message.should.include "apiVersion: apps/v1"
|
|
676
|
+
err.message.should.include "kind: Deployment"
|
|
677
|
+
err.message.should.include "name: web"
|
|
678
|
+
err.message.should.include "namespace: prod"
|
|
679
|
+
err.message.should.include "replicas: bad"
|
|
680
|
+
end
|
|
667
681
|
|
|
668
|
-
|
|
669
|
-
|
|
682
|
+
it "highlights error lines in red (ANSI)" do
|
|
683
|
+
err = validation_error.new(
|
|
684
|
+
[error_hash.("data_pointer" => "/spec/replicas", "type" => "integer", "data" => "bad")],
|
|
685
|
+
manifest: {
|
|
670
686
|
"apiVersion" => "apps/v1",
|
|
671
687
|
"kind" => "Deployment",
|
|
672
688
|
"spec" => { "replicas" => "bad" }
|
|
673
689
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
expect(err.message).to include("\033[31m")
|
|
680
|
-
expect(err.message).to match(/\033\[31m\s*replicas: bad\033\[0m/)
|
|
681
|
-
end
|
|
690
|
+
)
|
|
691
|
+
# The replicas line should be wrapped in red ANSI
|
|
692
|
+
err.message.should.include "\033[31m"
|
|
693
|
+
err.message.should.match(/\033\[31m\s*replicas: bad\033\[0m/)
|
|
694
|
+
end
|
|
682
695
|
|
|
683
|
-
|
|
684
|
-
|
|
696
|
+
it "appends a yellow inline comment on error lines" do
|
|
697
|
+
err = validation_error.new(
|
|
698
|
+
[error_hash.("data_pointer" => "/spec/replicas", "type" => "integer", "data" => "bad")],
|
|
699
|
+
manifest: {
|
|
685
700
|
"apiVersion" => "v1",
|
|
686
701
|
"kind" => "Pod",
|
|
687
702
|
"spec" => { "replicas" => "bad" }
|
|
688
703
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
)
|
|
693
|
-
expect(err.message).to match(/\033\[33m# expected integer, got String\033\[0m/)
|
|
694
|
-
end
|
|
704
|
+
)
|
|
705
|
+
err.message.should.match(/\033\[33m# expected integer, got String\033\[0m/)
|
|
706
|
+
end
|
|
695
707
|
|
|
696
|
-
|
|
697
|
-
|
|
708
|
+
it "injects missing required keys as red lines" do
|
|
709
|
+
err = validation_error.new(
|
|
710
|
+
[error_hash.(
|
|
711
|
+
"data_pointer" => "/spec",
|
|
712
|
+
"type" => "required",
|
|
713
|
+
"details" => { "missing_keys" => ["selector"] }
|
|
714
|
+
)],
|
|
715
|
+
manifest: {
|
|
698
716
|
"apiVersion" => "apps/v1",
|
|
699
717
|
"kind" => "Deployment",
|
|
700
718
|
"spec" => { "replicas" => 3 }
|
|
701
719
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
"details" => { "missing_keys" => ["selector"] }
|
|
707
|
-
)],
|
|
708
|
-
manifest: manifest
|
|
709
|
-
)
|
|
710
|
-
expect(err.message).to match(/\033\[31m\s*selector:\033\[0m/)
|
|
711
|
-
expect(err.message).to include("MISSING")
|
|
712
|
-
end
|
|
720
|
+
)
|
|
721
|
+
err.message.should.match(/\033\[31m\s*selector:\033\[0m/)
|
|
722
|
+
err.message.should.include "MISSING"
|
|
723
|
+
end
|
|
713
724
|
|
|
714
|
-
|
|
715
|
-
|
|
725
|
+
it "does not color lines that have no errors" do
|
|
726
|
+
err = validation_error.new(
|
|
727
|
+
[error_hash.("data_pointer" => "/spec/replicas", "type" => "integer", "data" => "bad")],
|
|
728
|
+
manifest: {
|
|
716
729
|
"apiVersion" => "apps/v1",
|
|
717
730
|
"kind" => "Deployment",
|
|
718
731
|
"metadata" => { "name" => "web" },
|
|
719
732
|
"spec" => { "replicas" => "bad" }
|
|
720
733
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
next unless line.include?("name: web")
|
|
728
|
-
expect(line).not_to include("\033[31m")
|
|
729
|
-
end
|
|
730
|
-
end
|
|
734
|
+
)
|
|
735
|
+
# The "name: web" line should NOT have red ANSI
|
|
736
|
+
lines = err.message.each_line.select { |line| line.include?("name: web") }
|
|
737
|
+
lines.should.not.be.empty
|
|
738
|
+
lines.each { |line| line.should.not.include "\033[31m" }
|
|
739
|
+
end
|
|
731
740
|
|
|
732
|
-
|
|
733
|
-
|
|
741
|
+
it "handles array items with correct pointer mapping" do
|
|
742
|
+
err = validation_error.new(
|
|
743
|
+
[error_hash.(
|
|
744
|
+
"data_pointer" => "/spec/containers/0/image",
|
|
745
|
+
"type" => "string",
|
|
746
|
+
"data" => 2343
|
|
747
|
+
)],
|
|
748
|
+
manifest: {
|
|
734
749
|
"apiVersion" => "v1",
|
|
735
750
|
"kind" => "Pod",
|
|
736
751
|
"spec" => {
|
|
@@ -739,23 +754,15 @@ if __FILE__ == $0
|
|
|
739
754
|
]
|
|
740
755
|
}
|
|
741
756
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
"type" => "string",
|
|
746
|
-
"data" => 2343
|
|
747
|
-
)],
|
|
748
|
-
manifest: manifest
|
|
749
|
-
)
|
|
750
|
-
expect(err.message).to match(/\033\[31m\s*image: 2343\033\[0m/)
|
|
751
|
-
end
|
|
757
|
+
)
|
|
758
|
+
err.message.should.match(/\033\[31m\s*image: 2343\033\[0m/)
|
|
759
|
+
end
|
|
752
760
|
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
end
|
|
761
|
+
it "omits the manifest section when manifest is nil" do
|
|
762
|
+
err = validation_error.new(
|
|
763
|
+
[error_hash.("data_pointer" => "/spec/x", "type" => "string", "data" => 1)]
|
|
764
|
+
)
|
|
765
|
+
err.message.should.not.include "---"
|
|
759
766
|
end
|
|
760
767
|
end
|
|
761
768
|
end
|