analytics_ops 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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/CONTRIBUTING.md +67 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +188 -0
  7. data/SECURITY.md +56 -0
  8. data/docs/api-support-matrix.md +50 -0
  9. data/docs/architecture.md +89 -0
  10. data/docs/authentication.md +86 -0
  11. data/docs/commands.md +112 -0
  12. data/docs/configuration-schema-v1.json +129 -0
  13. data/docs/configuration.md +167 -0
  14. data/docs/google-client-compatibility.md +59 -0
  15. data/docs/plan-format.md +89 -0
  16. data/docs/plan-schema-v1.json +224 -0
  17. data/docs/rails.md +73 -0
  18. data/docs/reports.md +124 -0
  19. data/docs/safety.md +93 -0
  20. data/docs/troubleshooting.md +103 -0
  21. data/exe/analytics-ops +6 -0
  22. data/lib/analytics_ops/applier.rb +52 -0
  23. data/lib/analytics_ops/canonical.rb +65 -0
  24. data/lib/analytics_ops/cli.rb +427 -0
  25. data/lib/analytics_ops/clients/admin.rb +446 -0
  26. data/lib/analytics_ops/clients/data.rb +314 -0
  27. data/lib/analytics_ops/configuration/document.rb +25 -0
  28. data/lib/analytics_ops/configuration/loader.rb +76 -0
  29. data/lib/analytics_ops/configuration/schema.rb +107 -0
  30. data/lib/analytics_ops/configuration/validator.rb +344 -0
  31. data/lib/analytics_ops/configuration.rb +19 -0
  32. data/lib/analytics_ops/desired_state.rb +40 -0
  33. data/lib/analytics_ops/errors.rb +31 -0
  34. data/lib/analytics_ops/plan.rb +551 -0
  35. data/lib/analytics_ops/planner.rb +233 -0
  36. data/lib/analytics_ops/rails/railtie.rb +65 -0
  37. data/lib/analytics_ops/rails.rb +5 -0
  38. data/lib/analytics_ops/redaction.rb +46 -0
  39. data/lib/analytics_ops/reports/catalog.rb +100 -0
  40. data/lib/analytics_ops/reports/definition.rb +226 -0
  41. data/lib/analytics_ops/reports/result.rb +90 -0
  42. data/lib/analytics_ops/reports.rb +13 -0
  43. data/lib/analytics_ops/resources.rb +79 -0
  44. data/lib/analytics_ops/snapshot.rb +45 -0
  45. data/lib/analytics_ops/version.rb +5 -0
  46. data/lib/analytics_ops/workspace.rb +212 -0
  47. data/lib/analytics_ops.rb +21 -0
  48. data/lib/generators/analytics_ops/install_generator.rb +23 -0
  49. data/lib/generators/analytics_ops/templates/analytics-ops +9 -0
  50. data/lib/generators/analytics_ops/templates/analytics_ops.yml +23 -0
  51. data/sig/analytics_ops.rbs +395 -0
  52. metadata +131 -0
