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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8c6a035227d1794848fc7c3d1b56d2473a49244eeb03f2cdcae92ed1a28f4f8f
4
+ data.tar.gz: c90802e37826aeefd96c67142858af3471ef3d070b8b94b39312073268aea464
5
+ SHA512:
6
+ metadata.gz: 8c3112bba7f1d99878451f440f35b38e4d4e6bbfe80bd6957d06350a051b1ddc6bd82c6e094519839dcde6e22e65109c8a855d72b09facd8cc88d067762aea54
7
+ data.tar.gz: 9f04bf54461647a171d8f6d99c916a49df34ee88a6741ead03c7d520b7ff393ba57b4fb1af5381aa029ef3805d07e79a8ad8c04f1215d70e11834f6d409537f7
data/Gemfile ADDED
@@ -0,0 +1,14 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ group :development, :test do
6
+ gem 'aruba'
7
+ gem 'rake', '~> 13.3'
8
+ gem 'rspec'
9
+ gem 'rubocop', require: false
10
+ gem 'rubocop-performance', require: false
11
+ gem 'rubocop-rspec', require: false
12
+ gem 'simplecov', require: false
13
+ gem 'webmock'
14
+ end
data/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # umgr
2
+
3
+ [![checks](https://github.com/gowda/umgr/actions/workflows/checks.yml/badge.svg)](https://github.com/gowda/umgr/actions/workflows/checks.yml)
4
+ [![CodeQL Advanced](https://github.com/gowda/umgr/actions/workflows/codeql.yml/badge.svg)](https://github.com/gowda/umgr/actions/workflows/codeql.yml)
5
+
6
+ `umgr` is a declarative account lifecycle tool for managing user account state
7
+ across platforms with a desired-state workflow.
8
+
9
+ You declare desired state in a YAML or JSON configuration file, and `umgr`
10
+ compares it with tracked current state to detect drift, import current users,
11
+ and generate/apply changes.
12
+
13
+ ## Motivation
14
+
15
+ Organizations often need to keep user accounts consistent across many systems.
16
+ `umgr` provides one interface to define account intent and reconcile drift.
17
+
18
+ Examples of platforms include Google Workspace, Atlassian, GitHub, Slack, AWS,
19
+ Azure, and Sentry. This list is illustrative, not exhaustive.
20
+
21
+ ## Interfaces
22
+
23
+ `umgr` exposes two interfaces:
24
+
25
+ - CLI for operators (built with Thor)
26
+ - Ruby gem API for embedding in larger applications
27
+
28
+ ## Core Capabilities
29
+
30
+ - Drift detection between desired state and platform/user reality.
31
+ - Import of current users from providers/plugins as a baseline state.
32
+ - Declarative plan/apply workflow for account lifecycle changes.
33
+
34
+ ## Provider/Plugin Model
35
+
36
+ Each platform integration is implemented as a provider/plugin.
37
+
38
+ - Providers implement platform-specific sync logic with SDKs or REST APIs.
39
+ - Providers can expose additional provider-specific options on top of the core
40
+ interface.
41
+ - The first built-in test provider is `echo`, which returns a fake user account
42
+ by echoing configured attributes.
43
+
44
+ Provider authoring guide:
45
+ [`docs/provider-authoring.md`](docs/provider-authoring.md)
46
+
47
+ Website:
48
+ - Site entrypoint: [`docs/index.html`](docs/index.html)
49
+ - Local preview/edit guide: [`docs/website.md`](docs/website.md)
50
+
51
+ ## Configuration and State
52
+
53
+ - Configuration formats: YAML and JSON
54
+ - Model: desired state (config) + current state reference (tool-managed state)
55
+ - Core identity convention: `provider.type.name`
56
+
57
+ ## CLI Example
58
+
59
+ ```bash
60
+ umgr init
61
+ umgr validate --config examples/users.yml
62
+ umgr plan --config examples/users.yml
63
+ umgr apply --config examples/users.yml
64
+ umgr show
65
+ ```
66
+
67
+ ## Ruby API Example
68
+
69
+ ```ruby
70
+ require "umgr"
71
+
72
+ runner = Umgr::Runner.new
73
+ result = runner.dispatch(:plan, config: "examples/users.yml")
74
+
75
+ puts result[:ok]
76
+ puts result.dig(:changeset, :summary)
77
+ ```
78
+
79
+ ## Private Installation (GitHub Packages)
80
+
81
+ `umgr` pre-releases are published to GitHub Packages (`rubygems`).
82
+
83
+ 1. Create a GitHub token with:
84
+ - `read:packages`
85
+ - `repo` (required when the package repository is private)
86
+
87
+ 2. Configure RubyGems credentials:
88
+
89
+ ```bash
90
+ mkdir -p ~/.gem
91
+ cat > ~/.gem/credentials <<'EOF'
92
+ ---
93
+ :github: Bearer <YOUR_GITHUB_TOKEN>
94
+ EOF
95
+ chmod 0600 ~/.gem/credentials
96
+ ```
97
+
98
+ 3. Install directly:
99
+
100
+ ```bash
101
+ gem install umgr \
102
+ --source "https://rubygems.pkg.github.com/gowda" \
103
+ --key github
104
+ ```
105
+
106
+ ### Gemfile Usage (Private Package)
107
+
108
+ ```ruby
109
+ source "https://rubygems.org"
110
+ source "https://rubygems.pkg.github.com/gowda" do
111
+ gem "umgr"
112
+ end
113
+ ```
114
+
115
+ Configure Bundler authentication for GitHub Packages:
116
+
117
+ ```bash
118
+ bundle config set --global rubygems.pkg.github.com "<GITHUB_USERNAME>:<YOUR_GITHUB_TOKEN>"
119
+ ```
120
+
121
+ ## Public RubyGems Release Path (OIDC)
122
+
123
+ Public RubyGems publishing is gated behind OIDC trusted publishing readiness.
124
+
125
+ - Workflow: `.github/workflows/publish-rubygems.yml`
126
+ - Readiness + promotion doc: [`docs/release-rubygems-oidc.md`](docs/release-rubygems-oidc.md)
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
data/exe/umgr ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'umgr'
5
+
6
+ Umgr::CLI.start(ARGV)
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ # rubocop:disable Metrics/ModuleLength
5
+ module ApplyResultBuilder
6
+ module_function
7
+
8
+ def call(state_backend:, options:, provider_registry:)
9
+ previous_state = state_backend.read
10
+ state_written = false
11
+ write_state_and_build_result(state_backend, options, provider_registry) { state_written = true }
12
+ rescue StandardError => e
13
+ attempt_rollback(state_backend, previous_state) if state_written
14
+ raise e
15
+ end
16
+
17
+ def apply_changes(changes, provider_registry)
18
+ changes.map { |change| apply_change(change, provider_registry) }
19
+ end
20
+
21
+ def apply_change(change, provider_registry)
22
+ return skipped_change_result(change) if change[:action] == 'no_change'
23
+
24
+ provider_name = provider_name_for(change)
25
+ raise Errors::InternalError, "Missing provider for #{change.fetch(:identity)}" unless provider_name
26
+
27
+ provider_result = provider_registry.fetch(provider_name).apply(changeset: change)
28
+ ensure_successful_apply!(provider_result, change)
29
+ applied_change_result(change, provider_name, provider_result)
30
+ end
31
+
32
+ def skipped_change_result(change)
33
+ {
34
+ identity: change.fetch(:identity),
35
+ action: change.fetch(:action),
36
+ status: 'skipped'
37
+ }
38
+ end
39
+
40
+ def ensure_successful_apply!(provider_result, change)
41
+ return unless apply_failed?(provider_result)
42
+
43
+ message = provider_result[:error] || provider_result['error'] || 'unknown provider apply failure'
44
+ raise Errors::InternalError, "Provider apply failed for #{change.fetch(:identity)}: #{message}"
45
+ end
46
+
47
+ def apply_failed?(provider_result)
48
+ return false unless provider_result.is_a?(Hash)
49
+
50
+ provider_result.fetch(:ok, provider_result.fetch('ok', true)) == false
51
+ end
52
+
53
+ def provider_name_for(change)
54
+ resource = change[:desired] || change[:current]
55
+ return nil unless resource
56
+
57
+ resource[:provider] || resource['provider']
58
+ end
59
+
60
+ def build_final_state(desired_state)
61
+ {
62
+ version: desired_state.fetch(:version),
63
+ resources: desired_state.fetch(:resources)
64
+ }
65
+ end
66
+
67
+ def write_state_and_build_result(state_backend, options, provider_registry)
68
+ desired_state = options.fetch(:desired_state)
69
+ plan_result = plan_result_for(state_backend, options, provider_registry)
70
+ apply_results = apply_changes(plan_result.fetch(:changeset).fetch(:changes), provider_registry)
71
+ final_state = build_final_state(desired_state)
72
+ state_backend.write(final_state)
73
+ yield if block_given?
74
+ idempotency = verify_idempotency(state_backend, options, provider_registry)
75
+ payload = result_payload(plan_result, apply_results, idempotency)
76
+ build_result(options: options, state_backend: state_backend, final_state: final_state, payload: payload)
77
+ end
78
+
79
+ def plan_result_for(state_backend, options, provider_registry)
80
+ PlanResultBuilder.call(state_backend: state_backend, options: options, provider_registry: provider_registry)
81
+ end
82
+
83
+ def rollback_state(state_backend, previous_state)
84
+ if previous_state
85
+ state_backend.write(previous_state)
86
+ else
87
+ state_backend.delete
88
+ end
89
+ end
90
+
91
+ def attempt_rollback(state_backend, previous_state)
92
+ rollback_state(state_backend, previous_state)
93
+ rescue StandardError => e
94
+ warn(
95
+ "Rollback failed after apply error (#{e.class}: #{e.message}); " \
96
+ 'original apply error preserved'
97
+ )
98
+ end
99
+
100
+ def applied_change_result(change, provider_name, provider_result)
101
+ {
102
+ identity: change.fetch(:identity),
103
+ action: change.fetch(:action),
104
+ provider: provider_name.to_s,
105
+ status: 'applied',
106
+ provider_result: provider_result
107
+ }
108
+ end
109
+
110
+ def verify_idempotency(state_backend, options, provider_registry)
111
+ post_apply_plan = plan_result_for(state_backend, options, provider_registry)
112
+ summary = post_apply_plan.fetch(:changeset).fetch(:summary)
113
+ return { checked: true, stable: true, summary: summary } if idempotent_summary?(summary)
114
+
115
+ raise Errors::InternalError, "Apply is not idempotent; pending changes remain: #{summary}"
116
+ end
117
+
118
+ def idempotent_summary?(summary)
119
+ summary.fetch(:create).zero? && summary.fetch(:update).zero? && summary.fetch(:delete).zero?
120
+ end
121
+
122
+ def result_payload(plan_result, apply_results, idempotency)
123
+ {
124
+ changeset: plan_result.fetch(:changeset),
125
+ drift: plan_result.fetch(:drift),
126
+ apply_results: apply_results,
127
+ idempotency: idempotency
128
+ }
129
+ end
130
+
131
+ def build_result(options:, state_backend:, final_state:, payload:)
132
+ base_result(options, state_backend, final_state).merge(
133
+ payload
134
+ )
135
+ end
136
+
137
+ def base_result(options, state_backend, final_state)
138
+ {
139
+ ok: true,
140
+ action: 'apply',
141
+ status: 'applied',
142
+ options: options,
143
+ state_path: state_backend.path,
144
+ state: final_state
145
+ }
146
+ end
147
+ end
148
+ # rubocop:enable Metrics/ModuleLength
149
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module ChangeSetBuilder
5
+ ACTIONS = %w[create update delete no_change].freeze
6
+
7
+ module_function
8
+
9
+ def call(desired_resources:, current_resources:)
10
+ desired_index = index_resources(desired_resources)
11
+ current_index = index_resources(current_resources)
12
+ identities = (desired_index.keys + current_index.keys).uniq.sort
13
+ changes = build_changes(identities, desired_index, current_index)
14
+ {
15
+ changes: changes,
16
+ summary: summarize(changes)
17
+ }
18
+ end
19
+
20
+ def build_changes(identities, desired_index, current_index)
21
+ identities.map do |identity|
22
+ desired = desired_index[identity]
23
+ current = current_index[identity]
24
+ {
25
+ identity: identity,
26
+ action: action_for(desired: desired, current: current),
27
+ desired: desired,
28
+ current: current
29
+ }
30
+ end
31
+ end
32
+
33
+ def action_for(desired:, current:)
34
+ return 'create' if desired && !current
35
+ return 'delete' if current && !desired
36
+ return 'no_change' if desired == current
37
+
38
+ 'update'
39
+ end
40
+
41
+ def summarize(changes)
42
+ ACTIONS.to_h { |action| [action.to_sym, changes.count { |change| change[:action] == action }] }
43
+ end
44
+
45
+ def index_resources(resources)
46
+ resources.to_h { |resource| [resource.fetch(:identity), resource] }
47
+ end
48
+ end
49
+ end
data/lib/umgr/cli.rb ADDED
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'thor'
5
+
6
+ module Umgr
7
+ class CLI < Thor
8
+ desc 'version', 'Print umgr version'
9
+ def version
10
+ puts Umgr::VERSION
11
+ end
12
+
13
+ desc 'help [COMMAND]', 'Describe available commands or one specific command'
14
+ def help(command = nil)
15
+ super
16
+ end
17
+
18
+ desc 'init', 'Initialize umgr state'
19
+ def init
20
+ execute(:init)
21
+ end
22
+
23
+ desc 'validate', 'Validate configuration'
24
+ option :config, type: :string, desc: 'Path to config file'
25
+ def validate
26
+ execute(:validate, **command_options)
27
+ end
28
+
29
+ desc 'plan', 'Generate plan from desired state'
30
+ option :config, type: :string, desc: 'Path to config file'
31
+ option :json, type: :boolean, default: false, desc: 'Render plan output as JSON'
32
+ def plan
33
+ execute(:plan, **command_options)
34
+ end
35
+
36
+ desc 'apply', 'Apply desired state'
37
+ option :config, type: :string, desc: 'Path to config file'
38
+ def apply
39
+ execute(:apply, **command_options)
40
+ end
41
+
42
+ desc 'show', 'Show current state'
43
+ def show
44
+ execute(:show)
45
+ end
46
+
47
+ desc 'import', 'Import current users from providers'
48
+ option :config, type: :string, desc: 'Path to config file'
49
+ def import
50
+ execute(:import, **command_options)
51
+ end
52
+
53
+ private
54
+
55
+ def runner
56
+ @runner ||= Umgr::Runner.new
57
+ end
58
+
59
+ def command_options
60
+ options.to_h.transform_keys(&:to_sym)
61
+ end
62
+
63
+ def render_result(action, result, options)
64
+ return render_plan_result(result, options) if action == :plan
65
+
66
+ puts JSON.generate(result)
67
+ end
68
+
69
+ def render_plan_result(result, options)
70
+ return puts(JSON.generate(result)) if options[:json]
71
+
72
+ puts drift_status_line(result.fetch(:drift))
73
+ summary = result.fetch(:changeset).fetch(:summary)
74
+ puts plan_summary_line(summary)
75
+ render_plan_changes(result.fetch(:changeset).fetch(:changes))
76
+ end
77
+
78
+ def execute(action, **options)
79
+ render_result(action, runner.dispatch(action, **options), options)
80
+ rescue Errors::Error => e
81
+ render_error(e)
82
+ exit(e.exit_code)
83
+ rescue StandardError => e
84
+ internal = Errors::InternalError.new(e.message)
85
+ render_error(internal)
86
+ exit(internal.exit_code)
87
+ end
88
+
89
+ def plan_summary_line(summary)
90
+ "Plan summary: create=#{summary.fetch(:create)} update=#{summary.fetch(:update)} " \
91
+ "delete=#{summary.fetch(:delete)} no_change=#{summary.fetch(:no_change)}"
92
+ end
93
+
94
+ def render_plan_changes(changes)
95
+ changes.each do |change|
96
+ puts "#{change.fetch(:action).upcase} #{change.fetch(:identity)}"
97
+ end
98
+ end
99
+
100
+ def drift_status_line(drift)
101
+ detected = drift.fetch(:detected) ? 'yes' : 'no'
102
+ "Drift detected: #{detected} (changes=#{drift.fetch(:change_count)})"
103
+ end
104
+
105
+ def render_error(error)
106
+ warn JSON.generate(
107
+ {
108
+ ok: false,
109
+ error: {
110
+ type: error.class.name,
111
+ message: error.message
112
+ }
113
+ }
114
+ )
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ module Umgr
7
+ class ConfigValidator
8
+ REQUIRED_TOP_LEVEL_KEYS = %w[version resources].freeze
9
+ REQUIRED_RESOURCE_FIELDS = %w[provider type name].freeze
10
+
11
+ def self.validated_config(config_path)
12
+ new(config_path).validated_config
13
+ end
14
+
15
+ def self.validate!(config_path)
16
+ new(config_path).validate!
17
+ end
18
+
19
+ def initialize(config_path)
20
+ @config_path = config_path
21
+ end
22
+
23
+ def validated_config
24
+ parsed = parse
25
+ validate_root!(parsed)
26
+ validate_resources!(parsed['resources'])
27
+ parsed
28
+ end
29
+
30
+ def validate!
31
+ validated_config
32
+ nil
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :config_path
38
+
39
+ def parse
40
+ content = File.read(config_path)
41
+ parse_by_extension(content)
42
+ rescue JSON::ParserError, Psych::SyntaxError => e
43
+ raise Errors::ValidationError, "Config parse error in #{config_path}: #{e.message}"
44
+ end
45
+
46
+ def parse_by_extension(content)
47
+ case File.extname(config_path).downcase
48
+ when '.json'
49
+ JSON.parse(content)
50
+ when '.yml', '.yaml'
51
+ YAML.safe_load(content, aliases: false)
52
+ else
53
+ raise Errors::ValidationError, "Unsupported config format: #{config_path}"
54
+ end
55
+ end
56
+
57
+ def validate_root!(parsed)
58
+ raise Errors::ValidationError, "Config root must be an object in #{config_path}" unless parsed.is_a?(Hash)
59
+
60
+ REQUIRED_TOP_LEVEL_KEYS.each do |key|
61
+ next if parsed.key?(key)
62
+
63
+ raise Errors::ValidationError, "Missing required key `#{key}` in #{config_path}"
64
+ end
65
+
66
+ version = parsed['version']
67
+ return if version.is_a?(Integer) && version.positive?
68
+
69
+ raise Errors::ValidationError, "`version` must be a positive integer in #{config_path}"
70
+ end
71
+
72
+ def validate_resources!(resources)
73
+ raise Errors::ValidationError, "`resources` must be an array in #{config_path}" unless resources.is_a?(Array)
74
+
75
+ resources.each_with_index do |resource, index|
76
+ validate_resource!(resource, index)
77
+ end
78
+ end
79
+
80
+ def validate_resource!(resource, index)
81
+ unless resource.is_a?(Hash)
82
+ raise Errors::ValidationError, "Resource at index #{index} must be an object in #{config_path}"
83
+ end
84
+
85
+ REQUIRED_RESOURCE_FIELDS.each do |field|
86
+ value = resource[field]
87
+ next if value.is_a?(String) && !value.empty?
88
+
89
+ raise Errors::ValidationError, "Resource #{index} missing required string field `#{field}` in #{config_path}"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module DeepSymbolizer
5
+ module_function
6
+
7
+ def call(value)
8
+ case value
9
+ when Hash
10
+ symbolize_hash(value)
11
+ when Array
12
+ value.map { |item| call(item) }
13
+ else
14
+ value
15
+ end
16
+ end
17
+
18
+ def symbolize_hash(value)
19
+ value.each_with_object({}) do |(key, nested_value), memo|
20
+ symbol_key = key.is_a?(String) ? key.to_sym : key
21
+ memo[symbol_key] = call(nested_value)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module DesiredStateEnricher
5
+ module_function
6
+
7
+ def call(desired_state)
8
+ resources = desired_state.fetch(:resources, []).map do |resource|
9
+ resource.merge(identity: ResourceIdentity.call(resource))
10
+ end
11
+
12
+ desired_state.merge(resources: resources)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module DriftReportBuilder
5
+ DRIFT_ACTIONS = %i[create update delete].freeze
6
+
7
+ module_function
8
+
9
+ def call(summary)
10
+ drift_counts = DRIFT_ACTIONS.to_h { |action| [action, summary.fetch(action, 0)] }
11
+ change_count = drift_counts.values.sum
12
+ {
13
+ detected: change_count.positive?,
14
+ change_count: change_count,
15
+ actions: drift_counts
16
+ }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Umgr
4
+ module Errors
5
+ class Error < StandardError
6
+ EXIT_CODE = 1
7
+
8
+ def exit_code
9
+ self.class::EXIT_CODE
10
+ end
11
+ end
12
+
13
+ class ValidationError < Error
14
+ EXIT_CODE = 2
15
+ end
16
+
17
+ class UnknownActionError < Error
18
+ EXIT_CODE = 3
19
+ end
20
+
21
+ class InternalError < Error
22
+ EXIT_CODE = 70
23
+ end
24
+
25
+ class ApiError < Error
26
+ EXIT_CODE = InternalError::EXIT_CODE
27
+ end
28
+
29
+ class AbstractMethodError < Error
30
+ end
31
+
32
+ class ProviderContractError < Error
33
+ EXIT_CODE = ValidationError::EXIT_CODE
34
+ end
35
+ end
36
+ end