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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Umgr::ImportResultBuilder do
|
|
4
|
+
let(:state_backend) { instance_double(Umgr::StateBackend, path: '/tmp/.umgr/state.json') }
|
|
5
|
+
let(:provider_registry) { instance_double(Umgr::ProviderRegistry) }
|
|
6
|
+
let(:provider) { instance_double(Umgr::Providers::EchoProvider) }
|
|
7
|
+
let(:desired_state) do
|
|
8
|
+
{
|
|
9
|
+
version: 1,
|
|
10
|
+
resources: [
|
|
11
|
+
{ provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'platform' } }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
let(:options) { { config: '/tmp/users.yml', desired_state: desired_state } }
|
|
16
|
+
|
|
17
|
+
it 'imports provider current state and persists deduplicated resources' do
|
|
18
|
+
allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
|
|
19
|
+
allow(provider).to receive(:current).and_return(
|
|
20
|
+
ok: true,
|
|
21
|
+
imported_accounts: [
|
|
22
|
+
{ provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'infra' } },
|
|
23
|
+
{ provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'platform' } },
|
|
24
|
+
{ provider: 'echo', type: 'user', name: 'bob' }
|
|
25
|
+
]
|
|
26
|
+
)
|
|
27
|
+
allow(state_backend).to receive(:write)
|
|
28
|
+
|
|
29
|
+
result = described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
|
|
30
|
+
|
|
31
|
+
expect(result[:ok]).to be(true)
|
|
32
|
+
expect(result[:status]).to eq('imported')
|
|
33
|
+
expect(result[:imported_count]).to eq(2)
|
|
34
|
+
expect(state_backend).to have_received(:write).with(
|
|
35
|
+
{
|
|
36
|
+
version: 1,
|
|
37
|
+
resources: [
|
|
38
|
+
{
|
|
39
|
+
provider: 'echo',
|
|
40
|
+
type: 'user',
|
|
41
|
+
name: 'alice',
|
|
42
|
+
attributes: { team: 'platform' },
|
|
43
|
+
identity: 'echo.user.alice'
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
provider: 'echo',
|
|
47
|
+
type: 'user',
|
|
48
|
+
name: 'bob',
|
|
49
|
+
identity: 'echo.user.bob'
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'uses returned resource payload when provider returns a single resource' do
|
|
57
|
+
allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
|
|
58
|
+
allow(provider).to receive(:current).and_return(
|
|
59
|
+
ok: true,
|
|
60
|
+
resource: { provider: 'echo', type: 'user', name: 'carla', attributes: { title: 'manager' } }
|
|
61
|
+
)
|
|
62
|
+
allow(state_backend).to receive(:write)
|
|
63
|
+
|
|
64
|
+
result = described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
|
|
65
|
+
imported = result.fetch(:state).fetch(:resources)
|
|
66
|
+
|
|
67
|
+
expect(imported).to eq(
|
|
68
|
+
[{ provider: 'echo', type: 'user', name: 'carla', attributes: { title: 'manager' }, identity: 'echo.user.carla' }]
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'raises internal error when provider current returns ok: false' do
|
|
73
|
+
allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
|
|
74
|
+
allow(provider).to receive(:current).and_return(ok: false, error: 'denied')
|
|
75
|
+
allow(state_backend).to receive(:write)
|
|
76
|
+
|
|
77
|
+
expect do
|
|
78
|
+
described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
|
|
79
|
+
end.to raise_error(Umgr::Errors::InternalError, /denied/)
|
|
80
|
+
expect(state_backend).not_to have_received(:write)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'uses account fallback shape when provider returns account attributes hash' do
|
|
84
|
+
allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
|
|
85
|
+
allow(provider).to receive(:current).and_return(
|
|
86
|
+
ok: true,
|
|
87
|
+
account: { team: 'security', title: 'engineer' }
|
|
88
|
+
)
|
|
89
|
+
allow(state_backend).to receive(:write)
|
|
90
|
+
|
|
91
|
+
result = described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
|
|
92
|
+
resource = result.fetch(:state).fetch(:resources).first
|
|
93
|
+
|
|
94
|
+
expect(resource).to eq(
|
|
95
|
+
provider: 'echo',
|
|
96
|
+
type: 'user',
|
|
97
|
+
name: 'alice',
|
|
98
|
+
attributes: { team: 'security', title: 'engineer' },
|
|
99
|
+
identity: 'echo.user.alice'
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'raises internal error when provider current returns no recognized resource keys' do
|
|
104
|
+
allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
|
|
105
|
+
allow(provider).to receive(:current).and_return(ok: true, ignored: 'value')
|
|
106
|
+
allow(state_backend).to receive(:write)
|
|
107
|
+
|
|
108
|
+
expect do
|
|
109
|
+
described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
|
|
110
|
+
end.to raise_error(Umgr::Errors::InternalError, /missing imported resources/)
|
|
111
|
+
expect(state_backend).not_to have_received(:write)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'raises internal error when account fallback is not a hash' do
|
|
115
|
+
allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
|
|
116
|
+
allow(provider).to receive(:current).and_return(ok: true, account: 'some_string')
|
|
117
|
+
allow(state_backend).to receive(:write)
|
|
118
|
+
|
|
119
|
+
expect do
|
|
120
|
+
described_class.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
|
|
121
|
+
end.to raise_error(Umgr::Errors::InternalError, /missing imported resources/)
|
|
122
|
+
expect(state_backend).not_to have_received(:write)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Umgr::PlanResultBuilder do
|
|
4
|
+
let(:state_backend) { instance_double(Umgr::StateBackend, path: '/tmp/.umgr/state.json') }
|
|
5
|
+
let(:desired_state) do
|
|
6
|
+
{
|
|
7
|
+
version: 1,
|
|
8
|
+
resources: [
|
|
9
|
+
{ provider: 'echo', type: 'user', name: 'alice', identity: 'echo.user.alice' }
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
let(:options) { { config: '/tmp/users.yml', desired_state: desired_state } }
|
|
14
|
+
|
|
15
|
+
it 'uses persisted state when available' do
|
|
16
|
+
allow(state_backend).to receive(:read).and_return(
|
|
17
|
+
version: 1,
|
|
18
|
+
resources: [{ provider: 'echo', type: 'user', name: 'alice', identity: 'echo.user.alice' }]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
result = described_class.call(state_backend: state_backend, options: options)
|
|
22
|
+
|
|
23
|
+
expect(result[:status]).to eq('planned')
|
|
24
|
+
expect(result[:current_state][:resources].size).to eq(1)
|
|
25
|
+
expect(result[:changeset][:summary]).to eq(create: 0, update: 0, delete: 0, no_change: 1)
|
|
26
|
+
expect(result[:drift]).to eq(
|
|
27
|
+
detected: false,
|
|
28
|
+
change_count: 0,
|
|
29
|
+
actions: { create: 0, update: 0, delete: 0 }
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'falls back to shared initial state when no persisted state exists' do
|
|
34
|
+
allow(state_backend).to receive(:read).and_return(nil)
|
|
35
|
+
|
|
36
|
+
result = described_class.call(state_backend: state_backend, options: options)
|
|
37
|
+
|
|
38
|
+
expect(result[:status]).to eq('planned')
|
|
39
|
+
expect(result[:current_state]).to eq(Umgr::StateTemplate::INITIAL_STATE)
|
|
40
|
+
expect(result[:changeset][:summary]).to eq(create: 1, update: 0, delete: 0, no_change: 0)
|
|
41
|
+
expect(result[:drift]).to eq(
|
|
42
|
+
detected: true,
|
|
43
|
+
change_count: 1,
|
|
44
|
+
actions: { create: 1, update: 0, delete: 0 }
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'enriches changes with provider-specific plan details when registry is provided' do
|
|
49
|
+
provider_registry = instance_double(Umgr::ProviderRegistry)
|
|
50
|
+
provider = instance_double(Umgr::Providers::GithubProvider)
|
|
51
|
+
desired = { provider: 'github', type: 'user', name: 'alice', identity: 'github.user.alice', teams: ['admins'] }
|
|
52
|
+
current = { provider: 'github', type: 'user', name: 'alice', identity: 'github.user.alice', teams: ['platform'] }
|
|
53
|
+
allow(state_backend).to receive(:read).and_return(version: 1, resources: [current])
|
|
54
|
+
allow(provider_registry).to receive(:fetch).with('github').and_return(provider)
|
|
55
|
+
allow(provider).to receive(:plan).with(desired: desired, current: current).and_return(
|
|
56
|
+
ok: true,
|
|
57
|
+
provider: 'github',
|
|
58
|
+
status: 'planned',
|
|
59
|
+
operations: [{ type: 'add_team_membership', team: 'admins', login: 'alice' }]
|
|
60
|
+
)
|
|
61
|
+
custom_options = { config: '/tmp/users.yml', desired_state: { version: 1, resources: [desired] } }
|
|
62
|
+
|
|
63
|
+
result = described_class.call(
|
|
64
|
+
state_backend: state_backend,
|
|
65
|
+
options: custom_options,
|
|
66
|
+
provider_registry: provider_registry
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
change = result.fetch(:changeset).fetch(:changes).first
|
|
70
|
+
expect(change[:action]).to eq('update')
|
|
71
|
+
expect(change.fetch(:provider_plan)).to eq(
|
|
72
|
+
ok: true,
|
|
73
|
+
provider: 'github',
|
|
74
|
+
status: 'planned',
|
|
75
|
+
operations: [{ type: 'add_team_membership', team: 'admins', login: 'alice' }]
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'keeps change unchanged when provider name cannot be resolved' do
|
|
80
|
+
provider_registry = instance_double(Umgr::ProviderRegistry)
|
|
81
|
+
change = { identity: 'unknown.user.alice', action: 'update', desired: nil, current: nil }
|
|
82
|
+
|
|
83
|
+
result = described_class.send(:enrich_change, change, provider_registry)
|
|
84
|
+
|
|
85
|
+
expect(result).to eq(change)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'keeps change unchanged when provider plan result is not includable' do
|
|
89
|
+
provider_registry = instance_double(Umgr::ProviderRegistry)
|
|
90
|
+
provider = instance_double(Umgr::Providers::GithubProvider)
|
|
91
|
+
desired = { provider: 'github', type: 'user', name: 'alice', identity: 'github.user.alice', teams: [] }
|
|
92
|
+
current = { provider: 'github', type: 'user', name: 'alice', identity: 'github.user.alice', teams: ['admins'] }
|
|
93
|
+
change = { identity: 'github.user.alice', action: 'update', desired: desired, current: current }
|
|
94
|
+
allow(provider_registry).to receive(:fetch).with('github').and_return(provider)
|
|
95
|
+
allow(provider).to receive(:plan).with(desired: desired, current: current).and_return(ok: false, status: 'error')
|
|
96
|
+
|
|
97
|
+
result = described_class.send(:enrich_change, change, provider_registry)
|
|
98
|
+
|
|
99
|
+
expect(result).to eq(change)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Umgr::ProviderContract do
|
|
4
|
+
let(:provider_class) do
|
|
5
|
+
Class.new do
|
|
6
|
+
def validate(resource:); end
|
|
7
|
+
def current(resource:); end
|
|
8
|
+
def plan(desired:, current:); end
|
|
9
|
+
def apply(changeset:); end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'accepts providers implementing the contract' do
|
|
14
|
+
provider = provider_class.new
|
|
15
|
+
|
|
16
|
+
expect(described_class.validate!(provider)).to eq(provider)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'raises when required methods are missing' do
|
|
20
|
+
invalid_provider = Object.new
|
|
21
|
+
|
|
22
|
+
expect { described_class.validate!(invalid_provider) }
|
|
23
|
+
.to raise_error(Umgr::Errors::ProviderContractError, /must implement concrete methods/)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'raises when provider uses abstract base implementations' do
|
|
27
|
+
abstract_provider = Umgr::Provider.new
|
|
28
|
+
|
|
29
|
+
expect { described_class.validate!(abstract_provider) }
|
|
30
|
+
.to raise_error(Umgr::Errors::ProviderContractError, /must implement concrete methods/)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Umgr::ProviderRegistry do
|
|
4
|
+
let(:registry) { described_class.new }
|
|
5
|
+
let(:provider) do
|
|
6
|
+
Class.new do
|
|
7
|
+
def validate(resource:); end
|
|
8
|
+
def current(resource:); end
|
|
9
|
+
def plan(desired:, current:); end
|
|
10
|
+
def apply(changeset:); end
|
|
11
|
+
end.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'registers and fetches providers by normalized name' do
|
|
15
|
+
registry.register('github', provider)
|
|
16
|
+
|
|
17
|
+
expect(registry.fetch(:github)).to eq(provider)
|
|
18
|
+
expect(registry.fetch('github')).to eq(provider)
|
|
19
|
+
expect(registry.fetch(:echo)).to be_a(Umgr::Providers::EchoProvider)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'registers built-in github provider by default' do
|
|
23
|
+
expect(registry.fetch(:github)).to be_a(Umgr::Providers::GithubProvider)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'lists registered provider names' do
|
|
27
|
+
registry.register(:slack, provider)
|
|
28
|
+
registry.register('github', provider)
|
|
29
|
+
|
|
30
|
+
expect(registry.names).to eq(%i[echo github slack])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'raises for empty provider names' do
|
|
34
|
+
expect { registry.register(' ', provider) }
|
|
35
|
+
.to raise_error(Umgr::Errors::ValidationError, /Provider name must be a non-empty/)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'raises when provider contract is incomplete' do
|
|
39
|
+
expect { registry.register('github', Object.new) }
|
|
40
|
+
.to raise_error(Umgr::Errors::ProviderContractError, /must implement/)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Umgr::ProviderResourceValidator do
|
|
4
|
+
it 'delegates validation to provider for each desired resource' do
|
|
5
|
+
provider = instance_double(Umgr::Providers::EchoProvider)
|
|
6
|
+
registry = instance_double(Umgr::ProviderRegistry)
|
|
7
|
+
desired_state = {
|
|
8
|
+
resources: [
|
|
9
|
+
{ provider: 'echo', type: 'user', name: 'alice' },
|
|
10
|
+
{ provider: 'echo', type: 'user', name: 'bob' }
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
allow(registry).to receive(:fetch).with('echo').and_return(provider)
|
|
15
|
+
allow(provider).to receive(:validate)
|
|
16
|
+
|
|
17
|
+
described_class.validate!(desired_state: desired_state, provider_registry: registry)
|
|
18
|
+
|
|
19
|
+
expect(provider).to have_received(:validate).with(resource: { provider: 'echo', type: 'user', name: 'alice' })
|
|
20
|
+
expect(provider).to have_received(:validate).with(resource: { provider: 'echo', type: 'user', name: 'bob' })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'raises when provider returns ok: false validation result' do
|
|
24
|
+
provider = instance_double(Umgr::Providers::EchoProvider)
|
|
25
|
+
registry = instance_double(Umgr::ProviderRegistry)
|
|
26
|
+
desired_state = {
|
|
27
|
+
resources: [
|
|
28
|
+
{ provider: 'echo', type: 'user', name: 'alice', identity: 'echo.user.alice' }
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
allow(registry).to receive(:fetch).with('echo').and_return(provider)
|
|
33
|
+
allow(provider).to receive(:validate).and_return(ok: false, error: 'provider validation failed')
|
|
34
|
+
|
|
35
|
+
expect do
|
|
36
|
+
described_class.validate!(desired_state: desired_state, provider_registry: registry)
|
|
37
|
+
end.to raise_error(Umgr::Errors::ValidationError, /provider validation failed for resource echo\.user\.alice/)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Umgr::Provider do
|
|
4
|
+
subject(:provider) { described_class.new }
|
|
5
|
+
|
|
6
|
+
it 'raises for validate by default' do
|
|
7
|
+
expect { provider.validate(resource: {}) }
|
|
8
|
+
.to raise_error(Umgr::Errors::AbstractMethodError, /must implement #validate/)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'raises for current by default' do
|
|
12
|
+
expect { provider.current(resource: {}) }
|
|
13
|
+
.to raise_error(Umgr::Errors::AbstractMethodError, /must implement #current/)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'raises for plan by default' do
|
|
17
|
+
expect { provider.plan(desired: {}, current: {}) }
|
|
18
|
+
.to raise_error(Umgr::Errors::AbstractMethodError, /must implement #plan/)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'raises for apply by default' do
|
|
22
|
+
expect { provider.apply(changeset: {}) }
|
|
23
|
+
.to raise_error(Umgr::Errors::AbstractMethodError, /must implement #apply/)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Umgr::Providers::EchoProvider do
|
|
4
|
+
subject(:provider) { described_class.new }
|
|
5
|
+
|
|
6
|
+
let(:resource) do
|
|
7
|
+
{
|
|
8
|
+
provider: 'echo',
|
|
9
|
+
type: 'user',
|
|
10
|
+
name: 'alice',
|
|
11
|
+
attributes: {
|
|
12
|
+
email: 'alice@example.com',
|
|
13
|
+
team: 'platform'
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'echoes resource in validate' do
|
|
19
|
+
result = provider.validate(resource: resource)
|
|
20
|
+
|
|
21
|
+
expect(result[:ok]).to be(true)
|
|
22
|
+
expect(result[:provider]).to eq('echo')
|
|
23
|
+
expect(result[:resource]).to eq(resource)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'echoes account attributes in current' do
|
|
27
|
+
result = provider.current(resource: resource)
|
|
28
|
+
|
|
29
|
+
expect(result[:ok]).to be(true)
|
|
30
|
+
expect(result[:account]).to eq(email: 'alice@example.com', team: 'platform')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'returns update status when desired and current differ' do
|
|
34
|
+
result = provider.plan(desired: { email: 'alice@example.com' }, current: { email: 'old@example.com' })
|
|
35
|
+
|
|
36
|
+
expect(result[:ok]).to be(true)
|
|
37
|
+
expect(result[:status]).to eq('update')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'returns no_change status when desired and current are the same' do
|
|
41
|
+
desired = { email: 'alice@example.com' }
|
|
42
|
+
result = provider.plan(desired: desired, current: desired)
|
|
43
|
+
|
|
44
|
+
expect(result[:ok]).to be(true)
|
|
45
|
+
expect(result[:status]).to eq('no_change')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'echoes changeset in apply' do
|
|
49
|
+
result = provider.apply(changeset: { action: 'update' })
|
|
50
|
+
|
|
51
|
+
expect(result[:ok]).to be(true)
|
|
52
|
+
expect(result[:status]).to eq('applied')
|
|
53
|
+
expect(result[:changeset]).to eq(action: 'update')
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Umgr::Providers::GithubAccountNormalizer do
|
|
4
|
+
it 'normalizes github user payload into canonical account resource shape' do
|
|
5
|
+
user = {
|
|
6
|
+
'id' => 10,
|
|
7
|
+
'login' => 'alice',
|
|
8
|
+
'avatar_url' => 'https://avatars.example/alice',
|
|
9
|
+
'html_url' => 'https://github.com/alice',
|
|
10
|
+
'type' => 'User'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
result = described_class.call(user: user, org: 'acme', teams: %w[admins platform])
|
|
14
|
+
|
|
15
|
+
expect(result).to eq(
|
|
16
|
+
provider: 'github',
|
|
17
|
+
type: 'user',
|
|
18
|
+
name: 'alice',
|
|
19
|
+
identity: 'github.user.alice',
|
|
20
|
+
org: 'acme',
|
|
21
|
+
teams: %w[admins platform],
|
|
22
|
+
attributes: {
|
|
23
|
+
id: 10,
|
|
24
|
+
login: 'alice',
|
|
25
|
+
avatar_url: 'https://avatars.example/alice',
|
|
26
|
+
html_url: 'https://github.com/alice',
|
|
27
|
+
type: 'User'
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Umgr::Providers::GithubApiClient do
|
|
6
|
+
subject(:client) { described_class.new }
|
|
7
|
+
|
|
8
|
+
it 'paginates org users with link headers' do
|
|
9
|
+
stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/members(\?.*)?\z}).to_return(
|
|
10
|
+
status: 200,
|
|
11
|
+
body: JSON.generate([{ login: 'alice' }]),
|
|
12
|
+
headers: {
|
|
13
|
+
'Content-Type' => 'application/json',
|
|
14
|
+
'Link' => '<https://api.github.com/orgs/acme/members?page=2>; rel="next"'
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
stub_request(:get, 'https://api.github.com/orgs/acme/members?page=2').to_return(
|
|
18
|
+
status: 200,
|
|
19
|
+
body: JSON.generate([{ login: 'bob' }]),
|
|
20
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
result = client.list_org_users(org: 'acme', token: 'secret')
|
|
24
|
+
|
|
25
|
+
expect(result.map { |user| user[:login] || user['login'] }).to eq(%w[alice bob])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'builds team memberships map from org teams and members endpoints' do
|
|
29
|
+
stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/teams(\?.*)?\z}).to_return(
|
|
30
|
+
status: 200,
|
|
31
|
+
body: JSON.generate([{ id: 101, slug: 'platform' }, { id: 102, slug: 'admins' }]),
|
|
32
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
33
|
+
)
|
|
34
|
+
stub_request(:get, %r{\Ahttps://api\.github\.com/teams/101/members(\?.*)?\z}).to_return(
|
|
35
|
+
status: 200,
|
|
36
|
+
body: JSON.generate([{ login: 'alice' }, { login: 'bob' }]),
|
|
37
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
38
|
+
)
|
|
39
|
+
stub_request(:get, %r{\Ahttps://api\.github\.com/teams/102/members(\?.*)?\z}).to_return(
|
|
40
|
+
status: 200,
|
|
41
|
+
body: JSON.generate([{ login: 'alice' }]),
|
|
42
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
result = client.list_org_team_memberships(org: 'acme', token: 'secret')
|
|
46
|
+
|
|
47
|
+
expect(result).to eq(
|
|
48
|
+
'alice' => %w[admins platform],
|
|
49
|
+
'bob' => ['platform']
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'raises api error on non-success responses' do
|
|
54
|
+
stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/members(\?.*)?\z}).to_return(
|
|
55
|
+
status: 500,
|
|
56
|
+
body: JSON.generate(message: 'internal error'),
|
|
57
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
expect { client.list_org_users(org: 'acme', token: 'secret') }
|
|
61
|
+
.to raise_error(Umgr::Errors::ApiError, /GitHub API request failed/)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'invites user to organization membership' do
|
|
65
|
+
stub_request(:put, 'https://api.github.com/orgs/acme/memberships/alice')
|
|
66
|
+
.with(body: hash_including(role: 'member'))
|
|
67
|
+
.to_return(status: 200, body: JSON.generate(state: 'pending'), headers: { 'Content-Type' => 'application/json' })
|
|
68
|
+
|
|
69
|
+
result = client.invite_org_member(org: 'acme', login: 'alice', token: 'secret')
|
|
70
|
+
|
|
71
|
+
expect(result[:state] || result['state']).to eq('pending')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'adds and removes team membership by slug' do
|
|
75
|
+
stub_request(:get, %r{\Ahttps://api\.github\.com/orgs/acme/teams(\?.*)?\z}).to_return(
|
|
76
|
+
status: 200,
|
|
77
|
+
body: JSON.generate([{ id: 101, slug: 'platform' }]),
|
|
78
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
79
|
+
)
|
|
80
|
+
stub_request(:put, 'https://api.github.com/teams/101/memberships/alice').to_return(
|
|
81
|
+
status: 200,
|
|
82
|
+
body: JSON.generate(state: 'active'),
|
|
83
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
84
|
+
)
|
|
85
|
+
stub_request(:delete, 'https://api.github.com/teams/101/memberships/alice').to_return(status: 204, body: '')
|
|
86
|
+
|
|
87
|
+
add_result = client.add_team_membership(org: 'acme', team_slug: 'platform', login: 'alice', token: 'secret')
|
|
88
|
+
remove_result = client.remove_team_membership(org: 'acme', team_slug: 'platform', login: 'alice', token: 'secret')
|
|
89
|
+
|
|
90
|
+
expect(add_result[:state] || add_result['state']).to eq('active')
|
|
91
|
+
expect(remove_result).to be(true)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'removes organization membership' do
|
|
95
|
+
stub_request(:delete, 'https://api.github.com/orgs/acme/memberships/alice').to_return(status: 204, body: '')
|
|
96
|
+
|
|
97
|
+
result = client.remove_org_member(org: 'acme', login: 'alice', token: 'secret')
|
|
98
|
+
|
|
99
|
+
expect(result).to be(true)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Umgr::Providers::GithubPlanBuilder do
|
|
4
|
+
describe '.call' do
|
|
5
|
+
it 'plans invite and team membership additions for new users' do
|
|
6
|
+
result = described_class.call(
|
|
7
|
+
desired: { provider: 'github', type: 'user', name: 'alice', teams: %w[admins platform] },
|
|
8
|
+
current: nil
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
expect(result).to include(
|
|
12
|
+
ok: true,
|
|
13
|
+
provider: 'github',
|
|
14
|
+
status: 'planned',
|
|
15
|
+
organization_action: 'invite'
|
|
16
|
+
)
|
|
17
|
+
expect(result.fetch(:operations)).to eq(
|
|
18
|
+
[
|
|
19
|
+
{ type: 'invite_org_member', login: 'alice' },
|
|
20
|
+
{ type: 'add_team_membership', login: 'alice', team: 'admins' },
|
|
21
|
+
{ type: 'add_team_membership', login: 'alice', team: 'platform' }
|
|
22
|
+
]
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'plans no_change operation when memberships are identical' do
|
|
27
|
+
result = described_class.call(
|
|
28
|
+
desired: { provider: 'github', type: 'user', name: 'alice', teams: %w[platform admins] },
|
|
29
|
+
current: { provider: 'github', type: 'user', name: 'alice', teams: %w[admins platform] }
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(result.fetch(:organization_action)).to eq('keep')
|
|
33
|
+
expect(result.fetch(:team_actions)).to eq(add: [], remove: [], unchanged: %w[admins platform])
|
|
34
|
+
expect(result.fetch(:operations)).to eq([{ type: 'no_change', login: 'alice' }])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'plans org removal when user is not desired anymore' do
|
|
38
|
+
result = described_class.call(
|
|
39
|
+
desired: nil,
|
|
40
|
+
current: { provider: 'github', type: 'user', name: 'alice', teams: ['admins'] }
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
expect(result.fetch(:organization_action)).to eq('remove')
|
|
44
|
+
expect(result.fetch(:operations)).to eq([{ type: 'remove_org_member', login: 'alice' }])
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|