analytics_ops 0.1.0 → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/CONTRIBUTING.md +2 -1
  4. data/README.md +232 -41
  5. data/SECURITY.md +8 -8
  6. data/docs/api-support-matrix.md +9 -8
  7. data/docs/architecture.md +22 -4
  8. data/docs/authentication.md +112 -51
  9. data/docs/commands.md +71 -17
  10. data/docs/configuration-schema-v1.json +53 -7
  11. data/docs/configuration.md +33 -9
  12. data/docs/google-client-compatibility.md +10 -6
  13. data/docs/live-smoke-test.md +159 -0
  14. data/docs/plan-format.md +4 -0
  15. data/docs/plan-schema-v1.json +113 -22
  16. data/docs/rails.md +12 -3
  17. data/docs/reports.md +44 -6
  18. data/docs/safety.md +18 -6
  19. data/docs/troubleshooting.md +42 -13
  20. data/lib/analytics_ops/applier.rb +2 -1
  21. data/lib/analytics_ops/cli/options.rb +157 -0
  22. data/lib/analytics_ops/cli/presenter.rb +221 -0
  23. data/lib/analytics_ops/cli.rb +172 -201
  24. data/lib/analytics_ops/clients/admin.rb +130 -129
  25. data/lib/analytics_ops/clients/admin_normalization.rb +95 -0
  26. data/lib/analytics_ops/clients/data.rb +120 -63
  27. data/lib/analytics_ops/clients/error_translation.rb +45 -0
  28. data/lib/analytics_ops/configuration/document.rb +1 -1
  29. data/lib/analytics_ops/configuration/loader.rb +38 -5
  30. data/lib/analytics_ops/configuration/schema.rb +51 -8
  31. data/lib/analytics_ops/configuration/validator.rb +43 -8
  32. data/lib/analytics_ops/configuration/writer.rb +105 -0
  33. data/lib/analytics_ops/configuration.rb +1 -0
  34. data/lib/analytics_ops/connection.rb +93 -0
  35. data/lib/analytics_ops/desired_state.rb +2 -2
  36. data/lib/analytics_ops/plan.rb +94 -33
  37. data/lib/analytics_ops/planner.rb +10 -2
  38. data/lib/analytics_ops/rails/railtie.rb +13 -18
  39. data/lib/analytics_ops/redaction.rb +14 -9
  40. data/lib/analytics_ops/reports/catalog.rb +22 -5
  41. data/lib/analytics_ops/reports/definition.rb +74 -37
  42. data/lib/analytics_ops/reports/overview.rb +55 -0
  43. data/lib/analytics_ops/reports/overview_result.rb +54 -0
  44. data/lib/analytics_ops/reports/result.rb +38 -9
  45. data/lib/analytics_ops/reports.rb +2 -0
  46. data/lib/analytics_ops/resources.rb +2 -1
  47. data/lib/analytics_ops/service_account.rb +171 -0
  48. data/lib/analytics_ops/setup.rb +171 -0
  49. data/lib/analytics_ops/snapshot.rb +20 -5
  50. data/lib/analytics_ops/version.rb +1 -1
  51. data/lib/analytics_ops/workspace.rb +41 -12
  52. data/lib/analytics_ops.rb +4 -0
  53. data/lib/generators/analytics_ops/install_generator.rb +2 -0
  54. data/lib/generators/analytics_ops/templates/analytics_ops.yml +2 -15
  55. data/sig/analytics_ops.rbs +117 -10
  56. metadata +26 -1
@@ -10,10 +10,12 @@ module AnalyticsOps
10
10
  API_NAME = /\A[a-z][a-zA-Z0-9_]*(?::[a-zA-Z][a-zA-Z0-9_]*)?\z/
11
11
  REPORT_NAME = /\A[a-z][a-z0-9_]{0,63}\z/
12
12
  RANGE_NAME = /\A[a-z][a-z0-9_]{0,39}\z/
13
+ ABSOLUTE_DATE = /\A\d{4}-\d{2}-\d{2}\z/
13
14
  RELATIVE_DATE = /\A(\d{1,4})daysAgo\z/
14
15
  MATCH_TYPES = %w[exact begins_with ends_with contains full_regexp partial_regexp].freeze
