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,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'github/api_client'
|
|
4
|
+
require_relative 'github/apply_executor'
|
|
5
|
+
require_relative 'github/account_normalizer'
|
|
6
|
+
require_relative 'github/plan_builder'
|
|
7
|
+
|
|
8
|
+
module Umgr
|
|
9
|
+
module Providers
|
|
10
|
+
class GithubProvider < Provider
|
|
11
|
+
def initialize(api_client: nil)
|
|
12
|
+
super()
|
|
13
|
+
@api_client = api_client || GithubApiClient.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate(resource:)
|
|
17
|
+
validate_org!(resource)
|
|
18
|
+
validate_auth!(resource)
|
|
19
|
+
validate_teams!(resource)
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
ok: true,
|
|
23
|
+
provider: 'github',
|
|
24
|
+
resource: resource
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def current(resource:)
|
|
29
|
+
validate(resource: resource)
|
|
30
|
+
org = resource.fetch(:org)
|
|
31
|
+
token = resolve_token!(resource)
|
|
32
|
+
accounts = import_accounts(org: org, token: token)
|
|
33
|
+
current_result(org: org, accounts: accounts)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def current_result(org:, accounts:)
|
|
37
|
+
{
|
|
38
|
+
ok: true,
|
|
39
|
+
provider: 'github',
|
|
40
|
+
org: org,
|
|
41
|
+
imported_accounts: accounts,
|
|
42
|
+
count: accounts.length
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def plan(desired:, current:)
|
|
47
|
+
GithubPlanBuilder.call(desired: desired, current: current)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def apply(changeset:)
|
|
51
|
+
GithubApplyExecutor.call(
|
|
52
|
+
changeset: changeset,
|
|
53
|
+
api_client: api_client,
|
|
54
|
+
token_resolver: method(:resolve_token!),
|
|
55
|
+
present_string: method(:present_string?),
|
|
56
|
+
plan_resolver: method(:plan_for_apply)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader :api_client
|
|
63
|
+
|
|
64
|
+
def validate_org!(resource)
|
|
65
|
+
org = resource[:org]
|
|
66
|
+
return if org.is_a?(String) && !org.strip.empty?
|
|
67
|
+
|
|
68
|
+
raise Errors::ValidationError, 'GitHub provider requires non-empty `org`'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_auth!(resource)
|
|
72
|
+
token = resource[:token]
|
|
73
|
+
token_env = resource[:token_env]
|
|
74
|
+
return if present_string?(token) || present_string?(token_env)
|
|
75
|
+
|
|
76
|
+
raise Errors::ValidationError, 'GitHub provider requires `token` or `token_env`'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_teams!(resource)
|
|
80
|
+
teams = resource[:teams]
|
|
81
|
+
return if teams.nil?
|
|
82
|
+
return if teams.is_a?(Array) && teams.all? { |team| present_string?(team) }
|
|
83
|
+
|
|
84
|
+
raise Errors::ValidationError, 'GitHub provider `teams` must be an array of non-empty strings'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def present_string?(value)
|
|
88
|
+
value.is_a?(String) && !value.strip.empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def resolve_token!(resource)
|
|
92
|
+
return resource[:token] if present_string?(resource[:token])
|
|
93
|
+
|
|
94
|
+
env_name = resource[:token_env]
|
|
95
|
+
env_token = ENV.fetch(env_name, nil)
|
|
96
|
+
return env_token if present_string?(env_token)
|
|
97
|
+
|
|
98
|
+
raise Errors::ValidationError, "GitHub provider `token_env` #{env_name} is not set"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def import_accounts(org:, token:)
|
|
102
|
+
users = api_client.list_org_users(org: org, token: token)
|
|
103
|
+
memberships = api_client.list_org_team_memberships(org: org, token: token)
|
|
104
|
+
users.map do |user|
|
|
105
|
+
login = fetch_login(user)
|
|
106
|
+
teams = memberships.fetch(login, [])
|
|
107
|
+
GithubAccountNormalizer.call(user: user, org: org, teams: teams)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def fetch_login(user)
|
|
112
|
+
login = user[:login] if user.respond_to?(:[])
|
|
113
|
+
login ||= user['login'] if user.respond_to?(:[])
|
|
114
|
+
login ||= user.login if user.respond_to?(:login)
|
|
115
|
+
return login if present_string?(login)
|
|
116
|
+
|
|
117
|
+
raise Errors::ApiError, 'GitHub API response missing user login'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def plan_for_apply(desired, current)
|
|
121
|
+
plan(desired: desired, current: current)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/umgr/runner.rb
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
class Runner
|
|
5
|
+
include RunnerConfig
|
|
6
|
+
|
|
7
|
+
ACTIONS = %i[init validate plan apply show import].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(state_backend: nil, provider_registry: nil)
|
|
10
|
+
@state_backend = state_backend || StateBackend.new
|
|
11
|
+
@provider_registry = provider_registry || ProviderRegistry.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def ping = :ok
|
|
15
|
+
|
|
16
|
+
# rubocop:disable Style/ArgumentsForwarding
|
|
17
|
+
def dispatch(action, **options)
|
|
18
|
+
action_name = action.to_sym
|
|
19
|
+
raise Errors::UnknownActionError, "Unknown action: #{action}" unless ACTIONS.include?(action_name)
|
|
20
|
+
|
|
21
|
+
send(action_name, **options)
|
|
22
|
+
end
|
|
23
|
+
# rubocop:enable Style/ArgumentsForwarding
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def init(**options)
|
|
28
|
+
existing_state = state_backend.read
|
|
29
|
+
if existing_state
|
|
30
|
+
completed(:init, 'already_initialized', options, existing_state)
|
|
31
|
+
else
|
|
32
|
+
state_backend.write(StateTemplate::INITIAL_STATE)
|
|
33
|
+
completed(:init, 'initialized', options, StateTemplate::INITIAL_STATE)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate(**options)
|
|
38
|
+
resolved_options = with_resolved_config(:validate, options)
|
|
39
|
+
not_implemented(:validate, resolved_options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def plan(**options)
|
|
43
|
+
resolved_options = with_resolved_config(:plan, options)
|
|
44
|
+
PlanResultBuilder.call(
|
|
45
|
+
state_backend: state_backend,
|
|
46
|
+
options: resolved_options,
|
|
47
|
+
provider_registry: provider_registry
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def apply(**options)
|
|
52
|
+
resolved_options = with_resolved_config(:apply, options)
|
|
53
|
+
ApplyResultBuilder.call(
|
|
54
|
+
state_backend: state_backend,
|
|
55
|
+
options: resolved_options,
|
|
56
|
+
provider_registry: provider_registry
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def show(**options)
|
|
61
|
+
state = state_backend.read
|
|
62
|
+
completed(:show, state ? 'ok' : 'not_initialized', options, state)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def import(**options)
|
|
66
|
+
resolved_options = with_resolved_config(:import, options)
|
|
67
|
+
ImportResultBuilder.call(
|
|
68
|
+
state_backend: state_backend,
|
|
69
|
+
options: resolved_options,
|
|
70
|
+
provider_registry: provider_registry
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def not_implemented(action, options)
|
|
75
|
+
{
|
|
76
|
+
ok: false,
|
|
77
|
+
action: action.to_s,
|
|
78
|
+
status: 'not_implemented',
|
|
79
|
+
options: options,
|
|
80
|
+
state_path: state_backend.path
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def completed(action, status, options, state)
|
|
85
|
+
{
|
|
86
|
+
ok: true,
|
|
87
|
+
action: action.to_s,
|
|
88
|
+
status: status,
|
|
89
|
+
options: options,
|
|
90
|
+
state_path: state_backend.path,
|
|
91
|
+
state: state
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
attr_reader :state_backend, :provider_registry
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
module RunnerConfig
|
|
5
|
+
AUTO_DISCOVERY_CONFIGS = %w[umgr.yml umgr.yaml umgr.json].freeze
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def with_resolved_config(action, options)
|
|
10
|
+
resolved_options = options.dup
|
|
11
|
+
resolved = resolve_config_path(options[:config])
|
|
12
|
+
if resolved
|
|
13
|
+
with_validated_config_options(action, resolved_options, resolved)
|
|
14
|
+
else
|
|
15
|
+
supported = AUTO_DISCOVERY_CONFIGS.join(', ')
|
|
16
|
+
raise Errors::ValidationError, "`config` is required for #{action}. Auto-discovery checks: #{supported}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def with_validated_config_options(action, resolved_options, resolved)
|
|
21
|
+
desired_state = ensure_valid_config(resolved)
|
|
22
|
+
UnknownProviderGuard.validate!(desired_state: desired_state, action: action, provider_registry: provider_registry)
|
|
23
|
+
ProviderResourceValidator.validate!(desired_state: desired_state, provider_registry: provider_registry)
|
|
24
|
+
resolved_options.merge(config: resolved, desired_state: desired_state)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resolve_config_path(config_path)
|
|
28
|
+
config_path && !config_path.empty? ? explicit_config_path(config_path) : discover_config_path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def explicit_config_path(config_path)
|
|
32
|
+
absolute_path = File.expand_path(config_path)
|
|
33
|
+
return absolute_path if File.file?(absolute_path)
|
|
34
|
+
|
|
35
|
+
raise Errors::ValidationError, "Config file not found: #{config_path}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def discover_config_path
|
|
39
|
+
AUTO_DISCOVERY_CONFIGS.each do |candidate|
|
|
40
|
+
absolute_path = File.expand_path(candidate)
|
|
41
|
+
return absolute_path if File.file?(absolute_path)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def ensure_valid_config(config_path)
|
|
48
|
+
desired_state = DeepSymbolizer.call(ConfigValidator.validated_config(config_path))
|
|
49
|
+
DesiredStateEnricher.call(desired_state)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module Umgr
|
|
8
|
+
class StateBackend
|
|
9
|
+
DEFAULT_STATE_DIR = '.umgr'
|
|
10
|
+
DEFAULT_STATE_FILE = 'state.json'
|
|
11
|
+
|
|
12
|
+
def initialize(root_dir: Dir.pwd, state_dir: DEFAULT_STATE_DIR, state_file: DEFAULT_STATE_FILE)
|
|
13
|
+
@root_dir = root_dir
|
|
14
|
+
@state_dir = state_dir
|
|
15
|
+
@state_file = state_file
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def path
|
|
19
|
+
File.join(root_dir, state_dir, state_file)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def read
|
|
23
|
+
return nil unless File.file?(path)
|
|
24
|
+
|
|
25
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(state)
|
|
29
|
+
ensure_state_directory!
|
|
30
|
+
temp_path = "#{path}.tmp-#{SecureRandom.hex(8)}"
|
|
31
|
+
write_temp_state_file(temp_path, state)
|
|
32
|
+
File.rename(temp_path, path)
|
|
33
|
+
path
|
|
34
|
+
ensure
|
|
35
|
+
FileUtils.rm_f(temp_path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def delete
|
|
39
|
+
FileUtils.rm_f(path)
|
|
40
|
+
path
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
attr_reader :root_dir, :state_dir, :state_file
|
|
46
|
+
|
|
47
|
+
def ensure_state_directory!
|
|
48
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write_temp_state_file(temp_path, state)
|
|
52
|
+
File.open(temp_path, 'w') do |file|
|
|
53
|
+
file.write(JSON.pretty_generate(state))
|
|
54
|
+
file.flush
|
|
55
|
+
file.fsync
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umgr
|
|
4
|
+
module UnknownProviderGuard
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def validate!(desired_state:, action:, provider_registry:)
|
|
8
|
+
resources = desired_state.fetch(:resources, [])
|
|
9
|
+
providers = resources.map { |resource| resource[:provider] }.uniq
|
|
10
|
+
unknown_providers = providers.reject { |provider| provider_registry.fetch(provider) }
|
|
11
|
+
|
|
12
|
+
return if unknown_providers.empty?
|
|
13
|
+
|
|
14
|
+
joined = unknown_providers.map(&:to_s).sort.join(', ')
|
|
15
|
+
raise Errors::ValidationError, "Unknown provider(s) for #{action}: #{joined}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/umgr/version.rb
ADDED
data/lib/umgr.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'umgr/version'
|
|
4
|
+
require_relative 'umgr/errors'
|
|
5
|
+
require_relative 'umgr/config_validator'
|
|
6
|
+
require_relative 'umgr/deep_symbolizer'
|
|
7
|
+
require_relative 'umgr/resource_identity'
|
|
8
|
+
require_relative 'umgr/desired_state_enricher'
|
|
9
|
+
require_relative 'umgr/state_template'
|
|
10
|
+
require_relative 'umgr/change_set_builder'
|
|
11
|
+
require_relative 'umgr/drift_report_builder'
|
|
12
|
+
require_relative 'umgr/plan_result_builder'
|
|
13
|
+
require_relative 'umgr/apply_result_builder'
|
|
14
|
+
require_relative 'umgr/import_result_builder'
|
|
15
|
+
require_relative 'umgr/provider'
|
|
16
|
+
require_relative 'umgr/provider_contract'
|
|
17
|
+
require_relative 'umgr/providers/echo_provider'
|
|
18
|
+
require_relative 'umgr/providers/github_provider'
|
|
19
|
+
require_relative 'umgr/provider_registry'
|
|
20
|
+
require_relative 'umgr/unknown_provider_guard'
|
|
21
|
+
require_relative 'umgr/provider_resource_validator'
|
|
22
|
+
require_relative 'umgr/state_backend'
|
|
23
|
+
require_relative 'umgr/runner_config'
|
|
24
|
+
require_relative 'umgr/runner'
|
|
25
|
+
require_relative 'umgr/cli'
|
|
26
|
+
|
|
27
|
+
module Umgr
|
|
28
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Umgr::ApplyResultBuilder 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(:persisted_state) do
|
|
8
|
+
{
|
|
9
|
+
version: 1,
|
|
10
|
+
resources: [
|
|
11
|
+
{ provider: 'echo', type: 'user', name: 'alice', identity: 'echo.user.alice', attributes: { team: 'infra' } }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
let(:desired_state) do
|
|
16
|
+
{
|
|
17
|
+
version: 1,
|
|
18
|
+
resources: [
|
|
19
|
+
{ provider: 'echo', type: 'user', name: 'alice', identity: 'echo.user.alice', attributes: { team: 'platform' } }
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
before do
|
|
25
|
+
stored_state = persisted_state
|
|
26
|
+
allow(state_backend).to receive(:read) { stored_state }
|
|
27
|
+
allow(state_backend).to receive(:write) { |new_state| stored_state = new_state }
|
|
28
|
+
allow(state_backend).to receive(:delete)
|
|
29
|
+
allow(provider_registry).to receive(:fetch).with('echo').and_return(provider)
|
|
30
|
+
allow(provider).to receive_messages(
|
|
31
|
+
plan: { ok: true, provider: 'echo', status: 'planned' },
|
|
32
|
+
apply: { ok: true, provider: 'echo', status: 'applied' }
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'applies changes and persists desired state' do
|
|
37
|
+
result = described_class.call(
|
|
38
|
+
state_backend: state_backend,
|
|
39
|
+
options: { config: '/tmp/users.yml', desired_state: desired_state },
|
|
40
|
+
provider_registry: provider_registry
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
expect(result[:ok]).to be(true)
|
|
44
|
+
expect(result[:status]).to eq('applied')
|
|
45
|
+
expect(result.fetch(:state)).to eq(version: 1, resources: desired_state[:resources])
|
|
46
|
+
expect(result.fetch(:apply_results).first.fetch(:status)).to eq('applied')
|
|
47
|
+
expect(result.fetch(:idempotency)).to eq(
|
|
48
|
+
checked: true,
|
|
49
|
+
stable: true,
|
|
50
|
+
summary: { create: 0, update: 0, delete: 0, no_change: 1 }
|
|
51
|
+
)
|
|
52
|
+
expect(state_backend.read).to eq(version: 1, resources: desired_state[:resources])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'does not persist state if provider apply returns ok: false' do
|
|
56
|
+
allow(provider).to receive(:apply).and_return(ok: false, error: 'boom')
|
|
57
|
+
|
|
58
|
+
expect do
|
|
59
|
+
described_class.call(
|
|
60
|
+
state_backend: state_backend,
|
|
61
|
+
options: { config: '/tmp/users.yml', desired_state: desired_state },
|
|
62
|
+
provider_registry: provider_registry
|
|
63
|
+
)
|
|
64
|
+
end.to raise_error(Umgr::Errors::InternalError, /Provider apply failed/)
|
|
65
|
+
expect(state_backend).not_to have_received(:write)
|
|
66
|
+
expect(state_backend).not_to have_received(:delete)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'skips no_change operations without invoking provider apply' do
|
|
70
|
+
allow(provider).to receive(:plan).and_return(ok: true, provider: 'echo', status: 'no_change')
|
|
71
|
+
allow(state_backend).to receive(:read).and_return(version: 1, resources: desired_state[:resources])
|
|
72
|
+
allow(state_backend).to receive(:write)
|
|
73
|
+
|
|
74
|
+
result = described_class.call(
|
|
75
|
+
state_backend: state_backend,
|
|
76
|
+
options: { config: '/tmp/users.yml', desired_state: desired_state },
|
|
77
|
+
provider_registry: provider_registry
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
expect(provider).not_to have_received(:apply)
|
|
81
|
+
expect(result.fetch(:apply_results).first).to include(action: 'no_change', status: 'skipped')
|
|
82
|
+
expect(result.fetch(:idempotency).fetch(:stable)).to be(true)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'rolls back to previous state when post-apply plan still includes changes' do
|
|
86
|
+
writes = []
|
|
87
|
+
allow(state_backend).to receive(:read).and_return(persisted_state)
|
|
88
|
+
allow(state_backend).to receive(:write) { |new_state| writes << new_state }
|
|
89
|
+
|
|
90
|
+
expect do
|
|
91
|
+
described_class.call(
|
|
92
|
+
state_backend: state_backend,
|
|
93
|
+
options: { config: '/tmp/users.yml', desired_state: desired_state },
|
|
94
|
+
provider_registry: provider_registry
|
|
95
|
+
)
|
|
96
|
+
end.to raise_error(Umgr::Errors::InternalError, /Apply is not idempotent/)
|
|
97
|
+
expect(writes).to eq([{ version: 1, resources: desired_state[:resources] }, persisted_state])
|
|
98
|
+
expect(state_backend).not_to have_received(:delete)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'removes written state on rollback when no previous state existed' do
|
|
102
|
+
allow(state_backend).to receive(:read).and_return(nil)
|
|
103
|
+
allow(state_backend).to receive(:write)
|
|
104
|
+
|
|
105
|
+
expect do
|
|
106
|
+
described_class.call(
|
|
107
|
+
state_backend: state_backend,
|
|
108
|
+
options: { config: '/tmp/users.yml', desired_state: desired_state },
|
|
109
|
+
provider_registry: provider_registry
|
|
110
|
+
)
|
|
111
|
+
end.to raise_error(Umgr::Errors::InternalError, /Apply is not idempotent/)
|
|
112
|
+
expect(state_backend).to have_received(:delete).once
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'preserves original error when rollback itself fails' do
|
|
116
|
+
write_count = 0
|
|
117
|
+
allow(state_backend).to receive(:read).and_return(persisted_state)
|
|
118
|
+
allow(state_backend).to receive(:write) do
|
|
119
|
+
write_count += 1
|
|
120
|
+
raise Errno::ENOSPC, 'disk full during rollback' if write_count == 2
|
|
121
|
+
end
|
|
122
|
+
allow(described_class).to receive(:warn)
|
|
123
|
+
|
|
124
|
+
expect do
|
|
125
|
+
described_class.call(
|
|
126
|
+
state_backend: state_backend,
|
|
127
|
+
options: { config: '/tmp/users.yml', desired_state: desired_state },
|
|
128
|
+
provider_registry: provider_registry
|
|
129
|
+
)
|
|
130
|
+
end.to raise_error(Umgr::Errors::InternalError, /Apply is not idempotent/)
|
|
131
|
+
expect(state_backend).not_to have_received(:delete)
|
|
132
|
+
expect(described_class).to have_received(:warn).with(/Rollback failed after apply error/)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it 'raises internal error when change does not include provider information' do
|
|
136
|
+
change = { identity: 'missing.provider', action: 'update', desired: nil, current: nil }
|
|
137
|
+
|
|
138
|
+
expect do
|
|
139
|
+
described_class.send(:apply_change, change, provider_registry)
|
|
140
|
+
end.to raise_error(Umgr::Errors::InternalError, /Missing provider/)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Umgr::ChangeSetBuilder do
|
|
4
|
+
it 'generates create, update, delete, and no_change entries with summary' do
|
|
5
|
+
desired_resources = [
|
|
6
|
+
{ identity: 'echo.user.alice', provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'platform' } },
|
|
7
|
+
{ identity: 'echo.user.carla', provider: 'echo', type: 'user', name: 'carla' },
|
|
8
|
+
{ identity: 'echo.user.dana', provider: 'echo', type: 'user', name: 'dana' }
|
|
9
|
+
]
|
|
10
|
+
current_resources = [
|
|
11
|
+
{ identity: 'echo.user.alice', provider: 'echo', type: 'user', name: 'alice', attributes: { team: 'infra' } },
|
|
12
|
+
{ identity: 'echo.user.bob', provider: 'echo', type: 'user', name: 'bob' },
|
|
13
|
+
{ identity: 'echo.user.dana', provider: 'echo', type: 'user', name: 'dana' }
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
result = described_class.call(desired_resources: desired_resources, current_resources: current_resources)
|
|
17
|
+
|
|
18
|
+
expect(result[:changes].map { |change| [change[:identity], change[:action]] }).to eq(
|
|
19
|
+
[
|
|
20
|
+
['echo.user.alice', 'update'],
|
|
21
|
+
['echo.user.bob', 'delete'],
|
|
22
|
+
['echo.user.carla', 'create'],
|
|
23
|
+
['echo.user.dana', 'no_change']
|
|
24
|
+
]
|
|
25
|
+
)
|
|
26
|
+
expect(result[:summary]).to eq(create: 1, update: 1, delete: 1, no_change: 1)
|
|
27
|
+
end
|
|
28
|
+
end
|