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
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
|
+
[](https://github.com/gowda/umgr/actions/workflows/checks.yml)
|
|
4
|
+
[](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
data/exe/umgr
ADDED
|
@@ -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
|
data/lib/umgr/errors.rb
ADDED
|
@@ -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
|