15
16
  NUMERIC_OPERATIONS = %w[equal less_than less_than_or_equal greater_than greater_than_or_equal].freeze
16
17
  MAX_LIMIT = 100_000
18
+ INT64_RANGE = (-(2**63))..((2**63) - 1)
17
19
 
18
20
  attr_reader :name, :kind, :dimensions, :metrics, :date_ranges,
19
21
  :dimension_filter, :metric_filter, :order_bys, :offset, :limit
@@ -22,8 +24,8 @@ module AnalyticsOps
22
24
  metric_filter: nil, order_bys: [], offset: 0, limit: 100)
23
25
  @name = validate_name(name)
24
26
  @kind = validate_kind(kind)
25
- @dimensions = api_names(dimensions, "dimensions", maximum: realtime_kind?(kind) ? 4 : 9)
26
- @metrics = api_names(metrics, "metrics", maximum: 10)
27
+ @dimensions = api_names(dimensions, "dimensions", minimum: 0, maximum: realtime_kind?(kind) ? 4 : 9)
28
+ @metrics = api_names(metrics, "metrics", minimum: 1, maximum: 10)
27
29
  @date_ranges = ranges(date_ranges)
28
30
  @dimension_filter = string_filter(dimension_filter)
29
31
  @metric_filter = numeric_filter(metric_filter)
@@ -71,10 +73,11 @@ module AnalyticsOps
71
73
  value == "realtime"
72
74
  end
73
75
 
74
- def api_names(values, label, maximum:)
75
- unless values.is_a?(Array) && values.any? && values.length <= maximum &&
76
+ def api_names(values, label, minimum:, maximum:)
77
+ unless values.is_a?(Array) && values.length.between?(minimum, maximum) &&
76
78
  values.all? { |value| valid_api_name?(value) }
77
- raise InvalidRequestError, "Report #{label} must contain 1 to #{maximum} valid Data API names"
79
+ raise InvalidRequestError,
80
+ "Report #{label} must contain #{minimum} to #{maximum} valid Data API names"
78
81
  end
79
82
  unless values.uniq.length == values.length
80
83
  raise InvalidRequestError,
@@ -92,23 +95,28 @@ module AnalyticsOps
92
95
  raise InvalidRequestError, "date_ranges must be an array" unless values.is_a?(Array)
93
96
  raise InvalidRequestError, "A report can contain at most four date ranges" if values.length > 4
94
97
 
95
- normalized = values.map do |range|
96
- hash = string_hash(range, "date range", %w[start_date end_date name])
97
- start_date = date(hash.fetch("start_date") { invalid!("Missing start_date") })
98
- end_date = date(hash.fetch("end_date") { invalid!("Missing end_date") })
99
- validate_date_order!(start_date, end_date)
100
-
101
- result = { "start_date" => start_date, "end_date" => end_date }
102
- if hash.key?("name")
103
- range_name = hash.fetch("name")
104
- invalid!("Date range name is invalid") unless range_name.is_a?(String) && RANGE_NAME.match?(range_name)
105
- result["name"] = range_name
106
- end
107
- result
108
- end
98
+ normalized = values.map { |range| normalize_range(range) }
99
+ names = normalized.filter_map { |range| range["name"] }
100
+ invalid!("Date range names must be unique") unless names.uniq.length == names.length
101
+
109
102
  Canonical.immutable(normalized)
110
103
  end
111
104
 
105
+ def normalize_range(range)
106
+ hash = string_hash(range, "date range", %w[start_date end_date name])
107
+ start_date = date(hash.fetch("start_date") { invalid!("Missing start_date") })
108
+ end_date = date(hash.fetch("end_date") { invalid!("Missing end_date") })
109
+ validate_date_order!(start_date, end_date)
110
+ result = { "start_date" => start_date, "end_date" => end_date }
111
+ return result unless hash.key?("name")
112
+
113
+ range_name = hash.fetch("name")
114
+ valid = range_name.is_a?(String) && RANGE_NAME.match?(range_name) &&
115
+ !range_name.start_with?("date_range_")
116
+ invalid!("Date range name is invalid or reserved") unless valid
117
+ result.merge("name" => range_name)
118
+ end
119
+
112
120
  def date(value)
113
121
  invalid!("Invalid report date") unless value.is_a?(String)
