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,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ RSpec.describe Umgr::Providers::GithubProvider do
6
+ subject(:provider) { described_class.new }
7
+
8
+ let(:resource) do
9
+ {
10
+ provider: 'github',
11
+ type: 'user',
12
+ name: 'alice',
13
+ org: 'acme',
14
+ token_env: 'GITHUB_TOKEN',
15
+ teams: %w[platform admins]
16
+ }
17
+ end
18
+
19
+ it 'validates provider-specific configuration' do
20
+ result = provider.validate(resource: resource)
21
+
22
+ expect(result[:ok]).to be(true)
23
+ expect(result[:provider]).to eq('github')
24
+ expect(result[:resource]).to eq(resource)
25
+ end
26
+
27
+ it 'requires org' do
28
+ invalid = resource.except(:org)
29
+
30
+ expect { provider.validate(resource: invalid) }
31
+ .to raise_error(Umgr::Errors::ValidationError, /requires non-empty `org`/)
32
+ end
33
+
34
+ it 'requires token or token_env' do
35
+ invalid = resource.except(:token, :token_env)
36
+
37
+ expect { provider.validate(resource: invalid) }
38
+ .to raise_error(Umgr::Errors::ValidationError, /requires `token` or `token_env`/)
39
+ end
40
+
41
+ it 'validates teams as an array of non-empty strings' do
42
+ invalid = resource.merge(teams: ['platform', ''])
43
+
44
+ expect { provider.validate(resource: invalid) }
45
+ .to raise_error(Umgr::Errors::ValidationError, /`teams` must be an array/)
46
+ end
47
+
48
+ it 'imports current users and team memberships into canonical resources' do
49
+ stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/members(\?.*)?\z}).to_return(
50
+ status: 200,
51
+ body: JSON.generate(
52
+ [
53
+ {
54
+ id: 10,
55
+ login: 'alice',
56
+ avatar_url: 'https://avatars.example/alice',
57
+ html_url: 'https://github.com/alice',
58
+ type: 'User'
59
+ }
60
+ ]
61
+ ),
62
+ headers: { 'Content-Type' => 'application/json' }
63
+ )
64
+ stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/teams(\?.*)?\z}).to_return(
65
+ status: 200,
66
+ body: JSON.generate([{ id: 101, slug: 'platform' }, { id: 102, slug: 'admins' }]),
67
+ headers: { 'Content-Type' => 'application/json' }
68
+ )
69
+ stub_request(:get, %r{\Ahttps://api\.github\.com/teams/101/members(\?.*)?\z}).to_return(
70
+ status: 200,
71
+ body: JSON.generate([{ login: 'alice' }]),
72
+ headers: { 'Content-Type' => 'application/json' }
73
+ )
74
+ stub_request(:get, %r{\Ahttps://api\.github\.com/teams/102/members(\?.*)?\z}).to_return(
75
+ status: 200,
76
+ body: JSON.generate([{ login: 'alice' }]),
77
+ headers: { 'Content-Type' => 'application/json' }
78
+ )
79
+
80
+ result = provider.current(resource: resource.merge(token: 'secret'))
81
+
82
+ expect(result[:ok]).to be(true)
83
+ expect(result[:provider]).to eq('github')
84
+ expect(result[:org]).to eq('acme')
85
+ expect(result[:count]).to eq(1)
86
+ expect(result[:imported_accounts]).to eq(
87
+ [
88
+ {
89
+ provider: 'github',
90
+ type: 'user',
91
+ name: 'alice',
92
+ identity: 'github.user.alice',
93
+ org: 'acme',
94
+ teams: %w[admins platform],
95
+ attributes: {
96
+ id: 10,
97
+ login: 'alice',
98
+ avatar_url: 'https://avatars.example/alice',
99
+ html_url: 'https://github.com/alice',
100
+ type: 'User'
101
+ }
102
+ }
103
+ ]
104
+ )
105
+ end
106
+
107
+ it 'raises api error when github api fails' do
108
+ stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/members(\?.*)?\z}).to_return(
109
+ status: 401,
110
+ body: JSON.generate(message: 'Bad credentials'),
111
+ headers: { 'Content-Type' => 'application/json' }
112
+ )
113
+
114
+ expect { provider.current(resource: resource.merge(token: 'secret')) }
115
+ .to raise_error(Umgr::Errors::ApiError, /GitHub API request failed/)
116
+ end
117
+
118
+ it 'uses token_env when token is not provided' do
119
+ allow(ENV).to receive(:fetch).and_call_original
120
+ allow(ENV).to receive(:fetch).with('GITHUB_TOKEN', nil).and_return('from-env')
121
+ stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/members(\?.*)?\z}).to_return(
122
+ status: 200,
123
+ body: JSON.generate([]),
124
+ headers: { 'Content-Type' => 'application/json' }
125
+ )
126
+ stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/teams(\?.*)?\z}).to_return(
127
+ status: 200,
128
+ body: JSON.generate([]),
129
+ headers: { 'Content-Type' => 'application/json' }
130
+ )
131
+
132
+ result = provider.current(resource: resource)
133
+
134
+ expect(result[:ok]).to be(true)
135
+ expect(result[:count]).to eq(0)
136
+ end
137
+
138
+ it 'raises when token_env is configured but not present in environment' do
139
+ allow(ENV).to receive(:fetch).and_call_original
140
+ allow(ENV).to receive(:fetch).with('GITHUB_TOKEN', nil).and_return(nil)
141
+
142
+ expect { provider.current(resource: resource) }
143
+ .to raise_error(Umgr::Errors::ValidationError, /token_env.*is not set/)
144
+ end
145
+
146
+ it 'plans invite and team additions when user is missing from current state' do
147
+ result = provider.plan(
148
+ desired: { provider: 'github', type: 'user', name: 'alice', teams: %w[admins platform] },
149
+ current: nil
150
+ )
151
+
152
+ expect(result).to include(
153
+ ok: true,
154
+ provider: 'github',
155
+ status: 'planned',
156
+ organization_action: 'invite'
157
+ )
158
+ expect(result.fetch(:team_actions)).to eq(add: %w[admins platform], remove: [], unchanged: [])
159
+ expect(result.fetch(:operations)).to eq(
160
+ [
161
+ { type: 'invite_org_member', login: 'alice' },
162
+ { type: 'add_team_membership', login: 'alice', team: 'admins' },
163
+ { type: 'add_team_membership', login: 'alice', team: 'platform' }
164
+ ]
165
+ )
166
+ end
167
+
168
+ it 'plans team membership add and remove operations' do
169
+ result = provider.plan(
170
+ desired: { provider: 'github', type: 'user', name: 'alice', teams: %w[admins security] },
171
+ current: { provider: 'github', type: 'user', name: 'alice', teams: %w[admins platform] }
172
+ )
173
+
174
+ expect(result).to include(
175
+ ok: true,
176
+ provider: 'github',
177
+ status: 'planned',
178
+ organization_action: 'keep'
179
+ )
180
+ expect(result.fetch(:team_actions)).to eq(add: ['security'], remove: ['platform'], unchanged: ['admins'])
181
+ expect(result.fetch(:operations)).to eq(
182
+ [
183
+ { type: 'add_team_membership', login: 'alice', team: 'security' },
184
+ { type: 'remove_team_membership', login: 'alice', team: 'platform' }
185
+ ]
186
+ )
187
+ end
188
+
189
+ it 'plans no_change when desired and current github membership match' do
190
+ result = provider.plan(
191
+ desired: { provider: 'github', type: 'user', name: 'alice', teams: %w[admins platform] },
192
+ current: { provider: 'github', type: 'user', name: 'alice', teams: %w[platform admins] }
193
+ )
194
+
195
+ expect(result).to include(
196
+ ok: true,
197
+ provider: 'github',
198
+ status: 'planned',
199
+ organization_action: 'keep'
200
+ )
201
+ expect(result.fetch(:team_actions)).to eq(add: [], remove: [], unchanged: %w[admins platform])
202
+ expect(result.fetch(:operations)).to eq([{ type: 'no_change', login: 'alice' }])
203
+ end
204
+
205
+ it 'plans org membership removal when user is removed from desired state' do
206
+ result = provider.plan(
207
+ desired: nil,
208
+ current: { provider: 'github', type: 'user', name: 'alice', teams: %w[admins platform] }
209
+ )
210
+
211
+ expect(result).to include(
212
+ ok: true,
213
+ provider: 'github',
214
+ status: 'planned',
215
+ organization_action: 'remove'
216
+ )
217
+ expect(result.fetch(:operations)).to eq([{ type: 'remove_org_member', login: 'alice' }])
218
+ end
219
+
220
+ it 'applies invite and team membership operations from provider plan' do
221
+ stub_request(:put, 'https://api.github.com/orgs/acme/memberships/alice')
222
+ .to_return(status: 200, body: JSON.generate(state: 'pending'), headers: { 'Content-Type' => 'application/json' })
223
+ stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/teams(\?.*)?\z}).to_return(
224
+ status: 200,
225
+ body: JSON.generate([{ id: 101, slug: 'platform' }]),
226
+ headers: { 'Content-Type' => 'application/json' }
227
+ )
228
+ stub_request(:put, 'https://api.github.com/teams/101/memberships/alice').to_return(
229
+ status: 200,
230
+ body: JSON.generate(state: 'active'),
231
+ headers: { 'Content-Type' => 'application/json' }
232
+ )
233
+
234
+ changeset = {
235
+ action: 'create',
236
+ desired: {
237
+ provider: 'github',
238
+ type: 'user',
239
+ name: 'alice',
240
+ org: 'acme',
241
+ token: 'secret'
242
+ },
243
+ current: nil,
244
+ provider_plan: {
245
+ operations: [
246
+ { type: 'invite_org_member', login: 'alice' },
247
+ { type: 'add_team_membership', login: 'alice', team: 'platform' }
248
+ ]
249
+ }
250
+ }
251
+
252
+ result = provider.apply(changeset: changeset)
253
+
254
+ expect(result).to include(ok: true, provider: 'github', status: 'applied')
255
+ expect(result.fetch(:executed_operations)).to eq(changeset[:provider_plan][:operations])
256
+ end
257
+
258
+ it 'raises for unsupported apply operations' do
259
+ expect do
260
+ provider.apply(
261
+ changeset: {
262
+ desired: { provider: 'github', type: 'user', name: 'alice', org: 'acme', token: 'secret' },
263
+ provider_plan: { operations: [{ type: 'mystery', login: 'alice' }] }
264
+ }
265
+ )
266
+ end.to raise_error(Umgr::Errors::InternalError, /Unsupported GitHub apply operation/)
267
+ end
268
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Umgr::ResourceIdentity do
4
+ it 'builds canonical identity as provider.type.name' do
5
+ resource = {
6
+ provider: 'github',
7
+ type: 'user',
8
+ name: 'alice'
9
+ }
10
+
11
+ expect(described_class.call(resource)).to eq('github.user.alice')
12
+ end
13
+ end