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,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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module ResourceIdentity
5
+ module_function
6
+
7
+ def call(resource)
8
+ "#{resource.fetch(:provider)}.#{resource.fetch(:type)}.#{resource.fetch(:name)}"
9
+ end
10
+ end
11
+ end
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module StateTemplate
5
+ INITIAL_STATE = { version: 1, resources: [].freeze }.freeze
6
+ end
7
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ VERSION = '0.1.4'
5
+ end
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