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,552 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+
5
+ RSpec.describe Umgr::Runner do
6
+ subject(:runner) { described_class.new }
7
+
8
+ it 'is instantiable' do
9
+ expect(runner).to be_a(described_class)
10
+ end
11
+
12
+ it 'returns ok for #ping' do
13
+ expect(runner.ping).to eq(:ok)
14
+ end
15
+
16
+ it 'dispatches not_implemented for remaining placeholder actions' do
17
+ Dir.mktmpdir do |tmp_dir|
18
+ File.write(File.join(tmp_dir, 'users.yml'), "version: 1\nresources: []\n")
19
+
20
+ %i[validate].each do |action|
21
+ result = Dir.chdir(tmp_dir) { runner.dispatch(action, config: 'users.yml') }
22
+
23
+ expect(result[:action]).to eq(action.to_s)
24
+ expect(result[:status]).to eq('not_implemented')
25
+ expect(result[:ok]).to be(false)
26
+ expect(result[:state_path]).to end_with('/.umgr/state.json')
27
+ end
28
+ end
29
+ end
30
+
31
+ it 'imports current users from providers and persists imported state' do
32
+ Dir.mktmpdir do |tmp_dir|
33
+ backend = Umgr::StateBackend.new(root_dir: tmp_dir)
34
+ local_runner = described_class.new(state_backend: backend)
35
+ File.write(
36
+ File.join(tmp_dir, 'users.yml'),
37
+ <<~YAML
38
+ version: 1
39
+ resources:
40
+ - provider: echo
41
+ type: user
42
+ name: alice
43
+ attributes:
44
+ team: platform
45
+ YAML
46
+ )
47
+
48
+ result = Dir.chdir(tmp_dir) { local_runner.dispatch(:import, config: 'users.yml') }
49
+
50
+ expect(result[:ok]).to be(true)
51
+ expect(result[:status]).to eq('imported')
52
+ expect(result[:imported_count]).to eq(1)
53
+ expect(result.fetch(:state).fetch(:resources)).to eq(
54
+ [
55
+ {
56
+ provider: 'echo',
57
+ type: 'user',
58
+ name: 'alice',
59
+ attributes: { team: 'platform' },
60
+ identity: 'echo.user.alice'
61
+ }
62
+ ]
63
+ )
64
+ expect(backend.read).to eq(result.fetch(:state))
65
+ end
66
+ end
67
+
68
+ it 'supports end-to-end workflow from init to show' do
69
+ Dir.mktmpdir do |tmp_dir|
70
+ backend = Umgr::StateBackend.new(root_dir: tmp_dir)
71
+ local_runner = described_class.new(state_backend: backend)
72
+ File.write(
73
+ File.join(tmp_dir, 'users.yml'),
74
+ <<~YAML
75
+ version: 1
76
+ resources:
77
+ - provider: echo
78
+ type: user
79
+ name: alice
80
+ attributes:
81
+ team: platform
82
+ YAML
83
+ )
84
+
85
+ init_result = Dir.chdir(tmp_dir) { local_runner.dispatch(:init) }
86
+ validate_result = Dir.chdir(tmp_dir) { local_runner.dispatch(:validate, config: 'users.yml') }
87
+ plan_result = Dir.chdir(tmp_dir) { local_runner.dispatch(:plan, config: 'users.yml') }
88
+ apply_result = Dir.chdir(tmp_dir) { local_runner.dispatch(:apply, config: 'users.yml') }
89
+ show_result = Dir.chdir(tmp_dir) { local_runner.dispatch(:show) }
90
+
91
+ expect(init_result[:status]).to eq('initialized')
92
+ expect(validate_result[:status]).to eq('not_implemented')
93
+ expect(plan_result[:status]).to eq('planned')
94
+ expect(plan_result.dig(:changeset, :summary)).to eq(create: 1, update: 0, delete: 0, no_change: 0)
95
+ expect(apply_result[:status]).to eq('applied')
96
+ expect(show_result[:status]).to eq('ok')
97
+ expect(show_result[:state]).to eq(apply_result[:state])
98
+ expect(backend.read).to eq(show_result[:state])
99
+ end
100
+ end
101
+
102
+ it 'applies desired state and persists resulting state' do
103
+ Dir.mktmpdir do |tmp_dir|
104
+ backend = Umgr::StateBackend.new(root_dir: tmp_dir)
105
+ backend.write(
106
+ version: 1,
107
+ resources: [{ provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'infra' } }]
108
+ )
109
+ local_runner = described_class.new(state_backend: backend)
110
+ File.write(
111
+ File.join(tmp_dir, 'users.yml'),
112
+ <<~YAML
113
+ version: 1
114
+ resources:
115
+ - provider: echo
116
+ type: user
117
+ name: alice
118
+ attributes:
119
+ team: platform
120
+ - provider: echo
121
+ type: user
122
+ name: carla
123
+ YAML
124
+ )
125
+
126
+ result = Dir.chdir(tmp_dir) { local_runner.dispatch(:apply, config: 'users.yml') }
127
+
128
+ expect(result[:ok]).to be(true)
129
+ expect(result[:status]).to eq('applied')
130
+ expect(result.fetch(:changeset).fetch(:summary)).to eq(create: 1, update: 1, delete: 0, no_change: 0)
131
+ expect(result.fetch(:apply_results).map { |item| item[:status] }).to eq(%w[applied applied])
132
+ expect(result.fetch(:idempotency)).to eq(
133
+ checked: true,
134
+ stable: true,
135
+ summary: { create: 0, update: 0, delete: 0, no_change: 2 }
136
+ )
137
+ expect(backend.read).to eq(result.fetch(:state))
138
+ end
139
+ end
140
+
141
+ it 'keeps existing state unchanged when apply fails' do
142
+ failing_provider = Class.new do
143
+ def validate(resource:)
144
+ { ok: true, resource: resource }
145
+ end
146
+
147
+ def current(resource:)
148
+ { ok: true, account: resource }
149
+ end
150
+
151
+ def plan(desired:, current:)
152
+ { ok: true, provider: 'failing', status: desired == current ? 'no_change' : 'planned' }
153
+ end
154
+
155
+ def apply(changeset:)
156
+ { ok: false, provider: 'failing', error: "cannot apply #{changeset.fetch(:identity)}" }
157
+ end
158
+ end.new
159
+
160
+ Dir.mktmpdir do |tmp_dir|
161
+ backend = Umgr::StateBackend.new(root_dir: tmp_dir)
162
+ backend.write(
163
+ version: 1,
164
+ resources: [{ provider: 'failing', type: 'user', name: 'alice', attributes: { team: 'infra' } }]
165
+ )
166
+ before_apply_state = backend.read
167
+ registry = Umgr::ProviderRegistry.new
168
+ registry.register('failing', failing_provider)
169
+ local_runner = described_class.new(state_backend: backend, provider_registry: registry)
170
+
171
+ File.write(
172
+ File.join(tmp_dir, 'users.yml'),
173
+ <<~YAML
174
+ version: 1
175
+ resources:
176
+ - provider: failing
177
+ type: user
178
+ name: alice
179
+ attributes:
180
+ team: platform
181
+ YAML
182
+ )
183
+
184
+ expect do
185
+ Dir.chdir(tmp_dir) { local_runner.dispatch(:apply, config: 'users.yml') }
186
+ end.to raise_error(Umgr::Errors::InternalError, /Provider apply failed/)
187
+ expect(backend.read).to eq(before_apply_state)
188
+ end
189
+ end
190
+
191
+ it 'is idempotent when plan is run after apply' do
192
+ Dir.mktmpdir do |tmp_dir|
193
+ backend = Umgr::StateBackend.new(root_dir: tmp_dir)
194
+ backend.write(version: 1, resources: [])
195
+ local_runner = described_class.new(state_backend: backend)
196
+ File.write(
197
+ File.join(tmp_dir, 'users.yml'),
198
+ <<~YAML
199
+ version: 1
200
+ resources:
201
+ - provider: echo
202
+ type: user
203
+ name: alice
204
+ attributes:
205
+ team: platform
206
+ YAML
207
+ )
208
+
209
+ Dir.chdir(tmp_dir) { local_runner.dispatch(:apply, config: 'users.yml') }
210
+ plan_result = Dir.chdir(tmp_dir) { local_runner.dispatch(:plan, config: 'users.yml') }
211
+
212
+ expect(plan_result[:status]).to eq('planned')
213
+ expect(plan_result.fetch(:changeset).fetch(:summary)).to eq(create: 0, update: 0, delete: 0, no_change: 1)
214
+ expect(plan_result.fetch(:drift)).to eq(
215
+ detected: false,
216
+ change_count: 0,
217
+ actions: { create: 0, update: 0, delete: 0 }
218
+ )
219
+ end
220
+ end
221
+
222
+ it 'returns a planned changeset for desired vs current state' do
223
+ Dir.mktmpdir do |tmp_dir|
224
+ backend = Umgr::StateBackend.new(root_dir: tmp_dir)
225
+ backend.write(
226
+ version: 1,
227
+ resources: [
228
+ { provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'infra' } },
229
+ { provider: 'echo', type: 'user', name: 'bob' }
230
+ ]
231
+ )
232
+ local_runner = described_class.new(state_backend: backend)
233
+ File.write(
234
+ File.join(tmp_dir, 'users.yml'),
235
+ <<~YAML
236
+ version: 1
237
+ resources:
238
+ - provider: echo
239
+ type: user
240
+ name: alice
241
+ attributes:
242
+ team: platform
243
+ - provider: echo
244
+ type: user
245
+ name: carla
246
+ YAML
247
+ )
248
+
249
+ result = Dir.chdir(tmp_dir) { local_runner.dispatch(:plan, config: 'users.yml') }
250
+ changes = result.fetch(:changeset).fetch(:changes)
251
+
252
+ expect(result[:ok]).to be(true)
253
+ expect(result[:status]).to eq('planned')
254
+ expect(changes.map { |change| [change[:identity], change[:action]] }).to eq(
255
+ [
256
+ ['echo.user.alice', 'update'],
257
+ ['echo.user.bob', 'delete'],
258
+ ['echo.user.carla', 'create']
259
+ ]
260
+ )
261
+ expect(result.fetch(:changeset).fetch(:summary)).to eq(create: 1, update: 1, delete: 1, no_change: 0)
262
+ expect(result.fetch(:drift)).to eq(
263
+ detected: true,
264
+ change_count: 3,
265
+ actions: { create: 1, update: 1, delete: 1 }
266
+ )
267
+ end
268
+ end
269
+
270
+ it 'includes github provider-specific plan details in structured plan changes' do
271
+ Dir.mktmpdir do |tmp_dir|
272
+ backend = Umgr::StateBackend.new(root_dir: tmp_dir)
273
+ backend.write(
274
+ version: 1,
275
+ resources: [
276
+ { provider: 'github', type: 'user', name: 'alice', org: 'acme', teams: %w[admins platform] }
277
+ ]
278
+ )
279
+ local_runner = described_class.new(state_backend: backend)
280
+ File.write(
281
+ File.join(tmp_dir, 'users.yml'),
282
+ <<~YAML
283
+ version: 1
284
+ resources:
285
+ - provider: github
286
+ type: user
287
+ name: alice
288
+ org: acme
289
+ token: secret
290
+ teams:
291
+ - admins
292
+ - security
293
+ YAML
294
+ )
295
+
296
+ result = Dir.chdir(tmp_dir) { local_runner.dispatch(:plan, config: 'users.yml') }
297
+ change = result.fetch(:changeset).fetch(:changes).find { |item| item[:identity] == 'github.user.alice' }
298
+
299
+ expect(change[:action]).to eq('update')
300
+ expect(change.fetch(:provider_plan)).to include(
301
+ provider: 'github',
302
+ organization_action: 'keep',
303
+ status: 'planned'
304
+ )
305
+ expect(change.fetch(:provider_plan).fetch(:team_actions)).to eq(
306
+ add: ['security'],
307
+ remove: ['platform'],
308
+ unchanged: ['admins']
309
+ )
310
+ expect(change.fetch(:provider_plan).fetch(:operations)).to eq(
311
+ [
312
+ { type: 'add_team_membership', login: 'alice', team: 'security' },
313
+ { type: 'remove_team_membership', login: 'alice', team: 'platform' }
314
+ ]
315
+ )
316
+ end
317
+ end
318
+
319
+ it 'returns not_initialized when show is called without state' do
320
+ Dir.mktmpdir do |tmp_dir|
321
+ backend = Umgr::StateBackend.new(root_dir: tmp_dir)
322
+ local_runner = described_class.new(state_backend: backend)
323
+
324
+ result = local_runner.dispatch(:show)
325
+
326
+ expect(result[:ok]).to be(true)
327
+ expect(result[:status]).to eq('not_initialized')
328
+ expect(result[:state]).to be_nil
329
+ expect(result[:state_path]).to eq(File.join(tmp_dir, '.umgr', 'state.json'))
330
+ end
331
+ end
332
+
333
+ it 'returns current state when show is called with initialized state' do
334
+ Dir.mktmpdir do |tmp_dir|
335
+ backend = Umgr::StateBackend.new(root_dir: tmp_dir)
336
+ backend.write(version: 1, resources: [{ provider: 'github', type: 'user', name: 'alice' }])
337
+ local_runner = described_class.new(state_backend: backend)
338
+
339
+ result = local_runner.dispatch(:show)
340
+
341
+ expect(result[:ok]).to be(true)
342
+ expect(result[:status]).to eq('ok')
343
+ expect(result[:state]).to eq(version: 1, resources: [{ provider: 'github', type: 'user', name: 'alice' }])
344
+ end
345
+ end
346
+
347
+ it 'initializes state on init' do
348
+ Dir.mktmpdir do |tmp_dir|
349
+ backend = Umgr::StateBackend.new(root_dir: tmp_dir)
350
+ local_runner = described_class.new(state_backend: backend)
351
+
352
+ result = local_runner.dispatch(:init)
353
+
354
+ expect(result[:ok]).to be(true)
355
+ expect(result[:status]).to eq('initialized')
356
+ expect(result[:state]).to eq(version: 1, resources: [])
357
+ expect(File.file?(File.join(tmp_dir, '.umgr', 'state.json'))).to be(true)
358
+ expect(backend.read).to eq(version: 1, resources: [])
359
+ end
360
+ end
361
+
362
+ it 'returns already_initialized when state exists' do
363
+ Dir.mktmpdir do |tmp_dir|
364
+ backend = Umgr::StateBackend.new(root_dir: tmp_dir)
365
+ backend.write(version: 1, resources: [{ provider: 'github', type: 'user', name: 'alice' }])
366
+ local_runner = described_class.new(state_backend: backend)
367
+
368
+ result = local_runner.dispatch(:init)
369
+
370
+ expect(result[:ok]).to be(true)
371
+ expect(result[:status]).to eq('already_initialized')
372
+ expect(result[:state]).to eq(version: 1, resources: [{ provider: 'github', type: 'user', name: 'alice' }])
373
+ end
374
+ end
375
+
376
+ it 'passes options to dispatched methods' do
377
+ Dir.mktmpdir do |tmp_dir|
378
+ File.write(File.join(tmp_dir, 'users.yml'), "version: 1\nresources: []\n")
379
+
380
+ result = Dir.chdir(tmp_dir) { runner.dispatch(:validate, config: 'users.yml') }
381
+
382
+ expect(result[:options][:config]).to end_with('/users.yml')
383
+ end
384
+ end
385
+
386
+ it 'auto-discovers config when not explicitly provided' do
387
+ Dir.mktmpdir do |tmp_dir|
388
+ config_path = File.join(tmp_dir, 'umgr.yml')
389
+ File.write(config_path, "version: 1\nresources: []\n")
390
+
391
+ result = Dir.chdir(tmp_dir) { runner.dispatch(:validate) }
392
+
393
+ expect(result[:options][:config]).to end_with('/umgr.yml')
394
+ end
395
+ end
396
+
397
+ it 'uses explicit config path over auto-discovery candidates' do
398
+ Dir.mktmpdir do |tmp_dir|
399
+ File.write(File.join(tmp_dir, 'umgr.yml'), "version: 1\nresources: []\n")
400
+ explicit_path = File.join(tmp_dir, 'custom.json')
401
+ File.write(explicit_path, "{\"version\":1,\"resources\":[]}\n")
402
+
403
+ result = Dir.chdir(tmp_dir) { runner.dispatch(:validate, config: 'custom.json') }
404
+
405
+ expect(result[:options][:config]).to end_with('/custom.json')
406
+ end
407
+ end
408
+
409
+ it 'raises validation error when required config is missing' do
410
+ expect { runner.dispatch(:validate) }
411
+ .to raise_error(Umgr::Errors::ValidationError, /config/)
412
+ end
413
+
414
+ it 'raises validation error when explicit config is missing' do
415
+ expect { runner.dispatch(:validate, config: 'does-not-exist.yml') }
416
+ .to raise_error(Umgr::Errors::ValidationError, /Config file not found/)
417
+ end
418
+
419
+ it 'raises validation error when version type is invalid' do
420
+ Dir.mktmpdir do |tmp_dir|
421
+ File.write(File.join(tmp_dir, 'invalid.yml'), "version: banana\nresources: []\n")
422
+
423
+ expect do
424
+ Dir.chdir(tmp_dir) { runner.dispatch(:validate, config: 'invalid.yml') }
425
+ end.to raise_error(Umgr::Errors::ValidationError, /`version` must be a positive integer/)
426
+ end
427
+ end
428
+
429
+ it 'raises validation error when top-level required keys are missing' do
430
+ Dir.mktmpdir do |tmp_dir|
431
+ File.write(File.join(tmp_dir, 'invalid.yml'), "resources: []\n")
432
+
433
+ expect do
434
+ Dir.chdir(tmp_dir) { runner.dispatch(:validate, config: 'invalid.yml') }
435
+ end.to raise_error(Umgr::Errors::ValidationError, /Missing required key `version`/)
436
+ end
437
+ end
438
+
439
+ it 'raises validation error when resource required fields are missing' do
440
+ Dir.mktmpdir do |tmp_dir|
441
+ File.write(File.join(tmp_dir, 'invalid.yml'), "version: 1\nresources:\n - provider: github\n")
442
+
443
+ expect do
444
+ Dir.chdir(tmp_dir) { runner.dispatch(:validate, config: 'invalid.yml') }
445
+ end.to raise_error(Umgr::Errors::ValidationError, /missing required string field `type`/)
446
+ end
447
+ end
448
+
449
+ it 'preserves attributes and provider-specific resource fields in desired_state' do
450
+ Dir.mktmpdir do |tmp_dir|
451
+ File.write(
452
+ File.join(tmp_dir, 'users.yml'),
453
+ <<~YAML
454
+ version: 1
455
+ resources:
456
+ - provider: echo
457
+ type: user
458
+ name: alice
459
+ attributes:
460
+ email: alice@example.com
461
+ first_name: Alice
462
+ org: platform
463
+ roles:
464
+ - admin
465
+ - writer
466
+ YAML
467
+ )
468
+
469
+ result = Dir.chdir(tmp_dir) { runner.dispatch(:validate, config: 'users.yml') }
470
+ resource = result[:options][:desired_state][:resources].first
471
+
472
+ expect(resource[:attributes]).to eq(
473
+ email: 'alice@example.com',
474
+ first_name: 'Alice'
475
+ )
476
+ expect(resource[:org]).to eq('platform')
477
+ expect(resource[:roles]).to eq(%w[admin writer])
478
+ end
479
+ end
480
+
481
+ it 'adds canonical identity to desired_state resources' do
482
+ Dir.mktmpdir do |tmp_dir|
483
+ File.write(
484
+ File.join(tmp_dir, 'users.yml'),
485
+ <<~YAML
486
+ version: 1
487
+ resources:
488
+ - provider: echo
489
+ type: user
490
+ name: alice
491
+ YAML
492
+ )
493
+
494
+ result = Dir.chdir(tmp_dir) { runner.dispatch(:validate, config: 'users.yml') }
495
+ resource = result.fetch(:options).fetch(:desired_state).fetch(:resources).first
496
+
497
+ expect(resource[:identity]).to eq('echo.user.alice')
498
+ end
499
+ end
500
+
501
+ it 'raises validation error when provider is unknown in config-backed actions' do
502
+ Dir.mktmpdir do |tmp_dir|
503
+ File.write(
504
+ File.join(tmp_dir, 'users.yml'),
505
+ <<~YAML
506
+ version: 1
507
+ resources:
508
+ - provider: atlassian
509
+ type: user
510
+ name: alice
511
+ YAML
512
+ )
513
+
514
+ %i[validate plan apply import].each do |action|
515
+ expect do
516
+ Dir.chdir(tmp_dir) { runner.dispatch(action, config: 'users.yml') }
517
+ end.to raise_error(Umgr::Errors::ValidationError, /Unknown provider\(s\) for #{action}: atlassian/)
518
+ end
519
+ end
520
+ end
521
+
522
+ it 'raises validation error for invalid github provider configuration' do
523
+ Dir.mktmpdir do |tmp_dir|
524
+ File.write(
525
+ File.join(tmp_dir, 'users.yml'),
526
+ <<~YAML
527
+ version: 1
528
+ resources:
529
+ - provider: github
530
+ type: user
531
+ name: alice
532
+ token_env: GITHUB_TOKEN
533
+ YAML
534
+ )
535
+
536
+ expect do
537
+ Dir.chdir(tmp_dir) { runner.dispatch(:validate, config: 'users.yml') }
538
+ end.to raise_error(Umgr::Errors::ValidationError, /requires non-empty `org`/)
539
+ end
540
+ end
541
+
542
+ it 'keeps action methods private' do
543
+ described_class::ACTIONS.each do |action|
544
+ expect(runner).not_to respond_to(action)
545
+ end
546
+ end
547
+
548
+ it 'raises for unsupported actions' do
549
+ expect { runner.dispatch(:unknown) }
550
+ .to raise_error(Umgr::Errors::UnknownActionError, /Unknown action/)
551
+ end
552
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.expand_path('support/**/*.rb', __dir__)].each { |file| require file }
4
+
5
+ require 'umgr'
6
+
7
+ RSpec.configure do |config|
8
+ config.disable_monkey_patching!
9
+ config.expect_with :rspec do |c|
10
+ c.syntax = :expect
11
+ end
12
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+
5
+ RSpec.describe Umgr::StateBackend do
6
+ it 'resolves state path under .umgr/state.json by default' do
7
+ Dir.mktmpdir do |tmp_dir|
8
+ backend = described_class.new(root_dir: tmp_dir)
9
+
10
+ expect(backend.path).to eq(File.join(tmp_dir, '.umgr', 'state.json'))
11
+ end
12
+ end
13
+
14
+ it 'writes and reads state data' do
15
+ Dir.mktmpdir do |tmp_dir|
16
+ backend = described_class.new(root_dir: tmp_dir)
17
+ payload = {
18
+ version: 1,
19
+ resources: [
20
+ { provider: 'github', type: 'user', name: 'alice' }
21
+ ]
22
+ }
23
+
24
+ written_path = backend.write(payload)
25
+
26
+ expect(written_path).to eq(backend.path)
27
+ expect(backend.read).to eq(payload)
28
+ end
29
+ end
30
+
31
+ it 'removes temporary files after atomic write' do
32
+ Dir.mktmpdir do |tmp_dir|
33
+ backend = described_class.new(root_dir: tmp_dir)
34
+ backend.write(version: 1, resources: [])
35
+
36
+ temp_files = Dir.glob(File.join(tmp_dir, '.umgr', 'state.json.tmp-*'))
37
+ expect(temp_files).to eq([])
38
+ end
39
+ end
40
+
41
+ it 'deletes persisted state file' do
42
+ Dir.mktmpdir do |tmp_dir|
43
+ backend = described_class.new(root_dir: tmp_dir)
44
+ backend.write(version: 1, resources: [])
45
+
46
+ backend.delete
47
+
48
+ expect(File.file?(backend.path)).to be(false)
49
+ expect(backend.read).to be_nil
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'simplecov'
4
+
5
+ SimpleCov.command_name(ENV.fetch('SIMPLECOV_COMMAND_NAME', 'rspec'))
6
+ SimpleCov.start do
7
+ enable_coverage :branch
8
+ add_filter '/spec/'
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'webmock/rspec'
4
+
5
+ WebMock.disable_net_connect!(allow_localhost: true)
data/umgr.gemspec ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/umgr/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'umgr'
7
+ spec.version = Umgr::VERSION
8
+ spec.authors = ['Basavanagowda Kanur']
9
+ spec.email = ['basavanagowda@gmail.com']
10
+
11
+ spec.summary = 'Declarative account lifecycle management'
12
+ spec.description = 'Manage account state across providers via CLI and API'
13
+ spec.homepage = 'https://github.com/gowda/umgr'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = Gem::Requirement.new('>= 3.4.0')
16
+ spec.metadata['rubygems_mfa_required'] = 'true'
17
+
18
+ spec.files = Dir.glob(%w[
19
+ Gemfile
20
+ Rakefile
21
+ README.md
22
+ exe/umgr
23
+ lib/**/*.rb
24
+ spec/**/*.rb
25
+ umgr.gemspec
26
+ ])
27
+ spec.bindir = 'exe'
28
+ spec.executables = ['umgr']
29
+ spec.require_paths = ['lib']
30
+
31
+ spec.add_dependency 'faraday-retry', '~> 2.3'
32
+ spec.add_dependency 'octokit', '~> 9.2'
33
+ spec.add_dependency 'thor', '~> 1.3'
34
+ end