Class: AnalyticsOps::Plan

Inherits:
Object
  • Object
show all
Defined in:
lib/analytics_ops/plan.rb,
sig/analytics_ops.rbs

Overview

Versioned, deterministic, credential-free input to the apply workflow.

Defined Under Namespace

Classes: Change, DuplicateKeyDetector, Finding

Constant Summary collapse

FORMAT_VERSION =

Returns:

  • (Integer)
1
MAX_BYTES =
1_048_576
MAX_CHANGES =
10_000
MAX_FINDINGS =
10_000
OPERATIONS =
%w[create update].freeze
RESOURCE_TYPES =
%w[data_stream retention key_event custom_dimension custom_metric].freeze
API_MATURITIES =
%w[stable beta experimental].freeze
FINDING_SEVERITIES =
%w[drift manual experimental warning].freeze
PROFILE =
/\A[a-z][a-z0-9_]{0,63}\z/i
ID =
/\A\d{1,50}\z/
NAME =
/\A[a-z][a-z0-9_]{0,63}\z/i
EVENT_NAME =
/\A[a-z][a-z0-9_]{0,39}\z/i
PARAMETER_NAME =
/\A[a-z][a-z0-9_]{0,39}\z/i
FINGERPRINT =
/\Asha256:[a-f0-9]{64}\z/
CONTROL_CHARACTERS =
/[\u0000-\u001f\u007f]/
SECRET_KEY =
Configuration::Validator::SECRET_KEY
RETENTION_VALUES =
Configuration::Validator::RETENTION_VALUES
USER_RETENTION_VALUES =
Configuration::Validator::USER_RETENTION_VALUES
DIMENSION_SCOPES =
Configuration::Validator::DIMENSION_SCOPES
METRIC_UNITS =
Configuration::Validator::METRIC_UNITS
RESTRICTED_METRIC_TYPES =
Configuration::Validator::RESTRICTED_METRIC_TYPES
RESOURCE_FIELDS =
{
  "data_stream" => %w[id name display_name type default_uri measurement_id],
  "retention" => %w[name event_data user_data reset_on_new_activity],
  "key_event" => %w[event_name counting_method],
  "custom_dimension" => %w[parameter_name display_name description scope disallow_ads_personalization],
  "custom_metric" => %w[
    parameter_name display_name description scope measurement_unit restricted_metric_types
  ]
}.freeze
NAMED_DEFINITION_FIELDS =
{
  "custom_dimension" => ["name", *RESOURCE_FIELDS.fetch("custom_dimension")],
  "custom_metric" => ["name", *RESOURCE_FIELDS.fetch("custom_metric")]
}.freeze
MUTABLE_FIELDS =
{
  "data_stream" => %w[default_uri],
  "retention" => %w[event_data user_data reset_on_new_activity],
  "custom_dimension" => %w[display_name description disallow_ads_personalization],
  "custom_metric" => %w[display_name description]
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(profile:, property_id:, snapshot_fingerprint:, changes:, findings:, format_version: FORMAT_VERSION) ⇒ Plan

Returns a new instance of Plan.



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/analytics_ops/plan.rb', line 194

def initialize(profile:, property_id:, snapshot_fingerprint:, changes:, findings:, format_version: FORMAT_VERSION)
  @format_version = self.class.integer(format_version, "format_version", expected: FORMAT_VERSION)
  @profile = self.class.pattern_string(profile, "profile", PROFILE)
  @property_id = self.class.pattern_string(property_id, "property_id", ID)
  @snapshot_fingerprint = self.class.pattern_string(snapshot_fingerprint, "snapshot_fingerprint", FINGERPRINT)
  @changes = validated_values(changes, Change, "changes", MAX_CHANGES)
  @findings = validated_values(findings, Finding, "findings", MAX_FINDINGS)
  validate_changes!
  validate_findings!
  @changes = Canonical.deep_freeze(@changes.sort_by do |change|
    [change.resource_type, change.resource_identity, change.operation]
  end)
  @findings = Canonical.deep_freeze(@findings.sort_by do |finding|
    [finding.severity, finding.code, finding.resource_identity]
  end)
  freeze
end

Instance Attribute Details

#changesArray[Change] (readonly)

Returns the value of attribute changes.

Returns:



192
193
194
# File 'lib/analytics_ops/plan.rb', line 192

def changes
  @changes
end

#findingsArray[Finding] (readonly)

Returns the value of attribute findings.

Returns:



192
193
194
# File 'lib/analytics_ops/plan.rb', line 192

def findings
  @findings
end

#format_versionInteger (readonly)

Returns the value of attribute format_version.

Returns:

  • (Integer)


192
193
194
# File 'lib/analytics_ops/plan.rb', line 192

def format_version
  @format_version
end

#profileString (readonly)

Returns the value of attribute profile.

Returns:

  • (String)


192
193
194
# File 'lib/analytics_ops/plan.rb', line 192

def profile
  @profile
end

#property_idString (readonly)

Returns the value of attribute property_id.

Returns:

  • (String)


192
193
194
# File 'lib/analytics_ops/plan.rb', line 192

def property_id
  @property_id
end

#snapshot_fingerprintString (readonly)

Returns the value of attribute snapshot_fingerprint.

Returns:

  • (String)


192
193
194
# File 'lib/analytics_ops/plan.rb', line 192

def snapshot_fingerprint
  @snapshot_fingerprint
end

Class Method Details

.array(value, path) ⇒ Object

Raises:



291
292
293
294
295
# File 'lib/analytics_ops/plan.rb', line 291

def self.array(value, path)
  raise InvalidPlanError, "#{path} must be an array" unless value.is_a?(Array)

  value
end

.exact_keys!(hash, allowed, path) ⇒ Object

Raises:



297
298
299
300
301
302
# File 'lib/analytics_ops/plan.rb', line 297

def self.exact_keys!(hash, allowed, path)
  unknown = hash.keys - allowed
  missing = allowed - hash.keys
  raise InvalidPlanError, "Unknown #{path} field #{unknown.first}" unless unknown.empty?
  raise InvalidPlanError, "Missing #{path} field #{missing.first}" unless missing.empty?
end

.from_h(raw) ⇒ Plan

Parameters:

  • raw (record)

Returns:



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/analytics_ops/plan.rb', line 266

def self.from_h(raw)
  hash = string_keyed_hash(raw, "plan")
  exact_keys!(hash, %w[format_version profile property_id snapshot_fingerprint changes findings], "plan")
  changes = array(hash.fetch("changes"), "plan.changes").map { |change| Change.from_h(change) }
  findings = array(hash.fetch("findings"), "plan.findings").map { |finding| Finding.from_h(finding) }

  new(
    format_version: hash.fetch("format_version"),
    profile: hash.fetch("profile"),
    property_id: hash.fetch("property_id"),
    snapshot_fingerprint: hash.fetch("snapshot_fingerprint"),
    changes:,
    findings:
  )
rescue KeyError => error
  raise InvalidPlanError, "Missing plan field #{error.key}"
end

.integer(value, path, expected: nil) ⇒ Object

Raises:



322
323
324
325
326
327
# File 'lib/analytics_ops/plan.rb', line 322

def self.integer(value, path, expected: nil)
  raise InvalidPlanError, "Invalid #{path}" unless value.is_a?(Integer)
  raise InvalidPlanError, "Unsupported #{path} #{value.inspect}" if expected && value != expected

  value
end

.load(path) ⇒ Plan

Parameters:

  • path (String)

Returns:



253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/analytics_ops/plan.rb', line 253

def self.load(path)
  contents = File.binread(path, MAX_BYTES + 1)
  raise InvalidPlanError, "Plan exceeds #{MAX_BYTES} bytes" if contents.bytesize > MAX_BYTES

  parsed = JSON.parse(contents, max_nesting: 100)
  DuplicateKeyDetector.new(contents).call
  from_h(parsed)
rescue JSON::ParserError => error
  raise InvalidPlanError, "Invalid plan JSON: #{Redaction.message(error.message)}"
rescue SystemCallError => error
  raise InvalidPlanError, "Cannot read plan #{Redaction.message(path)}: #{Redaction.message(error.message)}"
end

.pattern_string(value, path, pattern, maximum: 500) ⇒ Object



304
305
306
307
308
309
310
311
# File 'lib/analytics_ops/plan.rb', line 304

def self.pattern_string(value, path, pattern, maximum: 500)
  unless value.is_a?(String) && value.length.between?(1, maximum) && pattern.match?(value) &&
         !CONTROL_CHARACTERS.match?(value)
    raise InvalidPlanError, "Invalid #{path}"
  end

  value.dup.freeze
end

.printable_string(value, path, minimum: 1, maximum: 500) ⇒ Object



313
314
315
316
317
318
319
320
# File 'lib/analytics_ops/plan.rb', line 313

def self.printable_string(value, path, minimum: 1, maximum: 500)
  unless value.is_a?(String) && value.length.between?(minimum, maximum) && !CONTROL_CHARACTERS.match?(value) &&
         !Redaction.credential_shaped?(value)
    raise InvalidPlanError, "Invalid #{path}"
  end

  value
end

.string_keyed_hash(value, path) ⇒ Object

Raises:



284
285
286
287
288
289
# File 'lib/analytics_ops/plan.rb', line 284

def self.string_keyed_hash(value, path)
  raise InvalidPlanError, "#{path} must be an object" unless value.is_a?(Hash)
  raise InvalidPlanError, "#{path} keys must be strings" unless value.keys.all?(String)

  value
end

Instance Method Details

#drift?Boolean

Returns:

  • (Boolean)


216
217
218
# File 'lib/analytics_ops/plan.rb', line 216

def drift?
  !empty? || findings.any? { |finding| finding.severity == "drift" }
end

#empty?Boolean

Returns:

  • (Boolean)


212
213
214
# File 'lib/analytics_ops/plan.rb', line 212

def empty?
  changes.empty?
end

#to_hrecord

Returns:

  • (record)


220
221
222
223
224
225
226
227
228
229
# File 'lib/analytics_ops/plan.rb', line 220

def to_h
  {
    "format_version" => format_version,
    "profile" => profile,
    "property_id" => property_id,
    "snapshot_fingerprint" => snapshot_fingerprint,
    "changes" => changes.map(&:to_h),
    "findings" => findings.map(&:to_h)
  }
end

#to_json(*_arguments) ⇒ String

Parameters:

  • arguments (Object)

Returns:

  • (String)


231
232
233
# File 'lib/analytics_ops/plan.rb', line 231

def to_json(*_arguments)
  "#{JSON.pretty_generate(Canonical.normalize(to_h))}\n"
end

#write(path) ⇒ String

Parameters:

  • path (String)

Returns:

  • (String)


235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/analytics_ops/plan.rb', line 235

def write(path)
  expanded = File.expand_path(path)
  temporary = File.join(File.dirname(expanded),
                        ".#{File.basename(expanded)}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
  File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
    file.write(to_json)
    file.flush
    file.fsync
  end
  File.rename(temporary, expanded)
  File.chmod(0o600, expanded)
  expanded
rescue SystemCallError => error
  raise InvalidPlanError, "Cannot write plan #{Redaction.message(path)}: #{Redaction.message(error.message)}"
ensure
  File.unlink(temporary) if temporary && File.exist?(temporary)
end