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,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
module ImportResultBuilder
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(state_backend:, options:, provider_registry:)
|
|
8
|
+
desired_state = options.fetch(:desired_state)
|
|
9
|
+
imported_resources = import_resources(
|
|
10
|
+
desired_state.fetch(:resources, []),
|
|
11
|
+
provider_registry
|
|
12
|
+
)
|
|
13
|
+
final_state = build_final_state(desired_state.fetch(:version), imported_resources)
|
|
14
|
+
state_backend.write(final_state)
|
|
15
|
+
build_result(options, state_backend, final_state)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def import_resources(resources, provider_registry)
|
|
19
|
+
resources.flat_map do |resource|
|
|
20
|
+
import_resource(resource, provider_registry)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def import_resource(resource, provider_registry)
|
|
25
|
+
provider_name = resource.fetch(:provider)
|
|
26
|
+
provider = provider_registry.fetch(provider_name)
|
|
27
|
+
current_result = provider.current(resource: resource)
|
|
28
|
+
ensure_successful_current!(current_result, provider_name)
|
|
29
|
+
extract_resources(current_result, resource)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ensure_successful_current!(result, provider_name)
|
|
33
|
+
return unless result.fetch(:ok, result.fetch('ok', true)) == false
|
|
34
|
+
|
|
35
|
+
message = result[:error] || result['error'] || "provider current failed for #{provider_name}"
|
|
36
|
+
raise Errors::InternalError, message
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def extract_resources(result, fallback_resource)
|
|
40
|
+
imported_accounts = result[:imported_accounts] || result['imported_accounts']
|
|
41
|
+
return imported_accounts if imported_accounts.is_a?(Array)
|
|
42
|
+
|
|
43
|
+
resource = result[:resource] || result['resource']
|
|
44
|
+
return [resource] if resource
|
|
45
|
+
|
|
46
|
+
account = result[:account] || result['account']
|
|
47
|
+
return [fallback_resource.merge(attributes: account)] if account.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
raise Errors::InternalError, 'Provider current result missing imported resources'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_final_state(version, imported_resources)
|
|
53
|
+
enriched = DesiredStateEnricher.call(version: version, resources: imported_resources)
|
|
54
|
+
deduped_resources = deduplicate_by_identity(enriched.fetch(:resources))
|
|
55
|
+
{
|
|
56
|
+
version: version,
|
|
57
|
+
resources: deduped_resources
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def deduplicate_by_identity(resources)
|
|
62
|
+
unique = {}
|
|
63
|
+
resources.each { |resource| unique[resource.fetch(:identity)] = resource }
|
|
64
|
+
unique.values.sort_by { |resource| resource.fetch(:identity) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_result(options, state_backend, final_state)
|
|
68
|
+
{
|
|
69
|
+
ok: true,
|
|
70
|
+
action: 'import',
|
|
71
|
+
status: 'imported',
|
|
72
|
+
options: options,
|
|
73
|
+
state_path: state_backend.path,
|
|
74
|
+
state: final_state,
|
|
75
|
+
imported_count: final_state.fetch(:resources).length
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
module PlanResultBuilder
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(state_backend:, options:, provider_registry: nil)
|
|
8
|
+
desired_state = options.fetch(:desired_state)
|
|
9
|
+
current_state = DesiredStateEnricher.call(state_backend.read || StateTemplate::INITIAL_STATE)
|
|
10
|
+
changeset = build_changeset(
|
|
11
|
+
desired_state: desired_state,
|
|
12
|
+
current_state: current_state,
|
|
13
|
+
provider_registry: provider_registry
|
|
14
|
+
)
|
|
15
|
+
build_result(options: options, state_backend: state_backend, current_state: current_state, changeset: changeset)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build_changeset(desired_state:, current_state:, provider_registry:)
|
|
19
|
+
changeset = ChangeSetBuilder.call(
|
|
20
|
+
desired_resources: desired_state.fetch(:resources, []),
|
|
21
|
+
current_resources: current_state.fetch(:resources, [])
|
|
22
|
+
)
|
|
23
|
+
return changeset unless provider_registry
|
|
24
|
+
|
|
25
|
+
changeset.merge(changes: enrich_changes(changeset.fetch(:changes), provider_registry))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def enrich_changes(changes, provider_registry)
|
|
29
|
+
changes.map { |change| enrich_change(change, provider_registry) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enrich_change(change, provider_registry)
|
|
33
|
+
provider_name = provider_name_for(change)
|
|
34
|
+
return change unless provider_name
|
|
35
|
+
|
|
36
|
+
provider = provider_registry.fetch(provider_name)
|
|
37
|
+
provider_result = provider.plan(desired: change[:desired], current: change[:current])
|
|
38
|
+
return change unless include_provider_plan?(provider_result)
|
|
39
|
+
|
|
40
|
+
change.merge(provider_plan: compact_provider_plan(provider_result))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def provider_name_for(change)
|
|
44
|
+
resource = change[:desired] || change[:current]
|
|
45
|
+
return nil unless resource
|
|
46
|
+
|
|
47
|
+
resource[:provider] || resource['provider']
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def include_provider_plan?(provider_result)
|
|
51
|
+
return false unless provider_result.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
provider_result.fetch(:ok, provider_result.fetch('ok', nil)) != false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def compact_provider_plan(provider_result)
|
|
57
|
+
compacted = provider_result.dup
|
|
58
|
+
%i[desired current].each { |key| compacted.delete(key) }
|
|
59
|
+
%w[desired current].each { |key| compacted.delete(key) }
|
|
60
|
+
compacted
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_result(options:, state_backend:, current_state:, changeset:)
|
|
64
|
+
result = { ok: true, action: 'plan', status: 'planned', options: options, state_path: state_backend.path }
|
|
65
|
+
result.merge(
|
|
66
|
+
current_state: current_state,
|
|
67
|
+
changeset: changeset,
|
|
68
|
+
drift: DriftReportBuilder.call(changeset.fetch(:summary))
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
class Provider
|
|
5
|
+
def validate(resource:)
|
|
6
|
+
_ = resource
|
|
7
|
+
raise Errors::AbstractMethodError, "#{self.class} must implement #validate(resource:)"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def current(resource:)
|
|
11
|
+
_ = resource
|
|
12
|
+
raise Errors::AbstractMethodError, "#{self.class} must implement #current(resource:)"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def plan(desired:, current:)
|
|
16
|
+
_ = desired
|
|
17
|
+
_ = current
|
|
18
|
+
raise Errors::AbstractMethodError, "#{self.class} must implement #plan(desired:, current:)"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def apply(changeset:)
|
|
22
|
+
_ = changeset
|
|
23
|
+
raise Errors::AbstractMethodError, "#{self.class} must implement #apply(changeset:)"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
module ProviderContract
|
|
5
|
+
METHODS = %i[validate current plan apply].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def validate!(provider)
|
|
10
|
+
invalid_methods = METHODS.reject do |method_name|
|
|
11
|
+
provider.respond_to?(method_name) && provider.method(method_name).owner != Provider
|
|
12
|
+
end
|
|
13
|
+
return provider if invalid_methods.empty?
|
|
14
|
+
|
|
15
|
+
raise Errors::ProviderContractError,
|
|
16
|
+
"Provider #{provider.class} must implement concrete methods: #{invalid_methods.join(', ')}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
class ProviderRegistry
|
|
5
|
+
def initialize
|
|
6
|
+
@providers = {}
|
|
7
|
+
register(:echo, Providers::EchoProvider.new)
|
|
8
|
+
register(:github, Providers::GithubProvider.new)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register(name, provider)
|
|
12
|
+
normalized_name = normalize_name(name)
|
|
13
|
+
ProviderContract.validate!(provider)
|
|
14
|
+
providers[normalized_name] = provider
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch(name)
|
|
18
|
+
providers[normalize_name(name)]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def names
|
|
22
|
+
providers.keys.sort
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :providers
|
|
28
|
+
|
|
29
|
+
def normalize_name(name)
|
|
30
|
+
normalized = name.to_s.strip
|
|
31
|
+
raise Errors::ValidationError, 'Provider name must be a non-empty string or symbol' if normalized.empty?
|
|
32
|
+
|
|
33
|
+
normalized.to_sym
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
module ProviderResourceValidator
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def validate!(desired_state:, provider_registry:)
|
|
8
|
+
desired_state.fetch(:resources, []).each do |resource|
|
|
9
|
+
provider = provider_registry.fetch(resource[:provider])
|
|
10
|
+
result = provider.validate(resource: resource)
|
|
11
|
+
raise_validation_error!(provider: provider, resource: resource, result: result) if invalid_result?(result)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def invalid_result?(result)
|
|
16
|
+
return false unless result.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
result[:ok] == false || result['ok'] == false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def raise_validation_error!(provider:, resource:, result:)
|
|
22
|
+
message = result[:error] || result['error'] || "Provider #{provider.class} validation returned `ok: false`"
|
|
23
|
+
identity = resource[:identity] || ResourceIdentity.call(resource)
|
|
24
|
+
raise Errors::ValidationError, "#{message} for resource #{identity}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
module Providers
|
|
5
|
+
class EchoProvider < Provider
|
|
6
|
+
def validate(resource:)
|
|
7
|
+
{
|
|
8
|
+
ok: true,
|
|
9
|
+
provider: 'echo',
|
|
10
|
+
resource: resource
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def current(resource:)
|
|
15
|
+
{
|
|
16
|
+
ok: true,
|
|
17
|
+
provider: 'echo',
|
|
18
|
+
account: resource.fetch(:attributes, {}),
|
|
19
|
+
resource: resource
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def plan(desired:, current:)
|
|
24
|
+
status = desired == current ? 'no_change' : 'update'
|
|
25
|
+
{
|
|
26
|
+
ok: true,
|
|
27
|
+
provider: 'echo',
|
|
28
|
+
status: status,
|
|
29
|
+
desired: desired,
|
|
30
|
+
current: current
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def apply(changeset:)
|
|
35
|
+
{
|
|
36
|
+
ok: true,
|
|
37
|
+
provider: 'echo',
|
|
38
|
+
status: 'applied',
|
|
39
|
+
changeset: changeset
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
module Providers
|
|
5
|
+
module GithubAccountNormalizer
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(user:, org:, teams:)
|
|
9
|
+
login = fetch_value(user, :login)
|
|
10
|
+
resource = base_resource(login: login)
|
|
11
|
+
resource.merge(
|
|
12
|
+
identity: ResourceIdentity.call(resource),
|
|
13
|
+
org: org,
|
|
14
|
+
teams: teams.sort,
|
|
15
|
+
attributes: normalized_attributes(user)
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def normalized_attributes(user)
|
|
20
|
+
{
|
|
21
|
+
id: fetch_value(user, :id),
|
|
22
|
+
login: fetch_value(user, :login),
|
|
23
|
+
avatar_url: fetch_value(user, :avatar_url),
|
|
24
|
+
html_url: fetch_value(user, :html_url),
|
|
25
|
+
type: fetch_value(user, :type)
|
|
26
|
+
}.compact
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def base_resource(login:)
|
|
30
|
+
{
|
|
31
|
+
provider: 'github',
|
|
32
|
+
type: 'user',
|
|
33
|
+
name: login
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fetch_value(payload, key)
|
|
38
|
+
value = payload[key] if payload.respond_to?(:[])
|
|
39
|
+
value ||= payload[key.to_s] if payload.respond_to?(:[])
|
|
40
|
+
value ||= payload.public_send(key) if payload.respond_to?(key)
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'octokit'
|
|
4
|
+
|
|
5
|
+
module Umgr
|
|
6
|
+
module Providers
|
|
7
|
+
class GithubApiClient
|
|
8
|
+
def list_org_users(org:, token:)
|
|
9
|
+
with_api_error_handling do
|
|
10
|
+
build_client(token: token).org_members(org)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def list_org_team_memberships(org:, token:)
|
|
15
|
+
with_api_error_handling do
|
|
16
|
+
client = build_client(token: token)
|
|
17
|
+
teams = client.org_teams(org)
|
|
18
|
+
build_team_memberships(client: client, teams: teams)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def invite_org_member(org:, login:, token:)
|
|
23
|
+
with_api_error_handling do
|
|
24
|
+
build_client(token: token).update_organization_membership(org, user: login, role: 'member')
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def remove_org_member(org:, login:, token:)
|
|
29
|
+
with_api_error_handling do
|
|
30
|
+
build_client(token: token).remove_organization_membership(org, user: login)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def add_team_membership(org:, team_slug:, login:, token:)
|
|
35
|
+
with_api_error_handling do
|
|
36
|
+
client = build_client(token: token)
|
|
37
|
+
team_id = team_id_for!(client: client, org: org, team_slug: team_slug)
|
|
38
|
+
client.add_team_membership(team_id, login)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def remove_team_membership(org:, team_slug:, login:, token:)
|
|
43
|
+
with_api_error_handling do
|
|
44
|
+
client = build_client(token: token)
|
|
45
|
+
team_id = team_id_for!(client: client, org: org, team_slug: team_slug)
|
|
46
|
+
client.remove_team_membership(team_id, login)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def build_client(token:)
|
|
53
|
+
Octokit::Client.new(access_token: token, auto_paginate: true)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_team_memberships(client:, teams:)
|
|
57
|
+
memberships = Hash.new { |memo, key| memo[key] = [] }
|
|
58
|
+
|
|
59
|
+
teams.each do |team|
|
|
60
|
+
team_id = fetch_value(team, :id)
|
|
61
|
+
team_slug = fetch_value(team, :slug)
|
|
62
|
+
next unless team_id && team_slug
|
|
63
|
+
|
|
64
|
+
add_team_members!(memberships: memberships, client: client, team_id: team_id, team_slug: team_slug)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
memberships.transform_values!(&:sort)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_team_members!(memberships:, client:, team_id:, team_slug:)
|
|
71
|
+
members = client.team_members(team_id)
|
|
72
|
+
|
|
73
|
+
members.each do |member|
|
|
74
|
+
login = fetch_value(member, :login)
|
|
75
|
+
next unless login
|
|
76
|
+
|
|
77
|
+
memberships[login] << team_slug
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fetch_value(payload, key)
|
|
82
|
+
return normalize_value(payload.public_send(key)) if payload.respond_to?(key)
|
|
83
|
+
return fetch_indexed_value(payload, key) if payload.respond_to?(:[])
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fetch_indexed_value(payload, key)
|
|
89
|
+
normalize_value(payload[key] || payload[key.to_s])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_value(value)
|
|
93
|
+
value.is_a?(String) ? value : value&.to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def team_id_for!(client:, org:, team_slug:)
|
|
97
|
+
teams = client.org_teams(org)
|
|
98
|
+
matched = teams.find { |team| fetch_value(team, :slug) == team_slug }
|
|
99
|
+
return fetch_value(matched, :id) if matched
|
|
100
|
+
|
|
101
|
+
raise Errors::ValidationError, "GitHub team not found in org #{org}: #{team_slug}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def with_api_error_handling
|
|
105
|
+
yield
|
|
106
|
+
rescue Octokit::Error => e
|
|
107
|
+
raise Errors::ApiError, "GitHub API request failed: #{e.message}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
module Providers
|
|
5
|
+
module GithubApplyExecutor
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(changeset:, api_client:, token_resolver:, present_string:, plan_resolver:)
|
|
9
|
+
resource = changeset[:desired] || changeset[:current] || {}
|
|
10
|
+
org = resource[:org] || resource['org']
|
|
11
|
+
raise Errors::ValidationError, 'GitHub apply requires non-empty `org`' unless present_string.call(org)
|
|
12
|
+
|
|
13
|
+
token = token_resolver.call(resource)
|
|
14
|
+
operations = resolve_operations(changeset, plan_resolver)
|
|
15
|
+
executed_operations = execute_operations(api_client, org, token, operations)
|
|
16
|
+
apply_result(operations, executed_operations)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resolve_operations(changeset, plan_resolver)
|
|
20
|
+
provider_plan = changeset[:provider_plan] || changeset['provider_plan'] || {}
|
|
21
|
+
operations = provider_plan[:operations] || provider_plan['operations']
|
|
22
|
+
return operations if operations.is_a?(Array)
|
|
23
|
+
|
|
24
|
+
plan_resolver.call(changeset[:desired], changeset[:current]).fetch(:operations)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def execute_operations(api_client, org, token, operations)
|
|
28
|
+
operations.map do |operation|
|
|
29
|
+
execute_operation(api_client, org, token, operation)
|
|
30
|
+
operation
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def execute_operation(api_client, org, token, operation)
|
|
35
|
+
dispatch_operation(api_client, org, token, normalize_operation(operation))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def dispatch_operation(api_client, org, token, operation)
|
|
39
|
+
handler = operation_handlers(api_client, org, token)[operation[:type]]
|
|
40
|
+
return handler.call(operation) if handler
|
|
41
|
+
|
|
42
|
+
raise Errors::InternalError, "Unsupported GitHub apply operation: #{operation[:type]}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def operation_handlers(api_client, org, token)
|
|
46
|
+
{
|
|
47
|
+
'invite_org_member' => ->(operation) { invite_operation(api_client, org, token, operation) },
|
|
48
|
+
'remove_org_member' => ->(operation) { remove_org_operation(api_client, org, token, operation) },
|
|
49
|
+
'add_team_membership' => ->(operation) { add_team_operation(api_client, org, token, operation) },
|
|
50
|
+
'remove_team_membership' => ->(operation) { remove_team_operation(api_client, org, token, operation) },
|
|
51
|
+
'no_change' => ->(_operation) {}
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def invite_operation(api_client, org, token, operation)
|
|
56
|
+
api_client.invite_org_member(org: org, login: operation[:login], token: token)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def remove_org_operation(api_client, org, token, operation)
|
|
60
|
+
api_client.remove_org_member(org: org, login: operation[:login], token: token)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def add_team_operation(api_client, org, token, operation)
|
|
64
|
+
api_client.add_team_membership(org: org, team_slug: operation[:team], login: operation[:login], token: token)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def remove_team_operation(api_client, org, token, operation)
|
|
68
|
+
api_client.remove_team_membership(org: org, team_slug: operation[:team], login: operation[:login], token: token)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_operation(operation)
|
|
72
|
+
{
|
|
73
|
+
type: operation[:type] || operation['type'],
|
|
74
|
+
login: operation[:login] || operation['login'],
|
|
75
|
+
team: operation[:team] || operation['team']
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def apply_result(operations, executed_operations)
|
|
80
|
+
{
|
|
81
|
+
ok: true,
|
|
82
|
+
provider: 'github',
|
|
83
|
+
status: 'applied',
|
|
84
|
+
operations: operations,
|
|
85
|
+
executed_operations: executed_operations
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
module Providers
|
|
5
|
+
module GithubPlanBuilder
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(desired:, current:)
|
|
9
|
+
team_actions = build_team_actions(desired: desired, current: current)
|
|
10
|
+
login = extract_login(desired, current)
|
|
11
|
+
organization_action = organization_action_for(desired: desired, current: current)
|
|
12
|
+
operations = build_plan_operations(
|
|
13
|
+
organization_action: organization_action,
|
|
14
|
+
login: login,
|
|
15
|
+
team_add: team_actions[:add],
|
|
16
|
+
team_remove: team_actions[:remove]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
plan_result(organization_action: organization_action, operations: operations, team_actions: team_actions)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def extract_teams(resource)
|
|
23
|
+
return [] unless resource.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
raw = resource[:teams] || resource['teams'] || []
|
|
26
|
+
Array(raw).map(&:to_s).reject(&:empty?).uniq.sort
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def extract_login(desired, current)
|
|
30
|
+
resource = desired || current
|
|
31
|
+
return nil unless resource.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
login = resource[:name] || resource['name']
|
|
34
|
+
login&.to_s
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def organization_action_for(desired:, current:)
|
|
38
|
+
return 'invite' if desired && !current
|
|
39
|
+
return 'remove' if current && !desired
|
|
40
|
+
|
|
41
|
+
'keep'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build_team_actions(desired:, current:)
|
|
45
|
+
desired_teams = extract_teams(desired)
|
|
46
|
+
current_teams = extract_teams(current)
|
|
47
|
+
{
|
|
48
|
+
add: desired_teams - current_teams,
|
|
49
|
+
remove: current_teams - desired_teams,
|
|
50
|
+
unchanged: desired_teams & current_teams
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def plan_result(organization_action:, operations:, team_actions:)
|
|
55
|
+
{
|
|
56
|
+
ok: true,
|
|
57
|
+
provider: 'github',
|
|
58
|
+
status: 'planned',
|
|
59
|
+
organization_action: organization_action,
|
|
60
|
+
team_actions: team_actions,
|
|
61
|
+
operations: operations
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_plan_operations(organization_action:, login:, team_add:, team_remove:)
|
|
66
|
+
case organization_action
|
|
67
|
+
when 'invite'
|
|
68
|
+
build_invite_operations(login: login, team_add: team_add)
|
|
69
|
+
when 'remove'
|
|
70
|
+
[{ type: 'remove_org_member', login: login }]
|
|
71
|
+
else
|
|
72
|
+
build_membership_operations(login: login, team_add: team_add, team_remove: team_remove)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build_invite_operations(login:, team_add:)
|
|
77
|
+
operations = [{ type: 'invite_org_member', login: login }]
|
|
78
|
+
operations.concat(team_add.map { |team| { type: 'add_team_membership', login: login, team: team } })
|
|
79
|
+
operations
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_membership_operations(login:, team_add:, team_remove:)
|
|
83
|
+
operations = []
|
|
84
|
+
operations.concat(team_add.map { |team| { type: 'add_team_membership', login: login, team: team } })
|
|
85
|
+
operations.concat(team_remove.map { |team| { type: 'remove_team_membership', login: login, team: team } })
|
|
86
|
+
operations = [{ type: 'no_change', login: login }] if operations.empty?
|
|
87
|
+
operations
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|