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.
- checksums.yaml +7 -0
- data/Gemfile +14 -0
- data/README.md +126 -0
- data/Rakefile +3 -0
- data/exe/umgr +6 -0
- data/lib/umgr/apply_result_builder.rb +149 -0
- data/lib/umgr/change_set_builder.rb +49 -0
- data/lib/umgr/cli.rb +117 -0
- data/lib/umgr/config_validator.rb +93 -0
- data/lib/umgr/deep_symbolizer.rb +25 -0
- data/lib/umgr/desired_state_enricher.rb +15 -0
- data/lib/umgr/drift_report_builder.rb +19 -0
- data/lib/umgr/errors.rb +36 -0
- data/lib/umgr/import_result_builder.rb +79 -0
- data/lib/umgr/plan_result_builder.rb +72 -0
- data/lib/umgr/provider.rb +26 -0
- data/lib/umgr/provider_contract.rb +19 -0
- data/lib/umgr/provider_registry.rb +36 -0
- data/lib/umgr/provider_resource_validator.rb +27 -0
- data/lib/umgr/providers/echo_provider.rb +44 -0
- data/lib/umgr/providers/github/account_normalizer.rb +45 -0
- data/lib/umgr/providers/github/api_client.rb +111 -0
- data/lib/umgr/providers/github/apply_executor.rb +90 -0
- data/lib/umgr/providers/github/plan_builder.rb +91 -0
- data/lib/umgr/providers/github_provider.rb +125 -0
- data/lib/umgr/resource_identity.rb +11 -0
- data/lib/umgr/runner.rb +97 -0
- data/lib/umgr/runner_config.rb +52 -0
- data/lib/umgr/state_backend.rb +59 -0
- data/lib/umgr/state_template.rb +7 -0
- data/lib/umgr/unknown_provider_guard.rb +18 -0
- data/lib/umgr/version.rb +5 -0
- data/lib/umgr.rb +28 -0
- data/spec/apply_result_builder_spec.rb +142 -0
- data/spec/change_set_builder_spec.rb +28 -0
- data/spec/cli/commands_spec.rb +519 -0
- data/spec/cli/help_spec.rb +14 -0
- data/spec/cli/spec_helper.rb +13 -0
- data/spec/desired_state_enricher_spec.rb +30 -0
- data/spec/drift_report_builder_spec.rb +31 -0
- data/spec/import_result_builder_spec.rb +124 -0
- data/spec/plan_result_builder_spec.rb +101 -0
- data/spec/provider_contract_spec.rb +32 -0
- data/spec/provider_registry_spec.rb +42 -0
- data/spec/provider_resource_validator_spec.rb +39 -0
- data/spec/provider_spec.rb +25 -0
- data/spec/providers/echo_provider_spec.rb +55 -0
- data/spec/providers/github_account_normalizer_spec.rb +31 -0
- data/spec/providers/github_api_client_spec.rb +101 -0
- data/spec/providers/github_plan_builder_spec.rb +47 -0
- data/spec/providers/github_provider_spec.rb +268 -0
- data/spec/resource_identity_spec.rb +13 -0
- data/spec/runner_spec.rb +552 -0
- data/spec/spec_helper.rb +12 -0
- data/spec/state_backend_spec.rb +52 -0
- data/spec/support/simple_cov.rb +9 -0
- data/spec/support/webmock.rb +5 -0
- data/umgr.gemspec +34 -0
- metadata +141 -0
data/spec/runner_spec.rb
ADDED
|
@@ -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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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
|
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
|