harnex 0.7.12 → 0.7.14
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/CHANGELOG.md +71 -0
- data/README.md +62 -17
- data/TECHNICAL.md +20 -1
- data/guides/01_dispatch.md +29 -9
- data/guides/04_monitoring.md +11 -5
- data/lib/harnex/adapters/base.rb +16 -0
- data/lib/harnex/adapters/codex.rb +4 -0
- data/lib/harnex/adapters/codex_appserver.rb +12 -0
- data/lib/harnex/adapters/pi.rb +46 -5
- data/lib/harnex/artifact_report.rb +522 -33
- data/lib/harnex/cli.rb +17 -0
- data/lib/harnex/commands/artifact_report.rb +94 -0
- data/lib/harnex/commands/orchestration.rb +170 -0
- data/lib/harnex/commands/run.rb +68 -5
- data/lib/harnex/commands/wait.rb +6 -1
- data/lib/harnex/commands/watch.rb +2 -0
- data/lib/harnex/context_telemetry.rb +104 -0
- data/lib/harnex/core.rb +2 -0
- data/lib/harnex/orchestration.rb +458 -0
- data/lib/harnex/runtime/session.rb +620 -30
- data/lib/harnex/terminal_status.rb +7 -0
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +4 -0
- metadata +6 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "digest"
|
|
2
|
+
require "fileutils"
|
|
2
3
|
require "json"
|
|
3
4
|
|
|
4
5
|
module Harnex
|
|
@@ -10,70 +11,503 @@ module Harnex
|
|
|
10
11
|
MAX_CANONICAL_ARTIFACTS = 50
|
|
11
12
|
MAX_EVIDENCE_ITEMS = 20
|
|
12
13
|
MAX_STRING_LENGTH = 2_000
|
|
14
|
+
MAX_DIAGNOSTICS = 100
|
|
15
|
+
|
|
16
|
+
REPORT_STATUSES = %w[in_progress pass fail blocked unknown].freeze
|
|
17
|
+
VALIDATION_STATUSES = %w[in_progress pass fail not_run unknown].freeze
|
|
18
|
+
OUTCOME_STATUSES = %w[accepted rejected no_change unknown].freeze
|
|
19
|
+
ACCEPTED_OUTCOME_STATUSES = %w[accepted no_change].freeze
|
|
20
|
+
|
|
21
|
+
ValidationResult = Struct.new(
|
|
22
|
+
:ok, :status, :path, :bytes, :sha256, :schema, :diagnostics, :report,
|
|
23
|
+
keyword_init: true
|
|
24
|
+
) do
|
|
25
|
+
def public_payload(final: false)
|
|
26
|
+
{
|
|
27
|
+
"ok" => !!ok,
|
|
28
|
+
"status" => status,
|
|
29
|
+
"path" => path,
|
|
30
|
+
"bytes" => bytes,
|
|
31
|
+
"sha256" => sha256,
|
|
32
|
+
"schema" => schema,
|
|
33
|
+
"final" => !!final,
|
|
34
|
+
"diagnostics" => diagnostics || []
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
13
38
|
|
|
14
39
|
module_function
|
|
15
40
|
|
|
16
|
-
def
|
|
41
|
+
def validate(path, final: false)
|
|
17
42
|
report_path = File.expand_path(path.to_s)
|
|
18
|
-
return
|
|
43
|
+
return validation_failure(
|
|
44
|
+
report_path,
|
|
45
|
+
status: "missing",
|
|
46
|
+
diagnostics: [diagnostic("report_missing", "$", "artifact report file does not exist")]
|
|
47
|
+
) unless File.file?(report_path)
|
|
19
48
|
|
|
20
49
|
bytes = File.size(report_path)
|
|
21
50
|
sha256 = file_sha256(report_path)
|
|
22
51
|
if bytes > MAX_BYTES
|
|
23
|
-
return
|
|
52
|
+
return validation_failure(
|
|
24
53
|
report_path,
|
|
54
|
+
status: "oversized",
|
|
25
55
|
bytes: bytes,
|
|
26
56
|
sha256: sha256,
|
|
27
|
-
|
|
28
|
-
|
|
57
|
+
diagnostics: [
|
|
58
|
+
diagnostic("report_oversized", "$", "artifact report exceeds the #{MAX_BYTES}-byte limit")
|
|
59
|
+
]
|
|
29
60
|
)
|
|
30
61
|
end
|
|
31
62
|
|
|
32
63
|
parsed = JSON.parse(File.read(report_path, mode: "rb"))
|
|
33
64
|
unless parsed.is_a?(Hash)
|
|
34
|
-
return
|
|
65
|
+
return validation_failure(
|
|
35
66
|
report_path,
|
|
67
|
+
status: "malformed",
|
|
36
68
|
bytes: bytes,
|
|
37
69
|
sha256: sha256,
|
|
38
|
-
|
|
39
|
-
warning: "artifact report must be a JSON object"
|
|
70
|
+
diagnostics: [diagnostic("object_required", "$", "artifact report must be a JSON object")]
|
|
40
71
|
)
|
|
41
72
|
end
|
|
42
73
|
|
|
43
|
-
schema = parsed["schema"].
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)
|
|
53
|
-
end
|
|
74
|
+
schema = parsed["schema"].is_a?(String) ? bounded_string(parsed["schema"]) : nil
|
|
75
|
+
diagnostics = validate_document(parsed, final: final)
|
|
76
|
+
status = if parsed["schema"] != SCHEMA
|
|
77
|
+
"unsupported_schema"
|
|
78
|
+
elsif diagnostics.empty?
|
|
79
|
+
"valid"
|
|
80
|
+
else
|
|
81
|
+
"invalid"
|
|
82
|
+
end
|
|
54
83
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
84
|
+
ValidationResult.new(
|
|
85
|
+
ok: diagnostics.empty?,
|
|
86
|
+
status: status,
|
|
87
|
+
path: report_path,
|
|
88
|
+
bytes: bytes,
|
|
89
|
+
sha256: sha256,
|
|
90
|
+
schema: schema,
|
|
91
|
+
diagnostics: diagnostics,
|
|
92
|
+
report: parsed
|
|
93
|
+
)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
validation_failure(
|
|
58
96
|
report_path,
|
|
97
|
+
status: "malformed",
|
|
59
98
|
bytes: safe_file_size(report_path),
|
|
60
99
|
sha256: safe_file_sha256(report_path),
|
|
61
|
-
|
|
62
|
-
warning: "malformed artifact report JSON: #{bounded_string(e.message)}"
|
|
100
|
+
diagnostics: [diagnostic("json_invalid", "$", "artifact report is not valid JSON")]
|
|
63
101
|
)
|
|
64
|
-
rescue StandardError
|
|
65
|
-
|
|
102
|
+
rescue StandardError
|
|
103
|
+
validation_failure(
|
|
66
104
|
report_path,
|
|
105
|
+
status: "error",
|
|
67
106
|
bytes: safe_file_size(report_path),
|
|
68
107
|
sha256: safe_file_sha256(report_path),
|
|
69
|
-
|
|
70
|
-
|
|
108
|
+
diagnostics: [diagnostic("read_error", "$", "artifact report could not be read")]
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ingest(path)
|
|
113
|
+
result = validate(path)
|
|
114
|
+
case result.status
|
|
115
|
+
when "missing"
|
|
116
|
+
missing(result.path)
|
|
117
|
+
when "oversized"
|
|
118
|
+
warning(
|
|
119
|
+
result.path,
|
|
120
|
+
bytes: result.bytes,
|
|
121
|
+
sha256: result.sha256,
|
|
122
|
+
ingest_status: "oversized",
|
|
123
|
+
warning: "artifact report is #{result.bytes} bytes; max is #{MAX_BYTES} bytes",
|
|
124
|
+
diagnostics: result.diagnostics
|
|
125
|
+
)
|
|
126
|
+
when "malformed"
|
|
127
|
+
warning(
|
|
128
|
+
result.path,
|
|
129
|
+
bytes: result.bytes,
|
|
130
|
+
sha256: result.sha256,
|
|
131
|
+
ingest_status: "malformed",
|
|
132
|
+
warning: "malformed artifact report JSON",
|
|
133
|
+
diagnostics: result.diagnostics
|
|
134
|
+
)
|
|
135
|
+
when "unsupported_schema"
|
|
136
|
+
warning(
|
|
137
|
+
result.path,
|
|
138
|
+
bytes: result.bytes,
|
|
139
|
+
sha256: result.sha256,
|
|
140
|
+
ingest_status: "unsupported_schema",
|
|
141
|
+
schema: result.schema,
|
|
142
|
+
warning: "unsupported artifact report schema; expected #{SCHEMA}",
|
|
143
|
+
diagnostics: result.diagnostics
|
|
144
|
+
)
|
|
145
|
+
when "error"
|
|
146
|
+
warning(
|
|
147
|
+
result.path,
|
|
148
|
+
bytes: result.bytes,
|
|
149
|
+
sha256: result.sha256,
|
|
150
|
+
ingest_status: "error",
|
|
151
|
+
warning: "artifact report ingest failed",
|
|
152
|
+
diagnostics: result.diagnostics
|
|
153
|
+
)
|
|
154
|
+
else
|
|
155
|
+
payload = build_payload(
|
|
156
|
+
result.path,
|
|
157
|
+
bytes: result.bytes,
|
|
158
|
+
sha256: result.sha256,
|
|
159
|
+
report: result.report
|
|
160
|
+
)
|
|
161
|
+
unless result.ok
|
|
162
|
+
metadata = payload.fetch("artifact_report")
|
|
163
|
+
metadata["ingest_status"] = "invalid"
|
|
164
|
+
metadata["warning"] = "artifact report does not conform to #{SCHEMA}"
|
|
165
|
+
metadata["diagnostics"] = result.diagnostics
|
|
166
|
+
end
|
|
167
|
+
payload
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def initialize_file(path, force: false)
|
|
172
|
+
report_path = File.expand_path(path.to_s)
|
|
173
|
+
raise ArgumentError, "artifact report path is required" if path.to_s.strip.empty?
|
|
174
|
+
if File.exist?(report_path) && !force
|
|
175
|
+
raise ArgumentError, "artifact report already exists: #{report_path} (use --force to replace it)"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
FileUtils.mkdir_p(File.dirname(report_path))
|
|
179
|
+
File.write(report_path, JSON.pretty_generate(template) + "\n")
|
|
180
|
+
report_path
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def template
|
|
184
|
+
{
|
|
185
|
+
"schema" => SCHEMA,
|
|
186
|
+
"status" => "in_progress",
|
|
187
|
+
"canonical_artifacts" => [],
|
|
188
|
+
"outcome" => {
|
|
189
|
+
"status" => "unknown",
|
|
190
|
+
"summary" => ""
|
|
191
|
+
},
|
|
192
|
+
"validation" => {
|
|
193
|
+
"status" => "not_run",
|
|
194
|
+
"commands" => [],
|
|
195
|
+
"final_reported" => false
|
|
196
|
+
},
|
|
197
|
+
"artifacts" => []
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def accepted_final?(result)
|
|
202
|
+
result.ok && ACCEPTED_OUTCOME_STATUSES.include?(outcome_status(result))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def fingerprint(path)
|
|
206
|
+
report_path = File.expand_path(path.to_s)
|
|
207
|
+
return nil unless File.file?(report_path)
|
|
208
|
+
|
|
209
|
+
stat = File.stat(report_path)
|
|
210
|
+
{
|
|
211
|
+
"bytes" => stat.size,
|
|
212
|
+
"sha256" => file_sha256(report_path),
|
|
213
|
+
"mtime_ns" => (stat.mtime.to_r * 1_000_000_000).to_i,
|
|
214
|
+
"ctime_ns" => (stat.ctime.to_r * 1_000_000_000).to_i,
|
|
215
|
+
"inode" => stat.ino
|
|
216
|
+
}
|
|
217
|
+
rescue StandardError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def outcome_status(result)
|
|
222
|
+
report = result.respond_to?(:report) ? result.report : result
|
|
223
|
+
return nil unless report.is_a?(Hash)
|
|
224
|
+
|
|
225
|
+
outcome = report["outcome"]
|
|
226
|
+
outcome.is_a?(Hash) ? outcome["status"] : nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def validate_document(report, final: false)
|
|
230
|
+
diagnostics = []
|
|
231
|
+
validate_schema(report, diagnostics)
|
|
232
|
+
validate_optional_enum(report, "status", REPORT_STATUSES, diagnostics)
|
|
233
|
+
validate_string_array_field(
|
|
234
|
+
report,
|
|
235
|
+
"canonical_artifacts",
|
|
236
|
+
max_items: MAX_CANONICAL_ARTIFACTS,
|
|
237
|
+
diagnostics: diagnostics
|
|
238
|
+
)
|
|
239
|
+
validate_outcome(report["outcome"], diagnostics) if report.key?("outcome")
|
|
240
|
+
validate_validation(report["validation"], diagnostics) if report.key?("validation")
|
|
241
|
+
validate_artifacts(report["artifacts"], diagnostics) if report.key?("artifacts")
|
|
242
|
+
validate_final_contract(report, diagnostics) if final
|
|
243
|
+
diagnostics.first(MAX_DIAGNOSTICS)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def validate_schema(report, diagnostics)
|
|
247
|
+
value = report["schema"]
|
|
248
|
+
if !value.is_a?(String) || value.empty?
|
|
249
|
+
diagnostics << diagnostic("schema_required", "$.schema", "schema must be a non-empty string")
|
|
250
|
+
elsif value != SCHEMA
|
|
251
|
+
diagnostics << diagnostic("schema_unsupported", "$.schema", "schema must equal #{SCHEMA}")
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def validate_optional_enum(report, key, values, diagnostics)
|
|
256
|
+
return unless report.key?(key)
|
|
257
|
+
|
|
258
|
+
value = report[key]
|
|
259
|
+
unless value.is_a?(String) && values.include?(value)
|
|
260
|
+
diagnostics << diagnostic("enum", "$.#{key}", "#{key} must be one of #{values.join(', ')}")
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def validate_outcome(value, diagnostics)
|
|
265
|
+
unless value.is_a?(Hash)
|
|
266
|
+
diagnostics << diagnostic("object_required", "$.outcome", "outcome must be an object")
|
|
267
|
+
return
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
status = value["status"]
|
|
271
|
+
unless status.is_a?(String) && OUTCOME_STATUSES.include?(status)
|
|
272
|
+
diagnostics << diagnostic(
|
|
273
|
+
"enum",
|
|
274
|
+
"$.outcome.status",
|
|
275
|
+
"outcome.status must be one of #{OUTCOME_STATUSES.join(', ')}"
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
validate_optional_string(value, "summary", "$.outcome.summary", diagnostics)
|
|
279
|
+
validate_optional_string(value, "commit_sha", "$.outcome.commit_sha", diagnostics)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def validate_validation(value, diagnostics)
|
|
283
|
+
unless value.is_a?(Hash)
|
|
284
|
+
diagnostics << diagnostic("object_required", "$.validation", "validation must be an object")
|
|
285
|
+
return
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
if value.key?("status")
|
|
289
|
+
status = value["status"]
|
|
290
|
+
unless status.is_a?(String) && VALIDATION_STATUSES.include?(status)
|
|
291
|
+
diagnostics << diagnostic(
|
|
292
|
+
"enum",
|
|
293
|
+
"$.validation.status",
|
|
294
|
+
"validation.status must be one of #{VALIDATION_STATUSES.join(', ')}"
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
validate_commands(value["commands"], diagnostics) if value.key?("commands")
|
|
300
|
+
if value.key?("final_reported") && !boolean?(value["final_reported"])
|
|
301
|
+
diagnostics << diagnostic(
|
|
302
|
+
"boolean_required",
|
|
303
|
+
"$.validation.final_reported",
|
|
304
|
+
"validation.final_reported must be true or false"
|
|
305
|
+
)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def validate_commands(value, diagnostics)
|
|
310
|
+
unless value.is_a?(Array)
|
|
311
|
+
diagnostics << diagnostic("array_required", "$.validation.commands", "validation.commands must be an array")
|
|
312
|
+
return
|
|
313
|
+
end
|
|
314
|
+
if value.length > MAX_COMMANDS
|
|
315
|
+
diagnostics << diagnostic(
|
|
316
|
+
"too_many_items",
|
|
317
|
+
"$.validation.commands",
|
|
318
|
+
"validation.commands may contain at most #{MAX_COMMANDS} entries"
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
value.first(MAX_COMMANDS).each_with_index do |entry, index|
|
|
323
|
+
path = "$.validation.commands[#{index}]"
|
|
324
|
+
unless entry.is_a?(Hash)
|
|
325
|
+
diagnostics << diagnostic("object_required", path, "validation command must be an object")
|
|
326
|
+
next
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
validate_required_string(entry, "cmd", "#{path}.cmd", diagnostics)
|
|
330
|
+
unless entry["exit_code"].is_a?(Integer)
|
|
331
|
+
diagnostics << diagnostic(
|
|
332
|
+
"integer_required",
|
|
333
|
+
"#{path}.exit_code",
|
|
334
|
+
"validation command exit_code must be an integer"
|
|
335
|
+
)
|
|
336
|
+
end
|
|
337
|
+
validate_optional_string(entry, "status", "#{path}.status", diagnostics)
|
|
338
|
+
if entry.key?("duration_s") && !finite_non_negative_number?(entry["duration_s"])
|
|
339
|
+
diagnostics << diagnostic(
|
|
340
|
+
"number_required",
|
|
341
|
+
"#{path}.duration_s",
|
|
342
|
+
"validation command duration_s must be a finite non-negative number"
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def validate_artifacts(value, diagnostics)
|
|
349
|
+
unless value.is_a?(Array)
|
|
350
|
+
diagnostics << diagnostic("array_required", "$.artifacts", "artifacts must be an array")
|
|
351
|
+
return
|
|
352
|
+
end
|
|
353
|
+
if value.length > MAX_ARTIFACTS
|
|
354
|
+
diagnostics << diagnostic(
|
|
355
|
+
"too_many_items",
|
|
356
|
+
"$.artifacts",
|
|
357
|
+
"artifacts may contain at most #{MAX_ARTIFACTS} entries"
|
|
358
|
+
)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
value.first(MAX_ARTIFACTS).each_with_index do |entry, index|
|
|
362
|
+
path = "$.artifacts[#{index}]"
|
|
363
|
+
unless entry.is_a?(Hash)
|
|
364
|
+
diagnostics << diagnostic("object_required", path, "artifact must be an object")
|
|
365
|
+
next
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
validate_required_string(entry, "type", "#{path}.type", diagnostics)
|
|
369
|
+
validate_required_string(entry, "summary", "#{path}.summary", diagnostics)
|
|
370
|
+
validate_string_array_value(
|
|
371
|
+
entry["evidence"],
|
|
372
|
+
"#{path}.evidence",
|
|
373
|
+
max_items: MAX_EVIDENCE_ITEMS,
|
|
374
|
+
diagnostics: diagnostics,
|
|
375
|
+
required: true
|
|
376
|
+
)
|
|
377
|
+
confidence = entry["confidence"]
|
|
378
|
+
unless finite_number?(confidence) && confidence >= 0.0 && confidence <= 1.0
|
|
379
|
+
diagnostics << diagnostic(
|
|
380
|
+
"range",
|
|
381
|
+
"#{path}.confidence",
|
|
382
|
+
"artifact confidence must be a finite number from 0 through 1"
|
|
383
|
+
)
|
|
384
|
+
end
|
|
385
|
+
validate_optional_string(entry, "canonical_ref", "#{path}.canonical_ref", diagnostics)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def validate_final_contract(report, diagnostics)
|
|
390
|
+
unless report["status"] == "pass"
|
|
391
|
+
diagnostics << diagnostic("final_status", "$.status", "final report status must be pass")
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
outcome = report["outcome"]
|
|
395
|
+
unless outcome.is_a?(Hash)
|
|
396
|
+
diagnostics << diagnostic("required", "$.outcome", "final report requires an outcome object")
|
|
397
|
+
else
|
|
398
|
+
unless ACCEPTED_OUTCOME_STATUSES.include?(outcome["status"])
|
|
399
|
+
diagnostics << diagnostic(
|
|
400
|
+
"outcome_not_accepted",
|
|
401
|
+
"$.outcome.status",
|
|
402
|
+
"final outcome must be accepted or no_change"
|
|
403
|
+
)
|
|
404
|
+
end
|
|
405
|
+
unless non_empty_string?(outcome["summary"])
|
|
406
|
+
diagnostics << diagnostic(
|
|
407
|
+
"required",
|
|
408
|
+
"$.outcome.summary",
|
|
409
|
+
"final outcome requires a non-empty summary"
|
|
410
|
+
)
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
validation = report["validation"]
|
|
415
|
+
unless validation.is_a?(Hash)
|
|
416
|
+
diagnostics << diagnostic("required", "$.validation", "final report requires a validation object")
|
|
417
|
+
return
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
expected_statuses = outcome.is_a?(Hash) && outcome["status"] == "no_change" ? %w[pass not_run] : %w[pass]
|
|
421
|
+
unless expected_statuses.include?(validation["status"])
|
|
422
|
+
diagnostics << diagnostic(
|
|
423
|
+
"final_validation_status",
|
|
424
|
+
"$.validation.status",
|
|
425
|
+
"final validation.status must be #{expected_statuses.join(' or ')}"
|
|
426
|
+
)
|
|
427
|
+
end
|
|
428
|
+
unless validation["commands"].is_a?(Array)
|
|
429
|
+
diagnostics << diagnostic(
|
|
430
|
+
"required",
|
|
431
|
+
"$.validation.commands",
|
|
432
|
+
"final report requires a validation.commands array"
|
|
433
|
+
)
|
|
434
|
+
end
|
|
435
|
+
unless validation["final_reported"] == true
|
|
436
|
+
diagnostics << diagnostic(
|
|
437
|
+
"final_reported",
|
|
438
|
+
"$.validation.final_reported",
|
|
439
|
+
"final report requires validation.final_reported=true"
|
|
440
|
+
)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
Array(validation["commands"]).first(MAX_COMMANDS).each_with_index do |entry, index|
|
|
444
|
+
next unless entry.is_a?(Hash) && entry["exit_code"].is_a?(Integer)
|
|
445
|
+
next if entry["exit_code"].zero?
|
|
446
|
+
|
|
447
|
+
diagnostics << diagnostic(
|
|
448
|
+
"command_failed",
|
|
449
|
+
"$.validation.commands[#{index}].exit_code",
|
|
450
|
+
"accepted final reports require validation command exit_code=0"
|
|
451
|
+
)
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def validate_string_array_field(report, key, max_items:, diagnostics:)
|
|
456
|
+
return unless report.key?(key)
|
|
457
|
+
|
|
458
|
+
validate_string_array_value(
|
|
459
|
+
report[key],
|
|
460
|
+
"$.#{key}",
|
|
461
|
+
max_items: max_items,
|
|
462
|
+
diagnostics: diagnostics,
|
|
463
|
+
required: true
|
|
464
|
+
)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def validate_string_array_value(value, path, max_items:, diagnostics:, required:)
|
|
468
|
+
if value.nil? && !required
|
|
469
|
+
return
|
|
470
|
+
end
|
|
471
|
+
unless value.is_a?(Array)
|
|
472
|
+
diagnostics << diagnostic("array_required", path, "#{path.delete_prefix('$.')} must be an array")
|
|
473
|
+
return
|
|
474
|
+
end
|
|
475
|
+
if value.length > max_items
|
|
476
|
+
diagnostics << diagnostic("too_many_items", path, "#{path.delete_prefix('$.')} may contain at most #{max_items} entries")
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
value.first(max_items).each_with_index do |item, index|
|
|
480
|
+
next if non_empty_string?(item)
|
|
481
|
+
|
|
482
|
+
diagnostics << diagnostic(
|
|
483
|
+
"string_required",
|
|
484
|
+
"#{path}[#{index}]",
|
|
485
|
+
"#{path.delete_prefix('$.')} entries must be non-empty strings"
|
|
486
|
+
)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def validate_required_string(hash, key, path, diagnostics)
|
|
491
|
+
return if non_empty_string?(hash[key])
|
|
492
|
+
|
|
493
|
+
diagnostics << diagnostic("string_required", path, "#{path.delete_prefix('$.')} must be a non-empty string")
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def validate_optional_string(hash, key, path, diagnostics)
|
|
497
|
+
return unless hash.key?(key)
|
|
498
|
+
return if hash[key].is_a?(String) && hash[key].length <= MAX_STRING_LENGTH
|
|
499
|
+
|
|
500
|
+
diagnostics << diagnostic(
|
|
501
|
+
"string_required",
|
|
502
|
+
path,
|
|
503
|
+
"#{path.delete_prefix('$.')} must be a string no longer than #{MAX_STRING_LENGTH} characters"
|
|
71
504
|
)
|
|
72
505
|
end
|
|
73
506
|
|
|
74
507
|
def build_payload(path, bytes:, sha256:, report:)
|
|
75
508
|
artifacts = compact_artifacts(report["artifacts"])
|
|
76
509
|
validation = compact_validation(report["validation"])
|
|
510
|
+
outcome = compact_outcome(report["outcome"])
|
|
77
511
|
payload = {
|
|
78
512
|
"artifact_report" => metadata(
|
|
79
513
|
path,
|
|
@@ -89,6 +523,7 @@ module Harnex
|
|
|
89
523
|
}
|
|
90
524
|
payload["validation"] = validation if validation
|
|
91
525
|
payload["artifacts"] = artifacts unless artifacts.empty?
|
|
526
|
+
payload["outcome"] = outcome if outcome
|
|
92
527
|
payload
|
|
93
528
|
end
|
|
94
529
|
|
|
@@ -98,13 +533,15 @@ module Harnex
|
|
|
98
533
|
bytes: nil,
|
|
99
534
|
sha256: nil,
|
|
100
535
|
ingest_status: "missing",
|
|
101
|
-
warning: "artifact report not found"
|
|
536
|
+
warning: "artifact report not found",
|
|
537
|
+
diagnostics: [diagnostic("report_missing", "$", "artifact report file does not exist")]
|
|
102
538
|
)
|
|
103
539
|
end
|
|
104
540
|
|
|
105
|
-
def warning(path, bytes:, sha256:, ingest_status:, warning:, schema: nil)
|
|
541
|
+
def warning(path, bytes:, sha256:, ingest_status:, warning:, schema: nil, diagnostics: nil)
|
|
106
542
|
report = metadata(path, bytes: bytes, sha256: sha256, ingest_status: ingest_status, schema: schema)
|
|
107
543
|
report["warning"] = bounded_string(warning)
|
|
544
|
+
report["diagnostics"] = Array(diagnostics).first(MAX_DIAGNOSTICS) unless Array(diagnostics).empty?
|
|
108
545
|
{ "artifact_report" => report }
|
|
109
546
|
end
|
|
110
547
|
|
|
@@ -125,7 +562,7 @@ module Harnex
|
|
|
125
562
|
payload["status"] = bounded_string_or_nil(value["status"])
|
|
126
563
|
payload["commands"] = compact_commands(value["commands"])
|
|
127
564
|
payload["final_reported"] = !!value["final_reported"] if value.key?("final_reported")
|
|
128
|
-
payload.delete_if { |_key,
|
|
565
|
+
payload.delete_if { |_key, item| item.nil? || item == [] }
|
|
129
566
|
payload.empty? ? nil : payload
|
|
130
567
|
end
|
|
131
568
|
|
|
@@ -139,11 +576,26 @@ module Harnex
|
|
|
139
576
|
"status" => bounded_string_or_nil(entry["status"]),
|
|
140
577
|
"duration_s" => finite_float_or_nil(entry["duration_s"])
|
|
141
578
|
}
|
|
142
|
-
compact.delete_if { |_key,
|
|
579
|
+
compact.delete_if { |_key, item| item.nil? }
|
|
143
580
|
compact.empty? ? nil : compact
|
|
144
581
|
end
|
|
145
582
|
end
|
|
146
583
|
|
|
584
|
+
def compact_outcome(value)
|
|
585
|
+
return nil unless value.is_a?(Hash)
|
|
586
|
+
|
|
587
|
+
status = value["status"].to_s
|
|
588
|
+
return nil unless OUTCOME_STATUSES.include?(status)
|
|
589
|
+
|
|
590
|
+
payload = {
|
|
591
|
+
"status" => status,
|
|
592
|
+
"summary" => bounded_string_or_nil(value["summary"]),
|
|
593
|
+
"commit_sha" => bounded_string_or_nil(value["commit_sha"])
|
|
594
|
+
}
|
|
595
|
+
payload.delete_if { |_key, item| item.nil? }
|
|
596
|
+
payload
|
|
597
|
+
end
|
|
598
|
+
|
|
147
599
|
def compact_artifacts(value)
|
|
148
600
|
Array(value).first(MAX_ARTIFACTS).filter_map do |entry|
|
|
149
601
|
next unless entry.is_a?(Hash)
|
|
@@ -155,11 +607,32 @@ module Harnex
|
|
|
155
607
|
"confidence" => finite_float_or_nil(entry["confidence"]),
|
|
156
608
|
"canonical_ref" => bounded_string_or_nil(entry["canonical_ref"])
|
|
157
609
|
}
|
|
158
|
-
compact.delete_if { |_key,
|
|
610
|
+
compact.delete_if { |_key, item| item.nil? || item == [] }
|
|
159
611
|
compact.empty? ? nil : compact
|
|
160
612
|
end
|
|
161
613
|
end
|
|
162
614
|
|
|
615
|
+
def validation_failure(path, status:, diagnostics:, bytes: nil, sha256: nil)
|
|
616
|
+
ValidationResult.new(
|
|
617
|
+
ok: false,
|
|
618
|
+
status: status,
|
|
619
|
+
path: path,
|
|
620
|
+
bytes: bytes,
|
|
621
|
+
sha256: sha256,
|
|
622
|
+
schema: nil,
|
|
623
|
+
diagnostics: diagnostics.first(MAX_DIAGNOSTICS),
|
|
624
|
+
report: nil
|
|
625
|
+
)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def diagnostic(code, path, message)
|
|
629
|
+
{
|
|
630
|
+
"code" => code,
|
|
631
|
+
"path" => path,
|
|
632
|
+
"message" => bounded_string(message)
|
|
633
|
+
}
|
|
634
|
+
end
|
|
635
|
+
|
|
163
636
|
def string_array(value, max_items:)
|
|
164
637
|
Array(value).first(max_items).filter_map do |item|
|
|
165
638
|
text = bounded_string_or_nil(item)
|
|
@@ -197,6 +670,22 @@ module Harnex
|
|
|
197
670
|
nil
|
|
198
671
|
end
|
|
199
672
|
|
|
673
|
+
def finite_number?(value)
|
|
674
|
+
value.is_a?(Numeric) && value.finite?
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def finite_non_negative_number?(value)
|
|
678
|
+
finite_number?(value) && value >= 0
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def non_empty_string?(value)
|
|
682
|
+
value.is_a?(String) && !value.empty? && value.length <= MAX_STRING_LENGTH
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def boolean?(value)
|
|
686
|
+
value == true || value == false
|
|
687
|
+
end
|
|
688
|
+
|
|
200
689
|
def file_sha256(path)
|
|
201
690
|
digest = Digest::SHA256.new
|
|
202
691
|
File.open(path, "rb") do |file|
|