umgr 0.1.4

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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +14 -0
  3. data/README.md +126 -0
  4. data/Rakefile +3 -0
  5. data/exe/umgr +6 -0
  6. data/lib/umgr/apply_result_builder.rb +149 -0
  7. data/lib/umgr/change_set_builder.rb +49 -0
  8. data/lib/umgr/cli.rb +117 -0
  9. data/lib/umgr/config_validator.rb +93 -0
  10. data/lib/umgr/deep_symbolizer.rb +25 -0
  11. data/lib/umgr/desired_state_enricher.rb +15 -0
  12. data/lib/umgr/drift_report_builder.rb +19 -0
  13. data/lib/umgr/errors.rb +36 -0
  14. data/lib/umgr/import_result_builder.rb +79 -0
  15. data/lib/umgr/plan_result_builder.rb +72 -0
  16. data/lib/umgr/provider.rb +26 -0
  17. data/lib/umgr/provider_contract.rb +19 -0
  18. data/lib/umgr/provider_registry.rb +36 -0
  19. data/lib/umgr/provider_resource_validator.rb +27 -0
  20. data/lib/umgr/providers/echo_provider.rb +44 -0
  21. data/lib/umgr/providers/github/account_normalizer.rb +45 -0
  22. data/lib/umgr/providers/github/api_client.rb +111 -0
  23. data/lib/umgr/providers/github/apply_executor.rb +90 -0
  24. data/lib/umgr/providers/github/plan_builder.rb +91 -0
  25. data/lib/umgr/providers/github_provider.rb +125 -0
  26. data/lib/umgr/resource_identity.rb +11 -0
  27. data/lib/umgr/runner.rb +97 -0
  28. data/lib/umgr/runner_config.rb +52 -0
  29. data/lib/umgr/state_backend.rb +59 -0
  30. data/lib/umgr/state_template.rb +7 -0
  31. data/lib/umgr/unknown_provider_guard.rb +18 -0
  32. data/lib/umgr/version.rb +5 -0
  33. data/lib/umgr.rb +28 -0
  34. data/spec/apply_result_builder_spec.rb +142 -0
  35. data/spec/change_set_builder_spec.rb +28 -0
  36. data/spec/cli/commands_spec.rb +519 -0
  37. data/spec/cli/help_spec.rb +14 -0
  38. data/spec/cli/spec_helper.rb +13 -0
  39. data/spec/desired_state_enricher_spec.rb +30 -0
  40. data/spec/drift_report_builder_spec.rb +31 -0
  41. data/spec/import_result_builder_spec.rb +124 -0
  42. data/spec/plan_result_builder_spec.rb +101 -0
  43. data/spec/provider_contract_spec.rb +32 -0
  44. data/spec/provider_registry_spec.rb +42 -0
  45. data/spec/provider_resource_validator_spec.rb +39 -0
  46. data/spec/provider_spec.rb +25 -0
  47. data/spec/providers/echo_provider_spec.rb +55 -0
  48. data/spec/providers/github_account_normalizer_spec.rb +31 -0
  49. data/spec/providers/github_api_client_spec.rb +101 -0
  50. data/spec/providers/github_plan_builder_spec.rb +47 -0
  51. data/spec/providers/github_provider_spec.rb +268 -0
  52. data/spec/resource_identity_spec.rb +13 -0
  53. data/spec/runner_spec.rb +552 -0
  54. data/spec/spec_helper.rb +12 -0
  55. data/spec/state_backend_spec.rb +52 -0
  56. data/spec/support/simple_cov.rb +9 -0
  57. data/spec/support/webmock.rb +5 -0
  58. data/umgr.gemspec +34 -0
  59. metadata +141 -0
