analytics_ops 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +108 -0
- data/CONTRIBUTING.md +1 -0
- data/README.md +141 -13
- data/SECURITY.md +11 -2
- data/docs/ai-connections.md +192 -0
- data/docs/api-support-matrix.md +25 -8
- data/docs/architecture.md +78 -17
- data/docs/authentication.md +53 -3
- data/docs/bigquery.md +128 -0
- data/docs/commands.md +133 -12
- data/docs/configuration-schema-v1.json +608 -50
- data/docs/configuration.md +118 -5
- data/docs/google-client-compatibility.md +21 -1
- data/docs/governance.md +93 -0
- data/docs/health.md +82 -0
- data/docs/live-smoke-test.md +13 -10
- data/docs/rails.md +25 -9
- data/docs/reports.md +165 -3
- data/docs/safety.md +37 -5
- data/docs/troubleshooting.md +81 -7
- data/lib/analytics_ops/bigquery/definition.rb +173 -0
- data/lib/analytics_ops/bigquery.rb +4 -0
- data/lib/analytics_ops/cli/local_commands.rb +80 -0
- data/lib/analytics_ops/cli/options.rb +282 -9
- data/lib/analytics_ops/cli/presenter.rb +263 -28
- data/lib/analytics_ops/cli/presenter_csv.rb +92 -0
- data/lib/analytics_ops/cli/reporting_commands.rb +129 -0
- data/lib/analytics_ops/cli/setup_commands.rb +91 -0
- data/lib/analytics_ops/cli.rb +121 -44
- data/lib/analytics_ops/clients/admin.rb +28 -2
- data/lib/analytics_ops/clients/admin_governance.rb +95 -0
- data/lib/analytics_ops/clients/admin_governance_normalization.rb +90 -0
- data/lib/analytics_ops/clients/admin_resource_normalization.rb +61 -0
- data/lib/analytics_ops/clients/bigquery.rb +313 -0
- data/lib/analytics_ops/clients/data.rb +147 -54
- data/lib/analytics_ops/clients/data_result_normalization.rb +55 -0
- data/lib/analytics_ops/clients/error_translation.rb +13 -1
- data/lib/analytics_ops/configuration/schema.rb +175 -1
- data/lib/analytics_ops/configuration/validator.rb +45 -7
- data/lib/analytics_ops/configuration/validator_experimental.rb +136 -0
- data/lib/analytics_ops/configuration/validator_reporting.rb +93 -0
- data/lib/analytics_ops/configuration/writer.rb +112 -7
- data/lib/analytics_ops/desired_state.rb +28 -3
- data/lib/analytics_ops/errors.rb +33 -1
- data/lib/analytics_ops/funnels.rb +58 -0
- data/lib/analytics_ops/governance.rb +363 -0
- data/lib/analytics_ops/health.rb +310 -0
- data/lib/analytics_ops/mcp_server/custom_report_tool.rb +101 -0
- data/lib/analytics_ops/mcp_server/discovery_tools.rb +79 -0
- data/lib/analytics_ops/mcp_server/experimental_tools.rb +89 -0
- data/lib/analytics_ops/mcp_server/health_tools.rb +80 -0
- data/lib/analytics_ops/mcp_server/overview_tools.rb +49 -0
- data/lib/analytics_ops/mcp_server/standard_report_tool.rb +53 -0
- data/lib/analytics_ops/mcp_server.rb +402 -0
- data/lib/analytics_ops/portfolio.rb +216 -0
- data/lib/analytics_ops/rails/railtie.rb +19 -8
- data/lib/analytics_ops/redaction.rb +10 -6
- data/lib/analytics_ops/reports/csv_export.rb +91 -0
- data/lib/analytics_ops/reports/definition.rb +152 -14
- data/lib/analytics_ops/reports/metadata.rb +158 -0
- data/lib/analytics_ops/reports/period.rb +104 -0
- data/lib/analytics_ops/reports/result.rb +8 -3
- data/lib/analytics_ops/reports.rb +3 -0
- data/lib/analytics_ops/service_account.rb +321 -29
- data/lib/analytics_ops/setup.rb +31 -7
- data/lib/analytics_ops/version.rb +1 -1
- data/lib/analytics_ops/workspace.rb +222 -10
- data/lib/analytics_ops.rb +7 -2
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +1 -1
- data/sig/analytics_ops.rbs +494 -9
- metadata +60 -1
|
@@ -10,22 +10,25 @@ module AnalyticsOps
|
|
|
10
10
|
MAX_KEY_BYTES = 64 * 1024
|
|
11
11
|
READ_SCOPE = "https://www.googleapis.com/auth/analytics.readonly"
|
|
12
12
|
EDIT_SCOPE = "https://www.googleapis.com/auth/analytics.edit"
|
|
13
|
+
BIGQUERY_SCOPE = "https://www.googleapis.com/auth/bigquery.readonly"
|
|
13
14
|
ACCESS_SCOPES = {
|
|
14
15
|
read: [READ_SCOPE].freeze,
|
|
15
|
-
edit: [READ_SCOPE, EDIT_SCOPE].freeze
|
|
16
|
+
edit: [READ_SCOPE, EDIT_SCOPE].freeze,
|
|
17
|
+
bigquery: [BIGQUERY_SCOPE].freeze
|
|
16
18
|
}.freeze
|
|
17
19
|
REQUIRED_FIELDS = %w[type client_email private_key token_uri].freeze
|
|
18
20
|
CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/
|
|
19
21
|
|
|
20
|
-
attr_reader :path
|
|
22
|
+
attr_reader :path, :security_warnings
|
|
21
23
|
|
|
22
|
-
def self.load(path: nil, store: Store.new)
|
|
23
|
-
new(path || store.read)
|
|
24
|
+
def self.load(path: nil, store: Store.new, connection: nil, config: nil, profile: nil)
|
|
25
|
+
new(path || store.read(name: connection, config:, profile:))
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def initialize(path)
|
|
27
29
|
@path = validated_path(path).freeze
|
|
28
30
|
validate_key!
|
|
31
|
+
@security_warnings = key_security_warnings.map { |warning| warning.dup.freeze }.freeze
|
|
29
32
|
freeze
|
|
30
33
|
end
|
|
31
34
|
|
|
@@ -37,7 +40,7 @@ module AnalyticsOps
|
|
|
37
40
|
private
|
|
38
41
|
|
|
39
42
|
def scopes_for(access)
|
|
40
|
-
ACCESS_SCOPES.fetch(access) { raise ArgumentError, "access must be :read or :
|
|
43
|
+
ACCESS_SCOPES.fetch(access) { raise ArgumentError, "access must be :read, :edit, or :bigquery" }
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
def build_credentials(scopes)
|
|
@@ -92,11 +95,43 @@ module AnalyticsOps
|
|
|
92
95
|
raise AuthenticationError, "The selected JSON file is not a valid Google service-account key"
|
|
93
96
|
end
|
|
94
97
|
|
|
98
|
+
def key_security_warnings
|
|
99
|
+
warnings = []
|
|
100
|
+
mode = File.stat(path).mode & 0o777
|
|
101
|
+
if mode.anybits?(0o077)
|
|
102
|
+
warnings << "Service-account key permissions are #{format("%04o", mode)}; protect the file with `chmod 600`."
|
|
103
|
+
end
|
|
104
|
+
if inside_git_repository?
|
|
105
|
+
warnings << "The service-account key is stored inside a Git repository; " \
|
|
106
|
+
"move it outside every application repository."
|
|
107
|
+
end
|
|
108
|
+
warnings
|
|
109
|
+
rescue SystemCallError
|
|
110
|
+
["Analytics Ops could not verify the service-account key's local file permissions."]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def inside_git_repository?
|
|
114
|
+
directory = File.dirname(path)
|
|
115
|
+
loop do
|
|
116
|
+
return true if File.exist?(File.join(directory, ".git"))
|
|
117
|
+
|
|
118
|
+
parent = File.dirname(directory)
|
|
119
|
+
return false if parent == directory
|
|
120
|
+
|
|
121
|
+
directory = parent
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
95
125
|
# User-level pointer to a validated key. The key itself is never copied.
|
|
96
126
|
class Store
|
|
97
|
-
VERSION =
|
|
98
|
-
|
|
99
|
-
|
|
127
|
+
VERSION = 2
|
|
128
|
+
LEGACY_VERSION = 1
|
|
129
|
+
MAX_BYTES = 64 * 1024
|
|
130
|
+
FIELDS = %w[configs connections version].freeze
|
|
131
|
+
LEGACY_FIELDS = %w[service_account_path version].freeze
|
|
132
|
+
CONFIG_FIELDS = %w[profile_connections selected_profile].freeze
|
|
133
|
+
CONNECTION_NAME = /\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/
|
|
134
|
+
PROFILE_NAME = /\A[A-Za-z][A-Za-z0-9_]{0,63}\z/
|
|
100
135
|
|
|
101
136
|
attr_reader :path
|
|
102
137
|
|
|
@@ -115,35 +150,105 @@ module AnalyticsOps
|
|
|
115
150
|
@path = File.expand_path(candidate).freeze
|
|
116
151
|
end
|
|
117
152
|
|
|
118
|
-
def read
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
end
|
|
124
|
-
raise AuthenticationError, "The saved Analytics Ops connection is too large" if File.size(path) > MAX_BYTES
|
|
153
|
+
def read(name: nil, config: nil, profile: nil)
|
|
154
|
+
saved = read_document(required: true)
|
|
155
|
+
selected = resolve_name(saved, name:, config:, profile:)
|
|
156
|
+
saved.fetch("connections").fetch(selected)
|
|
157
|
+
end
|
|
125
158
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
document["version"] == VERSION &&
|
|
130
|
-
document["service_account_path"].is_a?(String)
|
|
131
|
-
return document.fetch("service_account_path") if valid
|
|
159
|
+
def resolve_connection_name(name: nil, config: nil, profile: nil)
|
|
160
|
+
resolve_name(read_document(required: true), name:, config:, profile:)
|
|
161
|
+
end
|
|
132
162
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
163
|
+
def connection_name_for(service_account_path, preferred:, config:, profile:)
|
|
164
|
+
normalized = ServiceAccount.new(service_account_path).path
|
|
165
|
+
validate_association!(config, profile)
|
|
166
|
+
preferred_name = validate_connection_name!(preferred)
|
|
167
|
+
saved = read_document(required: false)
|
|
168
|
+
associated = associated_connection(saved, config:, profile:)
|
|
169
|
+
return associated if associated
|
|
170
|
+
|
|
171
|
+
connections = saved.fetch("connections")
|
|
172
|
+
return preferred_name unless connections.key?(preferred_name)
|
|
173
|
+
return preferred_name if connections.fetch(preferred_name) == normalized
|
|
174
|
+
|
|
175
|
+
unique_connection_name(connections, preferred_name, normalized)
|
|
138
176
|
end
|
|
139
177
|
|
|
140
|
-
def write(service_account_path)
|
|
178
|
+
def write(service_account_path, name: "default", config: nil, profile: nil, select: true)
|
|
141
179
|
normalized = ServiceAccount.new(service_account_path).path
|
|
180
|
+
connection_name = validate_connection_name!(name)
|
|
181
|
+
validate_association!(config, profile)
|
|
182
|
+
raise AuthenticationError, "select must be true or false" unless [true, false].include?(select)
|
|
183
|
+
|
|
184
|
+
saved = read_document(required: false)
|
|
185
|
+
if connection_name != "default" &&
|
|
186
|
+
saved.fetch("configs").empty? &&
|
|
187
|
+
saved.fetch("connections") == { "default" => normalized }
|
|
188
|
+
saved.fetch("connections").delete("default")
|
|
189
|
+
end
|
|
190
|
+
saved.fetch("connections")[connection_name] = normalized
|
|
191
|
+
associate!(saved, config:, profile:, connection: connection_name, select:) if config
|
|
192
|
+
persist(saved)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def select(config:, profile:, connection: nil)
|
|
196
|
+
validate_association!(config, profile)
|
|
197
|
+
saved = read_document(required: true)
|
|
198
|
+
selected = resolve_name(saved, name: connection, config:, profile:)
|
|
199
|
+
associate!(saved, config:, profile:, connection: selected, select: true)
|
|
200
|
+
persist(saved)
|
|
201
|
+
selection(config:)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def selection(config:)
|
|
205
|
+
saved = read_document(required: false)
|
|
206
|
+
entry = saved.fetch("configs")[config_key(config)]
|
|
207
|
+
return nil unless entry
|
|
208
|
+
|
|
209
|
+
profile = entry.fetch("selected_profile")
|
|
210
|
+
{
|
|
211
|
+
"profile" => profile,
|
|
212
|
+
"connection" => entry.fetch("profile_connections").fetch(profile)
|
|
213
|
+
}.freeze
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def selected_profile(config:)
|
|
217
|
+
selection(config:)&.fetch("profile")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def profile_connection(config:, profile:)
|
|
221
|
+
saved = read_document(required: false)
|
|
222
|
+
associated = associated_connection(saved, config:, profile:)
|
|
223
|
+
return associated if associated
|
|
224
|
+
|
|
225
|
+
resolve_name(saved, name: nil, config:, profile:)
|
|
226
|
+
rescue AuthenticationError
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def summaries
|
|
231
|
+
saved = read_document(required: false)
|
|
232
|
+
selected_names = saved.fetch("configs").values.flat_map do |entry|
|
|
233
|
+
entry.fetch("profile_connections").values
|
|
234
|
+
end.uniq
|
|
235
|
+
saved.fetch("connections").sort.map do |name, service_account_path|
|
|
236
|
+
{
|
|
237
|
+
"name" => name,
|
|
238
|
+
"available" => File.file?(service_account_path),
|
|
239
|
+
"in_use" => selected_names.include?(name)
|
|
240
|
+
}.freeze
|
|
241
|
+
end.freeze
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
def persist(document)
|
|
142
247
|
directory = File.dirname(path)
|
|
143
248
|
FileUtils.mkdir_p(directory, mode: 0o700)
|
|
144
249
|
File.chmod(0o700, directory)
|
|
145
250
|
temporary = File.join(directory, ".connection.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
|
|
146
|
-
payload = JSON.generate(
|
|
251
|
+
payload = JSON.generate(ordered_document(document)) << "\n"
|
|
147
252
|
|
|
148
253
|
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
149
254
|
file.write(payload)
|
|
@@ -161,7 +266,194 @@ module AnalyticsOps
|
|
|
161
266
|
File.unlink(temporary) if temporary && File.exist?(temporary)
|
|
162
267
|
end
|
|
163
268
|
|
|
164
|
-
|
|
269
|
+
def read_document(required:)
|
|
270
|
+
unless File.file?(path)
|
|
271
|
+
raise AuthenticationError, missing_connection_message if required
|
|
272
|
+
|
|
273
|
+
return empty_document
|
|
274
|
+
end
|
|
275
|
+
raise AuthenticationError, "The saved Analytics Ops connection is too large" if File.size(path) > MAX_BYTES
|
|
276
|
+
|
|
277
|
+
parsed = JSON.parse(File.binread(path, MAX_BYTES + 1), max_nesting: 8)
|
|
278
|
+
return migrate_legacy(parsed) if legacy_document?(parsed)
|
|
279
|
+
|
|
280
|
+
validate_document!(parsed)
|
|
281
|
+
parsed
|
|
282
|
+
rescue AnalyticsOps::Error
|
|
283
|
+
raise
|
|
284
|
+
rescue JSON::ParserError, ArgumentError, SystemCallError
|
|
285
|
+
raise AuthenticationError, invalid_connection_message
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def empty_document
|
|
289
|
+
{ "version" => VERSION, "connections" => {}, "configs" => {} }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def legacy_document?(document)
|
|
293
|
+
document.is_a?(Hash) &&
|
|
294
|
+
document.keys.sort == LEGACY_FIELDS &&
|
|
295
|
+
document["version"] == LEGACY_VERSION &&
|
|
296
|
+
document["service_account_path"].is_a?(String)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def migrate_legacy(document)
|
|
300
|
+
empty_document.merge(
|
|
301
|
+
"connections" => { "default" => document.fetch("service_account_path") }
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def validate_document!(document)
|
|
306
|
+
valid = document.is_a?(Hash) &&
|
|
307
|
+
document.keys.sort == FIELDS &&
|
|
308
|
+
document["version"] == VERSION &&
|
|
309
|
+
valid_connections?(document["connections"]) &&
|
|
310
|
+
valid_configs?(document["configs"], document["connections"])
|
|
311
|
+
raise AuthenticationError, invalid_connection_message unless valid
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def valid_connections?(connections)
|
|
315
|
+
connections.is_a?(Hash) && connections.all? do |name, service_account_path|
|
|
316
|
+
CONNECTION_NAME.match?(name.to_s) &&
|
|
317
|
+
service_account_path.is_a?(String) &&
|
|
318
|
+
!service_account_path.empty? &&
|
|
319
|
+
!service_account_path.match?(CONTROL_CHARACTERS)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def valid_configs?(configs, connections)
|
|
324
|
+
configs.is_a?(Hash) &&
|
|
325
|
+
configs.all? { |config, entry| valid_config_entry?(config, entry, connections) }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def valid_config_entry?(config, entry, connections)
|
|
329
|
+
return false unless valid_config_name?(config)
|
|
330
|
+
return false unless entry.is_a?(Hash) && entry.keys.sort == CONFIG_FIELDS
|
|
331
|
+
|
|
332
|
+
profile = entry["selected_profile"]
|
|
333
|
+
mappings = entry["profile_connections"]
|
|
334
|
+
PROFILE_NAME.match?(profile.to_s) &&
|
|
335
|
+
mappings.is_a?(Hash) &&
|
|
336
|
+
mappings.key?(profile) &&
|
|
337
|
+
mappings.all? do |profile_name, connection_name|
|
|
338
|
+
valid_profile_connection?(profile_name, connection_name, connections)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def valid_config_name?(config)
|
|
343
|
+
config.is_a?(String) && !config.empty? && !config.match?(CONTROL_CHARACTERS)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def valid_profile_connection?(profile, connection, connections)
|
|
347
|
+
PROFILE_NAME.match?(profile.to_s) &&
|
|
348
|
+
CONNECTION_NAME.match?(connection.to_s) &&
|
|
349
|
+
connections.key?(connection)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def resolve_name(document, name:, config:, profile:)
|
|
353
|
+
connections = document.fetch("connections")
|
|
354
|
+
if name
|
|
355
|
+
selected = validate_connection_name!(name)
|
|
356
|
+
return selected if connections.key?(selected)
|
|
357
|
+
|
|
358
|
+
raise AuthenticationError, "Unknown Analytics Ops connection #{selected.inspect}; " \
|
|
359
|
+
"available connections: #{connections.keys.sort.join(", ")}"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
associated = associated_connection(document, config:, profile:)
|
|
363
|
+
return associated if associated && connections.key?(associated)
|
|
364
|
+
return profile if profile && connections.key?(profile)
|
|
365
|
+
return connections.keys.first if connections.length == 1
|
|
366
|
+
return "default" if connections.key?("default")
|
|
367
|
+
|
|
368
|
+
raise AuthenticationError, choose_connection_message(connections)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def associated_connection(document, config:, profile:)
|
|
372
|
+
return nil unless config
|
|
373
|
+
|
|
374
|
+
entry = document.fetch("configs")[config_key(config)]
|
|
375
|
+
return nil unless entry
|
|
376
|
+
|
|
377
|
+
selected_profile = profile || entry.fetch("selected_profile")
|
|
378
|
+
entry.fetch("profile_connections")[selected_profile]
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def associate!(document, config:, profile:, connection:, select:)
|
|
382
|
+
key = config_key(config)
|
|
383
|
+
entry = document.fetch("configs")[key] ||= {
|
|
384
|
+
"selected_profile" => profile,
|
|
385
|
+
"profile_connections" => {}
|
|
386
|
+
}
|
|
387
|
+
entry.fetch("profile_connections")[profile] = connection
|
|
388
|
+
entry["selected_profile"] = profile if select
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def validate_association!(config, profile)
|
|
392
|
+
return if config.nil? && profile.nil?
|
|
393
|
+
unless config.is_a?(String) && !config.empty? && !config.match?(CONTROL_CHARACTERS)
|
|
394
|
+
raise AuthenticationError, "The Analytics Ops configuration path is invalid"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
validate_profile_name!(profile)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def validate_connection_name!(name)
|
|
401
|
+
string = name.to_s
|
|
402
|
+
return string if CONNECTION_NAME.match?(string)
|
|
403
|
+
|
|
404
|
+
raise AuthenticationError,
|
|
405
|
+
"Connection names must start with a letter and use only letters, numbers, hyphens, or underscores"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def validate_profile_name!(profile)
|
|
409
|
+
string = profile.to_s
|
|
410
|
+
return string if PROFILE_NAME.match?(string)
|
|
411
|
+
|
|
412
|
+
raise AuthenticationError,
|
|
413
|
+
"Profile names must start with a letter and use only letters, numbers, or underscores"
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def config_key(config)
|
|
417
|
+
expanded = File.expand_path(config)
|
|
418
|
+
File.exist?(expanded) ? File.realpath(expanded) : expanded
|
|
419
|
+
rescue SystemCallError
|
|
420
|
+
File.expand_path(config)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def ordered_document(document)
|
|
424
|
+
connections = document.fetch("connections").sort.to_h
|
|
425
|
+
configs = document.fetch("configs").sort.to_h do |config, entry|
|
|
426
|
+
[
|
|
427
|
+
config,
|
|
428
|
+
{
|
|
429
|
+
"selected_profile" => entry.fetch("selected_profile"),
|
|
430
|
+
"profile_connections" => entry.fetch("profile_connections").sort.to_h
|
|
431
|
+
}
|
|
432
|
+
]
|
|
433
|
+
end
|
|
434
|
+
{ "version" => VERSION, "connections" => connections, "configs" => configs }
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def unique_connection_name(connections, preferred, service_account_path)
|
|
438
|
+
2.upto(9_999) do |number|
|
|
439
|
+
suffix = "_#{number}"
|
|
440
|
+
candidate = "#{preferred.slice(0, 64 - suffix.length)}#{suffix}"
|
|
441
|
+
return candidate unless connections.key?(candidate)
|
|
442
|
+
return candidate if connections.fetch(candidate) == service_account_path
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
raise AuthenticationError, "Cannot choose a unique Analytics Ops connection name; use --connection NAME"
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def missing_connection_message
|
|
449
|
+
"No service account is configured; run " \
|
|
450
|
+
"`analytics-ops setup --service-account /absolute/path/to/service-account.json`"
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def choose_connection_message(connections)
|
|
454
|
+
available = connections.keys.sort.join(", ")
|
|
455
|
+
"More than one Analytics Ops connection is saved. Use --connection NAME; available connections: #{available}"
|
|
456
|
+
end
|
|
165
457
|
|
|
166
458
|
def invalid_connection_message
|
|
167
459
|
"The saved Analytics Ops connection is invalid; rerun setup with --service-account"
|
data/lib/analytics_ops/setup.rb
CHANGED
|
@@ -5,21 +5,26 @@ module AnalyticsOps
|
|
|
5
5
|
class Setup
|
|
6
6
|
PROPERTY_ID = /\A\d{1,50}\z/
|
|
7
7
|
PROFILE = /\A[A-Za-z][A-Za-z0-9_]{0,63}\z/
|
|
8
|
+
DISABLED_API_REASONS = %w[SERVICE_DISABLED ACCESS_NOT_CONFIGURED].freeze
|
|
8
9
|
|
|
9
10
|
# Immutable setup outcome returned to the CLI and Ruby callers.
|
|
10
11
|
class Result
|
|
11
|
-
attr_reader :config_path, :profile, :property
|
|
12
|
+
attr_reader :config_path, :profile, :property, :warnings
|
|
12
13
|
|
|
13
|
-
def initialize(config_path:, profile:, property:, created:)
|
|
14
|
+
def initialize(config_path:, profile:, property:, created:, updated: false, warnings: [])
|
|
14
15
|
unless property.is_a?(Resources::Property)
|
|
15
16
|
raise ArgumentError, "property must be an AnalyticsOps::Resources::Property"
|
|
16
17
|
end
|
|
17
|
-
|
|
18
|
+
unless [true, false].include?(created) && [true, false].include?(updated) && !(created && updated)
|
|
19
|
+
raise ArgumentError, "created and updated must be distinct booleans"
|
|
20
|
+
end
|
|
18
21
|
|
|
19
22
|
@config_path = config_path.to_s.dup.freeze
|
|
20
23
|
@profile = profile.to_s.dup.freeze
|
|
21
24
|
@property = property
|
|
22
25
|
@created = created
|
|
26
|
+
@updated = updated
|
|
27
|
+
@warnings = normalized_warnings(warnings)
|
|
23
28
|
freeze
|
|
24
29
|
end
|
|
25
30
|
|
|
@@ -27,8 +32,12 @@ module AnalyticsOps
|
|
|
27
32
|
@created
|
|
28
33
|
end
|
|
29
34
|
|
|
35
|
+
def updated?
|
|
36
|
+
@updated
|
|
37
|
+
end
|
|
38
|
+
|
|
30
39
|
def status
|
|
31
|
-
created? ? "configured" : "already_configured"
|
|
40
|
+
created? || updated? ? "configured" : "already_configured"
|
|
32
41
|
end
|
|
33
42
|
|
|
34
43
|
def to_h
|
|
@@ -36,13 +45,25 @@ module AnalyticsOps
|
|
|
36
45
|
"status" => status,
|
|
37
46
|
"config" => config_path,
|
|
38
47
|
"profile" => profile,
|
|
48
|
+
"updated" => updated?,
|
|
49
|
+
"warnings" => warnings,
|
|
39
50
|
"property" => property.to_h
|
|
40
51
|
}
|
|
41
52
|
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def normalized_warnings(value)
|
|
57
|
+
unless value.is_a?(Array) && value.all? { |warning| warning.is_a?(String) && !warning.empty? }
|
|
58
|
+
raise ArgumentError, "warnings must be an array of strings"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
value.map { |warning| Redaction.message(warning).freeze }.freeze
|
|
62
|
+
end
|
|
42
63
|
end
|
|
43
64
|
|
|
44
65
|
def initialize(connection:, config:, profile:, property_id: nil, noninteractive: false,
|
|
45
|
-
input: $stdin, out: $stdout)
|
|
66
|
+
warnings: [], input: $stdin, out: $stdout)
|
|
46
67
|
validate_options!(
|
|
47
68
|
config:,
|
|
48
69
|
profile:,
|
|
@@ -54,6 +75,7 @@ module AnalyticsOps
|
|
|
54
75
|
@profile = profile.to_s
|
|
55
76
|
@property_id = property_id
|
|
56
77
|
@noninteractive = noninteractive
|
|
78
|
+
@warnings = warnings
|
|
57
79
|
@input = input
|
|
58
80
|
@out = out
|
|
59
81
|
end
|
|
@@ -70,7 +92,9 @@ module AnalyticsOps
|
|
|
70
92
|
config_path: write.path,
|
|
71
93
|
profile: @profile,
|
|
72
94
|
property: verification.property,
|
|
73
|
-
created: write.created
|
|
95
|
+
created: write.created?,
|
|
96
|
+
updated: write.updated?,
|
|
97
|
+
warnings: @warnings
|
|
74
98
|
)
|
|
75
99
|
end
|
|
76
100
|
|
|
@@ -116,7 +140,7 @@ module AnalyticsOps
|
|
|
116
140
|
end
|
|
117
141
|
|
|
118
142
|
def api_disabled?(error)
|
|
119
|
-
error.
|
|
143
|
+
DISABLED_API_REASONS.include?(error.remote_reason.to_s.upcase)
|
|
120
144
|
end
|
|
121
145
|
|
|
122
146
|
def raise_disabled_api!
|