114
122
  return value if %w[today yesterday].include?(value)
@@ -116,6 +124,8 @@ module AnalyticsOps
116
124
  relative = RELATIVE_DATE.match(value)
117
125
  return value if relative && relative[1].to_i <= 3650
118
126
 
127
+ invalid!("Invalid report date") unless ABSOLUTE_DATE.match?(value)
128
+
119
129
  Date.iso8601(value)
120
130
  value
121
131
  rescue Date::Error
@@ -123,12 +133,25 @@ module AnalyticsOps
123
133
  end
124
134
 
125
135
  def validate_date_order!(start_date, end_date)
126
- return unless start_date.match?(/\A\d{4}-/) && end_date.match?(/\A\d{4}-/)
127
- return if Date.iso8601(start_date) <= Date.iso8601(end_date)
136
+ if ABSOLUTE_DATE.match?(start_date) && ABSOLUTE_DATE.match?(end_date)
137
+ return if Date.iso8601(start_date) <= Date.iso8601(end_date)
138
+ else
139
+ start_offset = relative_date_offset(start_date)
140
+ end_offset = relative_date_offset(end_date)
141
+ return unless start_offset && end_offset
142
+ return if start_offset >= end_offset
143
+ end
128
144
 
129
145
  invalid!("Report start_date must not be after end_date")
130
146
  end
131
147
 
148
+ def relative_date_offset(value)
149
+ return 0 if value == "today"
150
+ return 1 if value == "yesterday"
151
+
152
+ RELATIVE_DATE.match(value)&.[](1)&.to_i
153
+ end
154
+
132
155
  def string_filter(value)
133
156
  return nil if value.nil?
134
157
 
@@ -157,35 +180,49 @@ module AnalyticsOps
157
180
  field = hash.fetch("field") { invalid!("Missing metric filter field") }
158
181
  operation = hash.fetch("operation") { invalid!("Missing metric filter operation") }
159
182
  number = hash.fetch("value") { invalid!("Missing metric filter value") }
160
- valid = valid_api_name?(field) && metrics.include?(field) &&
161
- NUMERIC_OPERATIONS.include?(operation) && number.is_a?(Numeric)
183
+ valid = valid_api_name?(field) && metrics.include?(field) && NUMERIC_OPERATIONS.include?(operation)
162
184
  invalid!("Metric filter is invalid or references an unselected metric") unless valid
163
- invalid!("Metric filter value must be finite") unless number.finite?
185
+ unless valid_numeric_filter_value?(number)
186
+ invalid!("Metric filter value must be a finite int64 Integer or Float")
187
+ end
164
188
 
165
189
  Canonical.immutable("field" => field, "operation" => operation, "value" => number)
166
190
  end
167
191
 
192
+ def valid_numeric_filter_value?(number)
193
+ (number.is_a?(Integer) && INT64_RANGE.cover?(number)) || (number.is_a?(Float) && number.finite?)
194
+ end
195
+
168
196
  def orders(values)
169
197
  raise InvalidRequestError, "order_bys must be an array" unless values.is_a?(Array)
170
198
  raise InvalidRequestError, "A report can contain at most ten order clauses" if values.length > 10
171
199
 
172
- normalized = values.map do |order|
173
- hash = string_hash(order, "report order", %w[metric dimension desc])
174
- selectors = %w[metric dimension].select { |key| hash.key?(key) }
175
- invalid!("Report order requires exactly one metric or dimension") unless selectors.length == 1
176
-
177
- selector = selectors.first
178
- api_name = hash.fetch(selector)
179
- selected = selector == "metric" ? metrics : dimensions
180
- unless valid_api_name?(api_name) && selected.include?(api_name)
181
- invalid!("Report order references an unselected #{selector}")
182
- end
200
+ normalized = values.map { |order| normalize_order(order) }
201
+ identities = normalized.map { |order| order_identity(order) }
202
+ invalid!("Report order clauses must reference unique fields") unless identities.uniq.length == identities.length
183
203
 
184
- { selector => api_name, "desc" => hash.key?("desc") ? boolean(hash.fetch("desc"), "desc") : false }
185
- end
186
204
  Canonical.immutable(normalized)
187
205
  end
188
206
 