@@ -0,0 +1,519 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'spec_helper'
5
+
6
+ RSpec.describe 'umgr commands', :cli do
7
+ let(:executable) { File.expand_path('../../exe/umgr', __dir__) }
8
+
9
+ command_invocations = {
10
+ 'validate' => '--config users.yml'
11
+ }
12
+
13
+ command_invocations.each do |command, args|
14
+ it "dispatches #{command}" do
15
+ write_file('users.yml', "version: 1\nresources: []\n") if args.include?('--config users.yml')
16
+ run_command("#{executable} #{command} #{args}".strip)
17
+
18
+ expect(last_command_started).to have_exit_status(0)
19
+ parsed = JSON.parse(last_command_started.stdout)
20
+ expect(parsed['action']).to eq(command)
21
+ expect(parsed['status']).to eq('not_implemented')
22
+ end
23
+ end
24
+
25
+ it 'returns not_initialized for show when state is missing' do
26
+ run_command("#{executable} show")
27
+
28
+ expect(last_command_started).to have_exit_status(0)
29
+ parsed = JSON.parse(last_command_started.stdout)
30
+ expect(parsed['ok']).to be(true)
31
+ expect(parsed['status']).to eq('not_initialized')
32
+ expect(parsed['state']).to be_nil
33
+ end
34
+
35
+ it 'returns current state for show when state exists' do
36
+ write_file(
37
+ '.umgr/state.json',
38
+ JSON.generate(version: 1, resources: [{ provider: 'github', type: 'user', name: 'alice' }])
39
+ )
40
+ run_command("#{executable} show")
41
+
42
+ expect(last_command_started).to have_exit_status(0)
43
+ parsed = JSON.parse(last_command_started.stdout)
44
+ expect(parsed['ok']).to be(true)
45
+ expect(parsed['status']).to eq('ok')
46
+ expect(parsed['state']).to eq(
47
+ 'version' => 1,
48
+ 'resources' => [{ 'provider' => 'github', 'type' => 'user', 'name' => 'alice' }]
49
+ )
50
+ end
51
+
52
+ it 'initializes state on init' do
53
+ run_command("#{executable} init")
54
+
55
+ expect(last_command_started).to have_exit_status(0)
56
+ parsed = JSON.parse(last_command_started.stdout)
57
+ expect(parsed['ok']).to be(true)
58
+ expect(parsed['status']).to eq('initialized')
59
+ expect(parsed['state']).to eq('version' => 1, 'resources' => [])
60
+ state_path = parsed['state_path']
61
+ expect(state_path).to end_with('/.umgr/state.json')
62
+ expect(File.file?(state_path)).to be(true)
63
+ end
64
+
65
+ it 'returns already_initialized when state file exists' do
66
+ write_file('.umgr/state.json', JSON.generate(version: 1, resources: []))
67
+ run_command("#{executable} init")
68
+
69
+ expect(last_command_started).to have_exit_status(0)
70
+ parsed = JSON.parse(last_command_started.stdout)
71
+ expect(parsed['ok']).to be(true)
72
+ expect(parsed['status']).to eq('already_initialized')
73
+ expect(parsed['state']).to eq('version' => 1, 'resources' => [])
74
+ end
75
+
76
+ it 'passes config option to validate' do
77
+ write_file('users.yml', "version: 1\nresources: []\n")
78
+ run_command("#{executable} validate --config users.yml")
79
+
80
+ expect(last_command_started).to have_exit_status(0)
81
+ parsed = JSON.parse(last_command_started.stdout)
82
+ expect(parsed['options']['config']).to end_with('users.yml')
83
+ end
84
+
85
+ it 'returns planned changeset for desired vs current state' do
86
+ write_file(
87
+ 'users.yml',
88
+ <<~YAML
89
+ version: 1
90
+ resources:
91
+ - provider: echo
92
+ type: user
93
+ name: alice
94
+ attributes:
95
+ team: platform
96
+ - provider: echo
97
+ type: user
98
+ name: carla
99
+ YAML
100
+ )
101
+ write_file(
102
+ '.umgr/state.json',
103
+ JSON.generate(
104
+ version: 1,
105
+ resources: [
106
+ { provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'infra' } },
107
+ { provider: 'echo', type: 'user', name: 'bob' }
108
+ ]
109
+ )
110
+ )
111
+
112
+ run_command("#{executable} plan --config users.yml")
113
+
114
+ expect(last_command_started).to have_exit_status(0)
115
+ lines = last_command_started.stdout.lines.map(&:strip).reject(&:empty?)
116
+
117
+ expect(lines.first).to eq('Drift detected: yes (changes=3)')
118
+ expect(lines[1]).to eq('Plan summary: create=1 update=1 delete=1 no_change=0')
119
+ expect(lines[2..]).to eq(
120
+ [
121
+ 'UPDATE echo.user.alice',
122
+ 'DELETE echo.user.bob',
123
+ 'CREATE echo.user.carla'
124
+ ]
125
+ )
126
+ end
127
+
128
+ it 'applies desired state and returns applied result as json' do
129
+ write_file(
130
+ 'users.yml',
131
+ <<~YAML
132
+ version: 1
133
+ resources:
134
+ - provider: echo
135
+ type: user
136
+ name: alice
137
+ attributes:
138
+ team: platform
139
+ YAML
140
+ )
141
+ write_file(
142
+ '.umgr/state.json',
143
+ JSON.generate(
144
+ version: 1,
145
+ resources: [{ provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'infra' } }]
146
+ )
147
+ )
148
+
149
+ run_command("#{executable} apply --config users.yml")
150
+
151
+ expect(last_command_started).to have_exit_status(0)
152
+ parsed = JSON.parse(last_command_started.stdout)
153
+
154
+ expect(parsed['ok']).to be(true)
155
+ expect(parsed['action']).to eq('apply')
156
+ expect(parsed['status']).to eq('applied')
157
+ expect(parsed.fetch('changeset').fetch('summary')).to eq(
158
+ 'create' => 0,
159
+ 'update' => 1,
160
+ 'delete' => 0,
161
+ 'no_change' => 0
162
+ )
163
+ expect(parsed.fetch('state').fetch('resources').first.fetch('attributes')).to eq('team' => 'platform')
164
+ expect(parsed.fetch('apply_results').first.fetch('status')).to eq('applied')
165
+ expect(parsed.fetch('idempotency')).to eq(
166
+ 'checked' => true,
167
+ 'stable' => true,
168
+ 'summary' => {
169
+ 'create' => 0,
170
+ 'update' => 0,
171
+ 'delete' => 0,
172
+ 'no_change' => 1
173
+ }
174
+ )
175
+ end
176
+
177
+ it 'imports current users and persists imported state as json' do
178
+ write_file(
179
+ 'users.yml',
180
+ <<~YAML
181
+ version: 1
182
+ resources:
183
+ - provider: echo
184
+ type: user
185
+ name: alice
186
+ attributes:
187
+ team: platform
188
+ YAML
189
+ )
190
+
191
+ run_command("#{executable} import --config users.yml")
192
+
193
+ expect(last_command_started).to have_exit_status(0)
194
+ parsed = JSON.parse(last_command_started.stdout)
195
+
196
+ expect(parsed['ok']).to be(true)
197
+ expect(parsed['action']).to eq('import')
198
+ expect(parsed['status']).to eq('imported')
199
+ expect(parsed['imported_count']).to eq(1)
200
+ expect(parsed.fetch('state').fetch('resources')).to eq(
201
+ [
202
+ {
203
+ 'provider' => 'echo',
204
+ 'type' => 'user',
205
+ 'name' => 'alice',
206
+ 'attributes' => { 'team' => 'platform' },
207
+ 'identity' => 'echo.user.alice'
208
+ }
209
+ ]
210
+ )
211
+ end
212
+
213
+ it 'supports end-to-end workflow from init to show' do
214
+ write_file(
215
+ 'users.yml',
216
+ <<~YAML
217
+ version: 1
218
+ resources:
219
+ - provider: echo
220
+ type: user
221
+ name: alice
222
+ attributes:
223
+ team: platform
224
+ YAML
225
+ )
226
+
227
+ run_command("#{executable} init")
228
+ expect(last_command_started).to have_exit_status(0)
229
+ init_result = JSON.parse(last_command_started.stdout)
230
+ expect(init_result['status']).to eq('initialized')
231
+
232
+ run_command("#{executable} validate --config users.yml")
233
+ expect(last_command_started).to have_exit_status(0)
234
+ validate_result = JSON.parse(last_command_started.stdout)
235
+ expect(validate_result['status']).to eq('not_implemented')
236
+
237
+ run_command("#{executable} plan --config users.yml")
238
+ expect(last_command_started).to have_exit_status(0)
239
+ plan_lines = last_command_started.stdout.lines.map(&:strip).reject(&:empty?)
240
+ expect(plan_lines.first).to eq('Drift detected: yes (changes=1)')
241
+ expect(plan_lines[1]).to eq('Plan summary: create=1 update=0 delete=0 no_change=0')
242
+ expect(plan_lines[2..]).to eq(['CREATE echo.user.alice'])
243
+
244
+ run_command("#{executable} apply --config users.yml")
245
+ expect(last_command_started).to have_exit_status(0)
246
+ apply_result = JSON.parse(last_command_started.stdout)
247
+ expect(apply_result['status']).to eq('applied')
248
+
249
+ run_command("#{executable} show")
250
+ expect(last_command_started).to have_exit_status(0)
251
+ show_result = JSON.parse(last_command_started.stdout)
252
+ expect(show_result['status']).to eq('ok')
253
+ expect(show_result['state']).to eq(apply_result['state'])
254
+ end
255
+
256
+ it 'returns no drift when plan is run after apply for the same config' do
257
+ write_file(
258
+ 'users.yml',
259
+ <<~YAML
260
+ version: 1
261
+ resources:
262
+ - provider: echo
263
+ type: user
264
+ name: alice
265
+ attributes:
266
+ team: platform
267
+ YAML
268
+ )
269
+ write_file('.umgr/state.json', JSON.generate(version: 1, resources: []))
270
+
271
+ run_command("#{executable} apply --config users.yml")
272
+ expect(last_command_started).to have_exit_status(0)
273
+
274
+ run_command("#{executable} plan --config users.yml")
275
+
276
+ expect(last_command_started).to have_exit_status(0)
277
+ lines = last_command_started.stdout.lines.map(&:strip).reject(&:empty?)
278
+ expect(lines.first).to eq('Drift detected: no (changes=0)')
279
+ expect(lines[1]).to eq('Plan summary: create=0 update=0 delete=0 no_change=1')
280
+ expect(lines[2..]).to eq(['NO_CHANGE echo.user.alice'])
281
+ end
282
+
283
+ it 'renders plan output as json when --json is provided' do
284
+ write_file(
285
+ 'users.yml',
286
+ <<~YAML
287
+ version: 1
288
+ resources:
289
+ - provider: echo
290
+ type: user
291
+ name: alice
292
+ YAML
293
+ )
294
+
295
+ run_command("#{executable} plan --config users.yml --json")
296
+
297
+ expect(last_command_started).to have_exit_status(0)
298
+ parsed = JSON.parse(last_command_started.stdout)
299
+
300
+ expect(parsed['ok']).to be(true)
301
+ expect(parsed['action']).to eq('plan')
302
+ expect(parsed['status']).to eq('planned')
303
+ expect(parsed.fetch('drift')).to eq(
304
+ 'detected' => true,
305
+ 'change_count' => 1,
306
+ 'actions' => {
307
+ 'create' => 1,
308
+ 'update' => 0,
309
+ 'delete' => 0
310
+ }
311
+ )
312
+ expect(parsed.fetch('changeset').fetch('summary')).to eq(
313
+ 'create' => 1,
314
+ 'update' => 0,
315
+ 'delete' => 0,
316
+ 'no_change' => 0
317
+ )
318
+ end
319
+
320
+ it 'includes github provider plan details in json plan output' do
321
+ write_file(
322
+ 'users.yml',
323
+ <<~YAML
324
+ version: 1
325
+ resources:
326
+ - provider: github
327
+ type: user
328
+ name: alice
329
+ org: acme
330
+ token: secret
331
+ teams:
332
+ - admins
333
+ - security
334
+ YAML
335
+ )
336
+ write_file(
337
+ '.umgr/state.json',
338
+ JSON.generate(
339
+ version: 1,
340
+ resources: [
341
+ { provider: 'github', type: 'user', name: 'alice', org: 'acme', teams: %w[admins platform] }
342
+ ]
343
+ )
344
+ )
345
+
346
+ run_command("#{executable} plan --config users.yml --json")
347
+
348
+ expect(last_command_started).to have_exit_status(0)
349
+ parsed = JSON.parse(last_command_started.stdout)
350
+ change = parsed.fetch('changeset').fetch('changes').find { |item| item['identity'] == 'github.user.alice' }
351
+
352
+ expect(change['action']).to eq('update')
353
+ expect(change.fetch('provider_plan')).to include(
354
+ 'provider' => 'github',
355
+ 'organization_action' => 'keep',
356
+ 'status' => 'planned'
357
+ )
358
+ expect(change.fetch('provider_plan').fetch('team_actions')).to eq(
359
+ 'add' => ['security'],
360
+ 'remove' => ['platform'],
361
+ 'unchanged' => ['admins']
362
+ )
363
+ end
364
+
365
+ it 'auto-discovers config for validate when --config is omitted' do
366
+ write_file('umgr.yml', "version: 1\nresources: []\n")
367
+ run_command("#{executable} validate")
368
+
369
+ expect(last_command_started).to have_exit_status(0)
370
+ parsed = JSON.parse(last_command_started.stdout)
371
+ expect(parsed['options']['config']).to end_with('umgr.yml')
372
+ end
373
+
374
+ it 'uses --config override when discovery candidates exist' do
375
+ write_file('umgr.yml', "version: 1\nresources: []\n")
376
+ write_file('custom.json', "{\"version\":1,\"resources\":[]}\n")
377
+ run_command("#{executable} validate --config custom.json")
378
+
379
+ expect(last_command_started).to have_exit_status(0)
380
+ parsed = JSON.parse(last_command_started.stdout)
381
+ expect(parsed['options']['config']).to end_with('custom.json')
382
+ end
383
+
384
+ it 'maps validation error to CLI exit code' do
385
+ run_command("#{executable} validate")
386
+
387
+ expect(last_command_started).to have_exit_status(Umgr::Errors::ValidationError::EXIT_CODE)
388
+ error_line = last_command_started.stderr.lines.map(&:strip).reject(&:empty?).last
389
+ parsed = JSON.parse(error_line)
390
+ expect(parsed['ok']).to be(false)
391
+ expect(parsed['error']['type']).to eq('Umgr::Errors::ValidationError')
392
+ end
393
+
394
+ it 'returns validation error when schema is invalid' do
395
+ write_file('invalid.yml', "version: 1\nresources:\n - provider: github\n")
396
+ run_command("#{executable} validate --config invalid.yml")
397
+
398
+ expect(last_command_started).to have_exit_status(Umgr::Errors::ValidationError::EXIT_CODE)
399
+ error_line = last_command_started.stderr.lines.map(&:strip).reject(&:empty?).last
400
+ parsed = JSON.parse(error_line)
401
+ expect(parsed['error']['type']).to eq('Umgr::Errors::ValidationError')
402
+ expect(parsed['error']['message']).to match(/missing required string field `type`/)
403
+ end
404
+
405
+ it 'returns validation error when version is invalid' do
406
+ write_file('invalid.yml', "version: banana\nresources: []\n")
407
+ run_command("#{executable} validate --config invalid.yml")
408
+
409
+ expect(last_command_started).to have_exit_status(Umgr::Errors::ValidationError::EXIT_CODE)
410
+ error_line = last_command_started.stderr.lines.map(&:strip).reject(&:empty?).last
411
+ parsed = JSON.parse(error_line)
412
+ expect(parsed['error']['type']).to eq('Umgr::Errors::ValidationError')
413
+ expect(parsed['error']['message']).to match(/`version` must be a positive integer/)
414
+ end
415
+
416
+ it 'returns validation error when provider is unknown' do
417
+ write_file(
418
+ 'users.yml',
419
+ <<~YAML
420
+ version: 1
421
+ resources:
422
+ - provider: atlassian
423
+ type: user
424
+ name: alice
425
+ YAML
426
+ )
427
+
428
+ run_command("#{executable} validate --config users.yml")
429
+
430
+ expect(last_command_started).to have_exit_status(Umgr::Errors::ValidationError::EXIT_CODE)
431
+ error_line = last_command_started.stderr.lines.map(&:strip).reject(&:empty?).last
432
+ parsed = JSON.parse(error_line)
433
+ expect(parsed['error']['type']).to eq('Umgr::Errors::ValidationError')
434
+ expect(parsed['error']['message']).to match(/Unknown provider\(s\) for validate: atlassian/)
435
+ end
436
+
437
+ it 'returns validation error when github provider config is invalid' do
438
+ write_file(
439
+ 'users.yml',
440
+ <<~YAML
441
+ version: 1
442
+ resources:
443
+ - provider: github
444
+ type: user
445
+ name: alice
446
+ token_env: GITHUB_TOKEN
447
+ YAML
448
+ )
449
+
450
+ run_command("#{executable} validate --config users.yml")
451
+
452
+ expect(last_command_started).to have_exit_status(Umgr::Errors::ValidationError::EXIT_CODE)
453
+ error_line = last_command_started.stderr.lines.map(&:strip).reject(&:empty?).last
454
+ parsed = JSON.parse(error_line)
455
+ expect(parsed['error']['type']).to eq('Umgr::Errors::ValidationError')
456
+ expect(parsed['error']['message']).to match(/requires non-empty `org`/)
457
+ end
458
+
459
+ it 'preserves attributes and provider-specific resource fields with echo provider' do
460
+ write_file(
461
+ 'users.yml',
462
+ <<~YAML
463
+ version: 1
464
+ resources:
465
+ - provider: echo
466
+ type: user
467
+ name: alice
468
+ attributes:
469
+ email: alice@example.com
470
+ first_name: Alice
471
+ org: platform
472
+ roles:
473
+ - admin
474
+ - writer
475
+ YAML
476
+ )
477
+
478
+ run_command("#{executable} validate --config users.yml")
479
+
480
+ expect(last_command_started).to have_exit_status(0)
481
+ parsed = JSON.parse(last_command_started.stdout)
482
+ resource = parsed.fetch('options').fetch('desired_state').fetch('resources').first
483
+
484
+ expect(resource['attributes']).to eq(
485
+ 'email' => 'alice@example.com',
486
+ 'first_name' => 'Alice'
487
+ )
488
+ expect(resource['org']).to eq('platform')
489
+ expect(resource['roles']).to eq(%w[admin writer])
490
+ end
491
+
492
+ it 'includes canonical identity for desired_state resources' do
493
+ write_file(
494
+ 'users.yml',
495
+ <<~YAML
496
+ version: 1
497
+ resources:
498
+ - provider: echo
499
+ type: user
500
+ name: alice
501
+ YAML
502
+ )
503
+
504
+ run_command("#{executable} validate --config users.yml")
505
+
506
+ expect(last_command_started).to have_exit_status(0)
507
+ parsed = JSON.parse(last_command_started.stdout)
508
+ resource = parsed.fetch('options').fetch('desired_state').fetch('resources').first
509
+ expect(resource['identity']).to eq('echo.user.alice')
510
+ end
511
+
512
+ it 'returns state backend path for commands' do
513
+ run_command("#{executable} show")
514
+
515
+ expect(last_command_started).to have_exit_status(0)
516
+ parsed = JSON.parse(last_command_started.stdout)
517
+ expect(parsed['state_path']).to end_with('/.umgr/state.json')
518
+ end
519
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'spec_helper'
4
+
5
+ RSpec.describe 'umgr help', :cli do
6
+ it 'shows available commands' do
7
+ executable = File.expand_path('../../exe/umgr', __dir__)
8
+
9
+ run_command("#{executable} help")
10
+
11
+ expect(last_command_started).to have_output(/Commands:/)
12
+ expect(last_command_started).to have_exit_status(0)
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aruba/rspec'
4
+ require 'umgr'
5
+
6
+ RSpec.configure do |config|
7
+ config.include Aruba::Api
8
+ config.before(:each, :cli) do
9
+ setup_aruba
10
+ project_lib = File.expand_path('../../lib', __dir__)
11
+ set_environment_variable('RUBYLIB', project_lib)
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::DesiredStateEnricher do
4
+ it 'adds canonical identity for each resource in multi-resource desired state' do
5
+ desired_state = {
6
+ version: 1,
7
+ resources: [
8
+ { provider: 'echo', type: 'user', name: 'alice' },
9
+ { provider: 'github', type: 'user', name: 'bob' }
10
+ ]
11
+ }
12
+
13
+ result = described_class.call(desired_state)
14
+
15
+ expect(result[:resources]).to eq(
16
+ [
17
+ { provider: 'echo', type: 'user', name: 'alice', identity: 'echo.user.alice' },
18
+ { provider: 'github', type: 'user', name: 'bob', identity: 'github.user.bob' }
19
+ ]
20
+ )
21
+ end
22
+
23
+ it 'returns desired state unchanged when resources is empty' do
24
+ desired_state = { version: 1, resources: [] }
25
+
26
+ result = described_class.call(desired_state)
27
+
28
+ expect(result).to eq(desired_state)
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::DriftReportBuilder do
4
+ it 'reports drift when create/update/delete counts are present' do
5
+ report = described_class.call(create: 1, update: 2, delete: 1, no_change: 5)
6
+
7
+ expect(report).to eq(
8
+ detected: true,
9
+ change_count: 4,
10
+ actions: {
11
+ create: 1,
12
+ update: 2,
13
+ delete: 1
14
+ }
15
+ )
16
+ end
17
+
18
+ it 'reports no drift when all drift actions are zero' do
19
+ report = described_class.call(create: 0, update: 0, delete: 0, no_change: 3)
20
+
21
+ expect(report).to eq(
22
+ detected: false,
23
+ change_count: 0,
24
+ actions: {
25
+ create: 0,
26
+ update: 0,
27
+ delete: 0
28
+ }
29
+ )
30
+ end
31
+ end