Class: AnalyticsOps::Plan
- Inherits:
-
Object
- Object
- AnalyticsOps::Plan
- 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 =
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
-
#changes ⇒ Array[Change]
readonly
Returns the value of attribute changes.
-
#findings ⇒ Array[Finding]
readonly
Returns the value of attribute findings.
-
#format_version ⇒ Integer
readonly
Returns the value of attribute format_version.
-
#profile ⇒ String
readonly
Returns the value of attribute profile.
-
#property_id ⇒ String
readonly
Returns the value of attribute property_id.
-
#snapshot_fingerprint ⇒ String
readonly
Returns the value of attribute snapshot_fingerprint.
Class Method Summary collapse
- .array(value, path) ⇒ Object
- .exact_keys!(hash, allowed, path) ⇒ Object
- .from_h(raw) ⇒ Plan
- .integer(value, path, expected: nil) ⇒ Object
- .load(path) ⇒ Plan
- .pattern_string(value, path, pattern, maximum: 500) ⇒ Object
- .printable_string(value, path, minimum: 1, maximum: 500) ⇒ Object
- .string_keyed_hash(value, path) ⇒ Object
Instance Method Summary collapse
- #drift? ⇒ Boolean
- #empty? ⇒ Boolean
-
#initialize(profile:, property_id:, snapshot_fingerprint:, changes:, findings:, format_version: FORMAT_VERSION) ⇒ Plan
constructor
A new instance of Plan.
- #to_h ⇒ record
- #to_json(*_arguments) ⇒ String
- #write(path) ⇒ String
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
#changes ⇒ Array[Change] (readonly)
Returns the value of attribute changes.
192 193 194 |
# File 'lib/analytics_ops/plan.rb', line 192 def changes @changes end |
#findings ⇒ Array[Finding] (readonly)
Returns the value of attribute findings.
192 193 194 |
# File 'lib/analytics_ops/plan.rb', line 192 def findings @findings end |
#format_version ⇒ Integer (readonly)
Returns the value of attribute format_version.
192 193 194 |
# File 'lib/analytics_ops/plan.rb', line 192 def format_version @format_version end |
#profile ⇒ String (readonly)
Returns the value of attribute profile.
192 193 194 |
# File 'lib/analytics_ops/plan.rb', line 192 def profile @profile end |
#property_id ⇒ String (readonly)
Returns the value of attribute property_id.
192 193 194 |
# File 'lib/analytics_ops/plan.rb', line 192 def property_id @property_id end |
#snapshot_fingerprint ⇒ String (readonly)
Returns the value of attribute snapshot_fingerprint.
192 193 194 |
# File 'lib/analytics_ops/plan.rb', line 192 def snapshot_fingerprint @snapshot_fingerprint end |
Class Method Details
.array(value, path) ⇒ Object
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
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
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
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
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.(error.)}" rescue SystemCallError => error raise InvalidPlanError, "Cannot read plan #{Redaction.(path)}: #{Redaction.(error.)}" 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
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
216 217 218 |
# File 'lib/analytics_ops/plan.rb', line 216 def drift? !empty? || findings.any? { |finding| finding.severity == "drift" } end |
#empty? ⇒ Boolean
212 213 214 |
# File 'lib/analytics_ops/plan.rb', line 212 def empty? changes.empty? end |
#to_h ⇒ 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
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
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) = File.(path) temporary = File.join(File.dirname(), ".#{File.basename()}.#{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, ) File.chmod(0o600, ) rescue SystemCallError => error raise InvalidPlanError, "Cannot write plan #{Redaction.(path)}: #{Redaction.(error.)}" ensure File.unlink(temporary) if temporary && File.exist?(temporary) end |