@@ -0,0 +1,551 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "uri"
6
+
7
+ module AnalyticsOps
8
+ # Versioned, deterministic, credential-free input to the apply workflow.
9
+ class Plan
10
+ FORMAT_VERSION = 1
11
+ MAX_BYTES = 1_048_576
12
+ MAX_CHANGES = 10_000
13
+ MAX_FINDINGS = 10_000
14
+ OPERATIONS = %w[create update].freeze
15
+ RESOURCE_TYPES = %w[data_stream retention key_event custom_dimension custom_metric].freeze
16
+ API_MATURITIES = %w[stable beta experimental].freeze
17
+ FINDING_SEVERITIES = %w[drift manual experimental warning].freeze
18
+ PROFILE = /\A[a-z][a-z0-9_]{0,63}\z/i
19
+ ID = /\A\d{1,50}\z/
20
+ NAME = /\A[a-z][a-z0-9_]{0,63}\z/i
21
+ EVENT_NAME = /\A[a-z][a-z0-9_]{0,39}\z/i
22
+ PARAMETER_NAME = /\A[a-z][a-z0-9_]{0,39}\z/i
23
+ FINGERPRINT = /\Asha256:[a-f0-9]{64}\z/
24
+ CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/
25
+ SECRET_KEY = Configuration::Validator::SECRET_KEY
26
+ RETENTION_VALUES = Configuration::Validator::RETENTION_VALUES
27
+ DIMENSION_SCOPES = Configuration::Validator::DIMENSION_SCOPES
28
+ METRIC_UNITS = Configuration::Validator::METRIC_UNITS
29
+
30
+ RESOURCE_FIELDS = {
31
+ "data_stream" => %w[id name display_name type default_uri measurement_id],
32
+ "retention" => %w[name event_data user_data reset_on_new_activity],
33
+ "key_event" => %w[event_name counting_method],
34
+ "custom_dimension" => %w[parameter_name display_name description scope disallow_ads_personalization],
35
+ "custom_metric" => %w[parameter_name display_name description scope measurement_unit]
36
+ }.freeze
37
+ NAMED_DEFINITION_FIELDS = {
38
+ "custom_dimension" => ["name", *RESOURCE_FIELDS.fetch("custom_dimension")],
39
+ "custom_metric" => ["name", *RESOURCE_FIELDS.fetch("custom_metric")]
40
+ }.freeze
41
+ MUTABLE_FIELDS = {
42
+ "data_stream" => %w[default_uri],
43
+ "retention" => %w[event_data user_data reset_on_new_activity],
44
+ "custom_dimension" => %w[display_name description disallow_ads_personalization],
45
+ "custom_metric" => %w[display_name description]
46
+ }.freeze
47
+
48
+ # Detects duplicate JSON object keys before ordinary JSON parsing can discard them.
49
+ class DuplicateKeyDetector
50
+ NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/
51
+
52
+ def initialize(source)
53
+ @source = source
54
+ @index = 0
55
+ end
56
+
57
+ def call
58
+ value
59
+ whitespace
60
+ raise InvalidPlanError, "Unexpected data after plan JSON" unless @index == @source.bytesize
61
+ end
62
+
63
+ private
64
+
65
+ def value
66
+ whitespace
67
+ case byte
68
+ when 123 then object
69
+ when 91 then array
70
+ when 34 then string
71
+ when 116 then literal("true")
72
+ when 102 then literal("false")
73
+ when 110 then literal("null")
74
+ else number
75
+ end
76
+ end
77
+
78
+ def object
79
+ consume(123)
80
+ whitespace
81
+ return consume(125) if byte == 125
82
+
83
+ keys = {}
84
+ loop do
85
+ whitespace
86
+ key = string
87
+ raise InvalidPlanError, "Duplicate JSON object field #{key.inspect}" if keys.key?(key)
88
+
89
+ keys[key] = true
90
+ whitespace
91
+ consume(58)
92
+ value
93
+ whitespace
94
+ break consume(125) if byte == 125
95
+
96
+ consume(44)
97
+ end
98
+ end
99
+
100
+ def array
101
+ consume(91)
102
+ whitespace
103
+ return consume(93) if byte == 93
104
+
105
+ loop do
106
+ value
107
+ whitespace
108
+ break consume(93) if byte == 93
109
+
110
+ consume(44)
111
+ end
112
+ end
113
+
114
+ def string
115
+ start = @index
116
+ consume(34)
117
+ loop do
118
+ current = byte
119
+ raise InvalidPlanError, "Unterminated JSON string" unless current
120
+
121
+ if current == 92
122
+ @index += 2
123
+ elsif current == 34
124
+ @index += 1
125
+ return JSON.parse(@source.byteslice(start...@index))
126
+ else
127
+ @index += 1
128
+ end
129
+ end
130
+ end
131
+
132
+ def literal(expected)
133
+ actual = @source.byteslice(@index, expected.bytesize)
134
+ raise InvalidPlanError, "Invalid plan JSON literal" unless actual == expected
135
+
136
+ @index += expected.bytesize
137
+ end
138
+
139
+ def number
140
+ match = NUMBER.match(@source, @index)
141
+ raise InvalidPlanError, "Invalid plan JSON value" unless match && match.begin(0) == @index
142
+
143
+ @index = match.end(0)
144
+ end
145
+
146
+ def whitespace
147
+ @index += 1 while [9, 10, 13, 32].include?(byte)
148
+ end
149
+
150
+ def consume(expected)
151
+ raise InvalidPlanError, "Invalid plan JSON structure" unless byte == expected
152
+
153
+ @index += 1
154
+ end
155
+
156
+ def byte
157
+ @source.getbyte(@index)
158
+ end
159
+ end
160
+
161
+ # One approved create or update operation.
162
+ class Change < Resources::Value
163
+ fields :resource_type, :resource_identity, :operation, :api_maturity,
164
+ :before, :after, :reversible, :rollback
165
+
166
+ def self.from_h(raw)
167
+ hash = Plan.string_keyed_hash(raw, "change")
168
+ Plan.exact_keys!(hash, field_names.map(&:to_s), "change")
169
+ new(**field_names.to_h { |name| [name, hash.fetch(name.to_s)] })
170
+ rescue KeyError => error
171
+ raise InvalidPlanError, "Missing plan change field #{error.key}"
172
+ end
173
+ end
174
+
175
+ # Read-only drift, manual, or experimental information attached to a plan.
176
+ class Finding < Resources::Value
177
+ fields :severity, :code, :resource_identity, :message
178
+
179
+ def self.from_h(raw)
180
+ hash = Plan.string_keyed_hash(raw, "finding")
181
+ Plan.exact_keys!(hash, field_names.map(&:to_s), "finding")
182
+ new(**field_names.to_h { |name| [name, hash.fetch(name.to_s)] })
183
+ rescue KeyError => error
184
+ raise InvalidPlanError, "Missing plan finding field #{error.key}"
185
+ end
186
+ end
187
+
188
+ attr_reader :format_version, :profile, :property_id, :snapshot_fingerprint, :changes, :findings
189
+
190
+ def initialize(profile:, property_id:, snapshot_fingerprint:, changes:, findings:, format_version: FORMAT_VERSION)
191
+ @format_version = self.class.integer(format_version, "format_version", expected: FORMAT_VERSION)
192
+ @profile = self.class.pattern_string(profile, "profile", PROFILE)
193
+ @property_id = self.class.pattern_string(property_id, "property_id", ID)
194
+ @snapshot_fingerprint = self.class.pattern_string(snapshot_fingerprint, "snapshot_fingerprint", FINGERPRINT)
195
+ @changes = validated_values(changes, Change, "changes", MAX_CHANGES)
196
+ @findings = validated_values(findings, Finding, "findings", MAX_FINDINGS)
197
+ validate_changes!
198
+ validate_findings!
199
+ @changes = Canonical.deep_freeze(@changes.sort_by do |change|
200
+ [change.resource_type, change.resource_identity, change.operation]
201
+ end)
202
+ @findings = Canonical.deep_freeze(@findings.sort_by do |finding|
203
+ [finding.severity, finding.code, finding.resource_identity]
204
+ end)
205
+ freeze
206
+ end
207
+
208
+ def empty?
209
+ changes.empty?
210
+ end
211
+
212
+ def drift?
213
+ !empty? || findings.any? { |finding| finding.severity == "drift" }
214
+ end
215
+
216
+ def to_h
217
+ {
218
+ "format_version" => format_version,
219
+ "profile" => profile,
220
+ "property_id" => property_id,
221
+ "snapshot_fingerprint" => snapshot_fingerprint,
222
+ "changes" => changes.map(&:to_h),
223
+ "findings" => findings.map(&:to_h)
224
+ }
225
+ end
226
+
227
+ def to_json(*_arguments)
228
+ "#{JSON.pretty_generate(Canonical.normalize(to_h))}\n"
229
+ end
230
+
231
+ def write(path)
232
+ expanded = File.expand_path(path)
233
+ temporary = File.join(File.dirname(expanded),
234
+ ".#{File.basename(expanded)}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
235
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
236
+ file.write(to_json)
237
+ file.flush
238
+ file.fsync
239
+ end
240
+ File.rename(temporary, expanded)
241
+ File.chmod(0o600, expanded)
242
+ expanded
243
+ rescue SystemCallError => error
244
+ raise InvalidPlanError, "Cannot write plan #{Redaction.message(path)}: #{Redaction.message(error.message)}"
245
+ ensure
246
+ File.unlink(temporary) if temporary && File.exist?(temporary)
247
+ end
248
+
249
+ def self.load(path)
250
+ contents = File.binread(path, MAX_BYTES + 1)
251
+ raise InvalidPlanError, "Plan exceeds #{MAX_BYTES} bytes" if contents.bytesize > MAX_BYTES
252
+
253
+ parsed = JSON.parse(contents, max_nesting: 100)
254
+ DuplicateKeyDetector.new(contents).call
255
+ from_h(parsed)
256
+ rescue JSON::ParserError => error
257
+ raise InvalidPlanError, "Invalid plan JSON: #{Redaction.message(error.message)}"
258
+ rescue SystemCallError => error
259
+ raise InvalidPlanError, "Cannot read plan #{Redaction.message(path)}: #{Redaction.message(error.message)}"
260
+ end
261
+
262
+ def self.from_h(raw)
263
+ hash = string_keyed_hash(raw, "plan")
264
+ exact_keys!(hash, %w[format_version profile property_id snapshot_fingerprint changes findings], "plan")
265
+ changes = array(hash.fetch("changes"), "plan.changes").map { |change| Change.from_h(change) }
266
+ findings = array(hash.fetch("findings"), "plan.findings").map { |finding| Finding.from_h(finding) }
267
+
268
+ new(
269
+ format_version: hash.fetch("format_version"),
270
+ profile: hash.fetch("profile"),
271
+ property_id: hash.fetch("property_id"),
272
+ snapshot_fingerprint: hash.fetch("snapshot_fingerprint"),
273
+ changes:,
274
+ findings:
275
+ )
276
+ rescue KeyError => error
277
+ raise InvalidPlanError, "Missing plan field #{error.key}"
278
+ end
279
+
280
+ def self.string_keyed_hash(value, path)
281
+ raise InvalidPlanError, "#{path} must be an object" unless value.is_a?(Hash)
282
+ raise InvalidPlanError, "#{path} keys must be strings" unless value.keys.all?(String)
283
+
284
+ value
285
+ end
286
+
287
+ def self.array(value, path)
288
+ raise InvalidPlanError, "#{path} must be an array" unless value.is_a?(Array)
289
+
290
+ value
291
+ end
292
+
293
+ def self.exact_keys!(hash, allowed, path)
294
+ unknown = hash.keys - allowed
295
+ missing = allowed - hash.keys
296
+ raise InvalidPlanError, "Unknown #{path} field #{unknown.first}" unless unknown.empty?
297
+ raise InvalidPlanError, "Missing #{path} field #{missing.first}" unless missing.empty?
298
+ end
299
+
300
+ def self.pattern_string(value, path, pattern, maximum: 500)
301
+ unless value.is_a?(String) && value.length.between?(1, maximum) && pattern.match?(value) &&
302
+ !CONTROL_CHARACTERS.match?(value)
303
+ raise InvalidPlanError, "Invalid #{path}"
304
+ end
305
+
306
+ value.dup.freeze
307
+ end
308
+
309
+ def self.printable_string(value, path, minimum: 1, maximum: 500)
310
+ unless value.is_a?(String) && value.length.between?(minimum, maximum) && !CONTROL_CHARACTERS.match?(value)
311
+ raise InvalidPlanError, "Invalid #{path}"
312
+ end
313
+
314
+ value
315
+ end
316
+
317
+ def self.integer(value, path, expected: nil)
318
+ raise InvalidPlanError, "Invalid #{path}" unless value.is_a?(Integer)
319
+ raise InvalidPlanError, "Unsupported #{path} #{value.inspect}" if expected && value != expected
320
+
321
+ value
322
+ end
323
+
324
+ private
325
+
326
+ def validated_values(values, type, path, maximum)
327
+ self.class.array(values, path)
328
+ raise InvalidPlanError, "#{path} exceeds #{maximum} entries" if values.length > maximum
329
+ raise InvalidPlanError, "#{path} must contain #{type.name} values" unless values.all?(type)
330
+
331
+ values.dup
332
+ end
333
+
334
+ def validate_changes!
335
+ changes.each_with_index { |change, index| validate_change!(change, "changes[#{index}]") }
336
+ identities = changes.map { |change| [change.resource_type, change.resource_identity] }
337
+ return if identities.uniq.length == identities.length
338
+
339
+ raise InvalidPlanError,
340
+ "Plan contains duplicate resource changes"
341
+ end
342
+
343
+ def validate_change!(change, path)
344
+ enum!(change.resource_type, RESOURCE_TYPES, "#{path}.resource_type")
345
+ enum!(change.operation, OPERATIONS, "#{path}.operation")
346
+ enum!(change.api_maturity, API_MATURITIES, "#{path}.api_maturity")
347
+ boolean!(change.reversible, "#{path}.reversible")
348
+ self.class.printable_string(change.rollback, "#{path}.rollback", maximum: 500)
349
+ validate_operation!(change, path)
350
+ validate_payload!(change, path)
351
+ end
352
+
353
+ def validate_operation!(change, path)
354
+ allowed = case change.resource_type
355
+ when "key_event"
356
+ ["create"]
357
+ when "data_stream", "retention"
358
+ ["update"]
359
+ else
360
+ OPERATIONS
361
+ end
362
+ return if allowed.include?(change.operation)
363
+
364
+ raise InvalidPlanError, "Unsupported #{change.operation} operation for #{change.resource_type} at #{path}"
365
+ end
366
+
367
+ def validate_payload!(change, path)
368
+ if change.operation == "create"
369
+ raise InvalidPlanError, "#{path}.before must be null for create" unless change.before.nil?
370
+
371
+ validate_create!(change, path)
372
+ else
373
+ before = payload_hash(change.before, "#{path}.before")
374
+ after = payload_hash(change.after, "#{path}.after")
375
+ validate_update!(change, before, after, path)
376
+ end
377
+ end
378
+
379
+ def validate_create!(change, path)
380
+ after = payload_hash(change.after, "#{path}.after")
381
+ fields = RESOURCE_FIELDS.fetch(change.resource_type)
382
+ self.class.exact_keys!(after, fields, "#{path}.after")
383
+ validate_resource_values!(change.resource_type, after, "#{path}.after", named: false)
384
+ validate_identity!(change, after, path)
385
+ end
386
+
387
+ def validate_update!(change, before, after, path)
388
+ fields = NAMED_DEFINITION_FIELDS.fetch(change.resource_type, RESOURCE_FIELDS.fetch(change.resource_type))
389
+ self.class.exact_keys!(before, fields, "#{path}.before")
390
+ self.class.exact_keys!(after, fields, "#{path}.after")
391
+ validate_resource_values!(change.resource_type, before, "#{path}.before", named: true)
392
+ validate_resource_values!(change.resource_type, after, "#{path}.after", named: true)
393
+ validate_identity!(change, after, path)
394
+ validate_immutable_fields!(change.resource_type, before, after, path)
395
+ validate_property_resource_name!(change.resource_type, before, path)
396
+ validate_property_resource_name!(change.resource_type, after, path)
397
+ end
398
+
399
+ def payload_hash(value, path)
400
+ hash = self.class.string_keyed_hash(value, path)
401
+ reject_secret_keys!(hash, path)
402
+ hash
403
+ end
404
+
405
+ def validate_resource_values!(resource_type, payload, path, named:)
406
+ case resource_type
407
+ when "data_stream"
408
+ id_string!(payload.fetch("id"), "#{path}.id")
409
+ resource_name!(payload.fetch("name"), "dataStreams", "#{path}.name")
410
+ text!(payload.fetch("display_name"), "#{path}.display_name", minimum: 0, maximum: 100)
411
+ enum!(payload.fetch("type"), %w[web android ios unspecified], "#{path}.type")
412
+ optional_uri!(payload.fetch("default_uri"), "#{path}.default_uri")
413
+ optional_text!(payload.fetch("measurement_id"), "#{path}.measurement_id", maximum: 64)
414
+ when "retention"
415
+ resource_name!(payload.fetch("name"), "dataRetentionSettings", "#{path}.name", singleton: true)
416
+ enum!(payload.fetch("event_data"), RETENTION_VALUES, "#{path}.event_data")
417
+ enum!(payload.fetch("user_data"), RETENTION_VALUES, "#{path}.user_data")
418
+ boolean!(payload.fetch("reset_on_new_activity"), "#{path}.reset_on_new_activity")
419
+ when "key_event"
420
+ event_name!(payload.fetch("event_name"), "#{path}.event_name")
421
+ enum!(payload.fetch("counting_method"), %w[once_per_event once_per_session], "#{path}.counting_method")
422
+ when "custom_dimension"
423
+ resource_name!(payload.fetch("name"), "customDimensions", "#{path}.name") if named
424
+ parameter_name!(payload.fetch("parameter_name"), "#{path}.parameter_name")
425
+ text!(payload.fetch("display_name"), "#{path}.display_name", minimum: 1, maximum: 82)
426
+ text!(payload.fetch("description"), "#{path}.description", minimum: 0, maximum: 150)
427
+ enum!(payload.fetch("scope"), DIMENSION_SCOPES, "#{path}.scope")
428
+ boolean!(payload.fetch("disallow_ads_personalization"), "#{path}.disallow_ads_personalization")
429
+ when "custom_metric"
430
+ resource_name!(payload.fetch("name"), "customMetrics", "#{path}.name") if named
431
+ parameter_name!(payload.fetch("parameter_name"), "#{path}.parameter_name")
432
+ text!(payload.fetch("display_name"), "#{path}.display_name", minimum: 1, maximum: 82)
433
+ text!(payload.fetch("description"), "#{path}.description", minimum: 0, maximum: 150)
434
+ enum!(payload.fetch("scope"), ["event"], "#{path}.scope")
435
+ enum!(payload.fetch("measurement_unit"), METRIC_UNITS, "#{path}.measurement_unit")
436
+ end
437
+ end
438
+
439
+ def validate_identity!(change, payload, path)
440
+ expected = case change.resource_type
441
+ when "data_stream"
442
+ "stream:#{payload.fetch("id")}"
443
+ when "retention"
444
+ "property:#{property_id}:retention"
445
+ when "key_event"
446
+ "event:#{payload.fetch("event_name")}"
447
+ when "custom_dimension"
448
+ "#{payload.fetch("scope")}:#{payload.fetch("parameter_name")}"
449
+ when "custom_metric"
450
+ payload.fetch("parameter_name")
451
+ end
452
+ return if change.resource_identity == expected
453
+
454
+ raise InvalidPlanError, "#{path}.resource_identity does not match its payload"
455
+ end
456
+
457
+ def validate_immutable_fields!(resource_type, before, after, path)
458
+ mutable = MUTABLE_FIELDS.fetch(resource_type)
459
+ changed = before.keys.reject { |key| before[key] == after[key] }
460
+ forbidden = changed - mutable
461
+ raise InvalidPlanError, "#{path} changes immutable field #{forbidden.first}" unless forbidden.empty?
462
+ raise InvalidPlanError, "#{path} update does not change any mutable field" if changed.empty?
463
+ end
464
+
465
+ def validate_property_resource_name!(resource_type, payload, path)
466
+ return if resource_type == "key_event"
467
+
468
+ expected = case resource_type
469
+ when "data_stream"
470
+ "properties/#{property_id}/dataStreams/#{payload.fetch("id")}"
471
+ when "retention"
472
+ "properties/#{property_id}/dataRetentionSettings"
473
+ when "custom_dimension", "custom_metric"
474
+ prefix = resource_type == "custom_dimension" ? "customDimensions" : "customMetrics"
475
+ payload.fetch("name").start_with?("properties/#{property_id}/#{prefix}/")
476
+ end
477
+ valid = expected == true || payload.fetch("name") == expected
478
+ raise InvalidPlanError, "#{path} resource belongs to a different property" unless valid
479
+ end
480
+
481
+ def validate_findings!
482
+ findings.each_with_index do |finding, index|
483
+ path = "findings[#{index}]"
484
+ enum!(finding.severity, FINDING_SEVERITIES, "#{path}.severity")
485
+ self.class.pattern_string(finding.code, "#{path}.code", NAME)
486
+ text!(finding.resource_identity, "#{path}.resource_identity", minimum: 1, maximum: 200)
487
+ text!(finding.message, "#{path}.message", minimum: 1, maximum: 1_000)
488
+ end
489
+ end
490
+
491
+ def reject_secret_keys!(value, path)
492
+ value.each do |key, child|
493
+ raise InvalidPlanError, "Secret-shaped plan field #{path}.#{key} is forbidden" if SECRET_KEY.match?(key)
494
+
495
+ reject_secret_keys!(child, "#{path}.#{key}") if child.is_a?(Hash)
496
+ end
497
+ end
498
+
499
+ def enum!(value, allowed, path)
500
+ return value if value.is_a?(String) && allowed.include?(value)
501
+
502
+ raise InvalidPlanError, "Invalid #{path}"
503
+ end
504
+
505
+ def boolean!(value, path)
506
+ return value if [true, false].include?(value)
507
+
508
+ raise InvalidPlanError, "Invalid #{path}; expected true or false"
509
+ end
510
+
511
+ def id_string!(value, path)
512
+ self.class.pattern_string(value, path, ID)
513
+ end
514
+
515
+ def event_name!(value, path)
516
+ self.class.pattern_string(value, path, EVENT_NAME)
517
+ end
518
+
519
+ def parameter_name!(value, path)
520
+ self.class.pattern_string(value, path, PARAMETER_NAME)
521
+ end
522
+
523
+ def text!(value, path, minimum:, maximum:)
524
+ self.class.printable_string(value, path, minimum:, maximum:)
525
+ end
526
+
527
+ def optional_text!(value, path, maximum:)
528
+ return if value.nil?
529
+
530
+ text!(value, path, minimum: 1, maximum:)
531
+ end
532
+
533
+ def optional_uri!(value, path)
534
+ return if value.nil?
535
+
536
+ text!(value, path, minimum: 1, maximum: 2_048)
537
+ uri = URI.parse(value)
538
+ return if %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo
539
+
540
+ raise InvalidPlanError, "Invalid #{path}"
541
+ rescue URI::InvalidURIError
542
+ raise InvalidPlanError, "Invalid #{path}"
543
+ end
544
+
545
+ def resource_name!(value, collection, path, singleton: false)
546
+ suffix = singleton ? "" : "/[A-Za-z0-9_-]+"
547
+ pattern = %r{\Aproperties/#{ID.source.delete_prefix("\\A").delete_suffix("\\z")}/#{collection}#{suffix}\z}
548
+ self.class.pattern_string(value, path, pattern, maximum: 200)
549
+ end
550
+ end
551
+ end