207
+ def normalize_order(order)
208
+ hash = string_hash(order, "report order", %w[metric dimension desc])
209
+ selectors = %w[metric dimension].select { |key| hash.key?(key) }
210
+ invalid!("Report order requires exactly one metric or dimension") unless selectors.length == 1
211
+
212
+ selector = selectors.first
213
+ api_name = hash.fetch(selector)
214
+ selected = selector == "metric" ? metrics : dimensions
215
+ valid = valid_api_name?(api_name) && selected.include?(api_name)
216
+ invalid!("Report order references an unselected #{selector}") unless valid
217
+
218
+ { selector => api_name, "desc" => hash.key?("desc") ? boolean(hash.fetch("desc"), "desc") : false }
219
+ end
220
+
221
+ def order_identity(order)
222
+ selector = order.key?("metric") ? "metric" : "dimension"
223
+ [selector, order.fetch(selector)]
224
+ end
225
+
189
226
  def validate_shape!
190
227
  invalid!("Realtime reports cannot include date ranges") if realtime? && !date_ranges.empty?
191
228
  invalid!("Realtime reports do not support offset") if realtime? && offset.positive?
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Reports
5
+ # Internal catalog for the small reports shown by Workspace#overview.
6
+ module Overview
7
+ DEFINITIONS = [
8
+ Definition.new(
9
+ name: "overview_totals",
10
+ kind: "standard",
11
+ dimensions: [],
12
+ metrics: %w[activeUsers sessions keyEvents],
13
+ date_ranges: Catalog::STANDARD_DATE_RANGE,
14
+ limit: 1
15
+ ),
16
+ Definition.new(
17
+ name: "overview_trend",
18
+ kind: "standard",
19
+ dimensions: %w[date],
20
+ metrics: %w[activeUsers sessions keyEvents],
21
+ date_ranges: Catalog::STANDARD_DATE_RANGE,
22
+ order_bys: [{ "dimension" => "date", "desc" => false }],
23
+ limit: 31
24
+ ),
25
+ Definition.new(
26
+ name: "overview_acquisition",
27
+ kind: "standard",
28
+ dimensions: %w[sessionDefaultChannelGroup],
29
+ metrics: %w[sessions activeUsers keyEvents],
30
+ date_ranges: Catalog::STANDARD_DATE_RANGE,
31
+ order_bys: [{ "metric" => "sessions", "desc" => true }],
32
+ limit: 10
33
+ ),
34
+ Definition.new(
35
+ name: "overview_landing_pages",
36
+ kind: "standard",
37
+ dimensions: %w[landingPagePlusQueryString],
38
+ metrics: %w[sessions activeUsers keyEvents],
39
+ date_ranges: Catalog::STANDARD_DATE_RANGE,
40
+ order_bys: [{ "metric" => "sessions", "desc" => true }],
41
+ limit: 10
42
+ ),
43
+ Definition.new(
44
+ name: "overview_devices",
45
+ kind: "standard",
46
+ dimensions: %w[deviceCategory],
47
+ metrics: %w[activeUsers sessions keyEvents],
48
+ date_ranges: Catalog::STANDARD_DATE_RANGE,
49
+ order_bys: [{ "metric" => "activeUsers", "desc" => true }],
50
+ limit: 10
51
+ )
52
+ ].freeze
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Reports
5
+ # Immutable collection of the bounded reports that make up an overview.
6
+ class OverviewResult
7
+ MAX_REPORTS = 5
8
+ PROPERTY_ID = /\A\d{1,50}\z/
9
+
10
+ attr_reader :property_id, :reports, :property_quota
11
+
12
+ def initialize(property_id:, reports:)
13
+ unless property_id.is_a?(String) && PROPERTY_ID.match?(property_id)
14
+ raise RemoteError, "Overview property ID is invalid"
15
+ end
16
+ unless reports.is_a?(Array) && reports.length.between?(1, MAX_REPORTS) &&
17
+ reports.all? { |report| report.is_a?(Result) && report.kind == "standard" }
18
+ raise RemoteError, "Overview must contain 1 to #{MAX_REPORTS} standard report results"
19
+ end
20
+
21
+ names = reports.map(&:name)
22
+ raise RemoteError, "Overview report names must be unique" unless names.uniq.length == names.length
23
+
24
+ @property_id = property_id.dup.freeze
25
+ @reports = reports.dup.freeze
26
+ @property_quota = Canonical.immutable(latest_quota)
27
+ freeze
28
+ end
29
+
30
+ def report(name)
31
+ reports.find { |result| result.name == name.to_s } ||
32
+ raise(KeyError, "Unknown overview report #{name.inspect}")
33
+ end
34
+
35
+ def to_h
36
+ {
37
+ "property_id" => property_id,
38
+ "reports" => reports.map(&:to_h),
39
+ "property_quota" => property_quota
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ def latest_quota
46
+ reports.reverse_each do |result|
47
+ quota = result.metadata["property_quota"]
48
+ return quota if quota
49
+ end
50
+ {}
51
+ end
52
+ end
53
+ end
54
+ end
@@ -4,24 +4,21 @@ module AnalyticsOps
4
4
  module Reports
5
5
  # Immutable normalized Data API response.
6
6
  class Result
7
- attr_reader :name, :kind, :dimension_headers, :metric_headers, :rows, :row_count, :metadata
7
+ attr_reader :name, :kind, :dimension_headers, :metric_headers, :headers, :rows, :row_count, :metadata
8
8
 
9
9
  def initialize(name:, kind:, dimension_headers:, metric_headers:, rows:, row_count:, metadata:)
10
10
  @name = string(name, "name")
11
11
  @kind = kind_value(kind)
12
12
  @dimension_headers = normalize_headers(dimension_headers, "dimension_headers")
13
13
  @metric_headers = normalize_headers(metric_headers, "metric_headers")
14
+ @headers = Canonical.immutable(@dimension_headers + @metric_headers)
14
15
  validate_header_uniqueness!
15
16
  @rows = normalized_rows(rows)
16
17
  @row_count = count(row_count)
17
- @metadata = hash(metadata, "metadata")
18
+ @metadata = normalized_metadata(metadata)
18
19
  freeze
19
20
  end
20
21
 
21
- def headers
22
- dimension_headers + metric_headers
23
- end
24
-
25
22
  def to_h
26
23
  {
27
24
  "name" => name,
@@ -37,7 +34,7 @@ module AnalyticsOps
37
34
  private
38
35
 
39
36
  def string(value, label)
40
- return value.dup.freeze if value.is_a?(String) && !value.empty?
37
+ return value.dup.freeze if valid_utf8?(value) && !value.empty?
41
38
 
42
39
  raise RemoteError, "Report result #{label} is invalid"
43
40
  end
@@ -49,9 +46,10 @@ module AnalyticsOps
49
46
  end
50
47
 
51
48
  def normalize_headers(values, label)
52
- unless values.is_a?(Array) && values.all? { |value| value.is_a?(String) && !value.empty? }
53
- raise RemoteError, "Report result #{label} is invalid"
49
+ valid = values.is_a?(Array) && values.all? do |value|
50
+ valid_utf8?(value) && !value.empty? && value.length <= 128 && !value.match?(/[\u0000-\u001f\u007f]/)
54
51
  end
52
+ raise RemoteError, "Report result #{label} is invalid" unless valid
55
53
 
56
54
  Canonical.immutable(values)
57
55
  end
@@ -66,6 +64,9 @@ module AnalyticsOps
66
64
  normalized = values.map do |row|
67
65
  value = hash(row, "row")
68
66
  raise RemoteError, "Report result row fields do not match headers" unless value.keys.sort == headers.sort
67
+ unless value.values.all? { |item| valid_utf8?(item) }
68
+ raise RemoteError, "Report result row values must be valid UTF-8 strings"
69
+ end
69
70
 
70
71
  value
71
72
  end
@@ -85,6 +86,34 @@ module AnalyticsOps
85
86
 
86
87
  Canonical.immutable(value)
87
88
  end
89
+
90
+ def normalized_metadata(value)
91
+ raise RemoteError, "Report result metadata must be an object" unless value.is_a?(Hash)
92
+
93
+ validate_metadata_value!(value, "metadata")
94
+ Canonical.immutable(value)
95
+ end
96
+
97
+ def validate_metadata_value!(value, path)
98
+ case value
99
+ when Hash
100
+ raise RemoteError, "Report result #{path} keys must be strings" unless value.keys.all?(String)
101
+
102
+ value.each { |key, child| validate_metadata_value!(child, "#{path}.#{key}") }
103
+ when Array
104
+ value.each.with_index { |child, index| validate_metadata_value!(child, "#{path}[#{index}]") }
105
+ when String, Integer, TrueClass, FalseClass, NilClass
106
+ raise RemoteError, "Report result #{path} must be valid UTF-8" if value.is_a?(String) && !valid_utf8?(value)
107
+ when Float
108
+ raise RemoteError, "Report result #{path} must be finite" unless value.finite?
109
+ else
110
+ raise RemoteError, "Report result #{path} has an unsupported value"
111
+ end
112
+ end
113
+
114
+ def valid_utf8?(value)
115
+ value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding?
116
+ end
88
117
  end
89
118
  end
90
119
  end
@@ -4,7 +4,9 @@
4
4
 
5
5
  require_relative "reports/definition"
6
6
  require_relative "reports/catalog"
7
+ require_relative "reports/overview"
7
8
  require_relative "reports/result"
9
+ require_relative "reports/overview_result"
8
10
 
9
11
  module AnalyticsOps
10
12
  # Immutable Data API definitions, recipes, and normalized results.
@@ -73,7 +73,8 @@ module AnalyticsOps
73
73
 
74
74
  # Registered custom metric.
75
75
  class CustomMetric < Value
76
- fields :name, :parameter_name, :display_name, :description, :scope, :measurement_unit
76
+ fields :name, :parameter_name, :display_name, :description, :scope, :measurement_unit,
77
+ :restricted_metric_types
77
78
  end
78
79
  end
79
80
  end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module AnalyticsOps
8
+ # The single supported Google authentication source.
9
+ class ServiceAccount
10
+ MAX_KEY_BYTES = 64 * 1024
11
+ READ_SCOPE = "https://www.googleapis.com/auth/analytics.readonly"
12
+ EDIT_SCOPE = "https://www.googleapis.com/auth/analytics.edit"
13
+ ACCESS_SCOPES = {
14
+ read: [READ_SCOPE].freeze,
15
+ edit: [READ_SCOPE, EDIT_SCOPE].freeze
16
+ }.freeze
17
+ REQUIRED_FIELDS = %w[type client_email private_key token_uri].freeze
18
+ CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/
19
+
20
+ attr_reader :path
21
+
22
+ def self.load(path: nil, store: Store.new)
23
+ new(path || store.read)
24
+ end
25
+
26
+ def initialize(path)
27
+ @path = validated_path(path).freeze
28
+ validate_key!
29
+ freeze
30
+ end
31
+
32
+ def credentials(access: :read)
33
+ build_credentials(scopes_for(access))
34
+ end
35
+ private :credentials
36
+
37
+ private
38
+
39
+ def scopes_for(access)
40
+ ACCESS_SCOPES.fetch(access) { raise ArgumentError, "access must be :read or :edit" }
41
+ end
42
+
43
+ def build_credentials(scopes)
44
+ require "googleauth"
45
+ File.open(path, "rb") do |key|
46
+ Google::Auth::ServiceAccountCredentials.make_creds(json_key_io: key, scope: scopes)
47
+ end
48
+ rescue LoadError => error
49
+ raise UnsupportedCapabilityError,
50
+ "Google authentication support is unavailable: #{Redaction.message(error.message)}"
51
+ rescue AnalyticsOps::Error
52
+ raise
53
+ rescue StandardError
54
+ raise AuthenticationError, "The configured service-account key could not be loaded"
55
+ end
56
+
57
+ def validated_path(value)
58
+ unless value.is_a?(String) && !value.empty? && !value.match?(CONTROL_CHARACTERS)
59
+ raise AuthenticationError, "A valid service-account key path is required"
60
+ end
61
+
62
+ expanded = File.expand_path(value)
63
+ unless File.file?(expanded)
64
+ raise AuthenticationError,
65
+ "The configured service-account key is unavailable; run " \
66
+ "`analytics-ops setup --service-account /absolute/path/to/service-account.json`"
67
+ end
68
+ if File.size(expanded) > MAX_KEY_BYTES
69
+ raise AuthenticationError, "The configured service-account key is too large"
70
+ end
71
+
72
+ File.realpath(expanded)
73
+ rescue AnalyticsOps::Error
74
+ raise
75
+ rescue SystemCallError
76
+ raise AuthenticationError,
77
+ "The configured service-account key is unavailable; run " \
78
+ "`analytics-ops setup --service-account /absolute/path/to/service-account.json`"
79
+ end
80
+
81
+ def validate_key!
82
+ document = JSON.parse(File.binread(path, MAX_KEY_BYTES + 1), max_nesting: 8)
83
+ valid = document.is_a?(Hash) &&
84
+ document["type"] == "service_account" &&
85
+ REQUIRED_FIELDS.all? { |field| document[field].is_a?(String) && !document[field].empty? }
86
+ return if valid
87
+
88
+ raise AuthenticationError, "The selected JSON file is not a Google service-account key"
89
+ rescue AnalyticsOps::Error
90
+ raise
91
+ rescue JSON::ParserError, ArgumentError, SystemCallError
92
+ raise AuthenticationError, "The selected JSON file is not a valid Google service-account key"
93
+ end
94
+
95
+ # User-level pointer to a validated key. The key itself is never copied.
96
+ class Store
97
+ VERSION = 1
98
+ MAX_BYTES = 16 * 1024
99
+ FIELDS = %w[service_account_path version].freeze
100
+
101
+ attr_reader :path
102
+
103
+ def self.default_path
104
+ File.join(Dir.home, ".config", "analytics_ops", "connection.json")
105
+ rescue ArgumentError
106
+ raise AuthenticationError, "Cannot determine the user configuration directory"
107
+ end
108
+
109
+ def initialize(path: nil)
110
+ candidate = path || self.class.default_path
111
+ unless candidate.is_a?(String) && !candidate.empty? && !candidate.match?(CONTROL_CHARACTERS)
112
+ raise AuthenticationError, "The Analytics Ops connection path is invalid"
113
+ end
114
+
115
+ @path = File.expand_path(candidate).freeze
116
+ end
117
+
118
+ def read
119
+ unless File.file?(path)
120
+ raise AuthenticationError,
121
+ "No service account is configured; run " \
122
+ "`analytics-ops setup --service-account /absolute/path/to/service-account.json`"
123
+ end
124
+ raise AuthenticationError, "The saved Analytics Ops connection is too large" if File.size(path) > MAX_BYTES
125
+
126
+ document = JSON.parse(File.binread(path, MAX_BYTES + 1), max_nesting: 4)
127
+ valid = document.is_a?(Hash) &&
128
+ document.keys.sort == FIELDS &&
129
+ document["version"] == VERSION &&
130
+ document["service_account_path"].is_a?(String)
131
+ return document.fetch("service_account_path") if valid
132
+
133
+ raise AuthenticationError, invalid_connection_message
134
+ rescue AnalyticsOps::Error
135
+ raise
136
+ rescue JSON::ParserError, ArgumentError, SystemCallError
137
+ raise AuthenticationError, invalid_connection_message
138
+ end
139
+
140
+ def write(service_account_path)
141
+ normalized = ServiceAccount.new(service_account_path).path
142
+ directory = File.dirname(path)
143
+ FileUtils.mkdir_p(directory, mode: 0o700)
144
+ File.chmod(0o700, directory)
145
+ temporary = File.join(directory, ".connection.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
146
+ payload = JSON.generate("version" => VERSION, "service_account_path" => normalized) << "\n"
147
+
148
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
149
+ file.write(payload)
150
+ file.flush
151
+ file.fsync
152
+ end
153
+ File.rename(temporary, path)
154
+ File.chmod(0o600, path)
155
+ path
156
+ rescue AnalyticsOps::Error
157
+ raise
158
+ rescue SystemCallError
159
+ raise AuthenticationError, "Cannot save the Analytics Ops service-account connection"
160
+ ensure
161
+ File.unlink(temporary) if temporary && File.exist?(temporary)
162
+ end
163
+
164
+ private
165
+
166
+ def invalid_connection_message
167
+ "The saved Analytics Ops connection is invalid; rerun setup with --service-account"
168
+ end
169
+ end
170
+ end
171
+ end