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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +188 -0
- data/SECURITY.md +56 -0
- data/docs/api-support-matrix.md +50 -0
- data/docs/architecture.md +89 -0
- data/docs/authentication.md +86 -0
- data/docs/commands.md +112 -0
- data/docs/configuration-schema-v1.json +129 -0
- data/docs/configuration.md +167 -0
- data/docs/google-client-compatibility.md +59 -0
- data/docs/plan-format.md +89 -0
- data/docs/plan-schema-v1.json +224 -0
- data/docs/rails.md +73 -0
- data/docs/reports.md +124 -0
- data/docs/safety.md +93 -0
- data/docs/troubleshooting.md +103 -0
- data/exe/analytics-ops +6 -0
- data/lib/analytics_ops/applier.rb +52 -0
- data/lib/analytics_ops/canonical.rb +65 -0
- data/lib/analytics_ops/cli.rb +427 -0
- data/lib/analytics_ops/clients/admin.rb +446 -0
- data/lib/analytics_ops/clients/data.rb +314 -0
- data/lib/analytics_ops/configuration/document.rb +25 -0
- data/lib/analytics_ops/configuration/loader.rb +76 -0
- data/lib/analytics_ops/configuration/schema.rb +107 -0
- data/lib/analytics_ops/configuration/validator.rb +344 -0
- data/lib/analytics_ops/configuration.rb +19 -0
- data/lib/analytics_ops/desired_state.rb +40 -0
- data/lib/analytics_ops/errors.rb +31 -0
- data/lib/analytics_ops/plan.rb +551 -0
- data/lib/analytics_ops/planner.rb +233 -0
- data/lib/analytics_ops/rails/railtie.rb +65 -0
- data/lib/analytics_ops/rails.rb +5 -0
- data/lib/analytics_ops/redaction.rb +46 -0
- data/lib/analytics_ops/reports/catalog.rb +100 -0
- data/lib/analytics_ops/reports/definition.rb +226 -0
- data/lib/analytics_ops/reports/result.rb +90 -0
- data/lib/analytics_ops/reports.rb +13 -0
- data/lib/analytics_ops/resources.rb +79 -0
- data/lib/analytics_ops/snapshot.rb +45 -0
- data/lib/analytics_ops/version.rb +5 -0
- data/lib/analytics_ops/workspace.rb +212 -0
- data/lib/analytics_ops.rb +21 -0
- data/lib/generators/analytics_ops/install_generator.rb +23 -0
- data/lib/generators/analytics_ops/templates/analytics-ops +9 -0
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +23 -0
- data/sig/analytics_ops.rbs +395 -0
- 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
|