moose-inventory 2.0 → 2.1
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 +4 -4
- data/.github/workflows/release.yml +2 -0
- data/.gitignore +2 -1
- data/.rubocop.yml +21 -0
- data/BACKLOG.md +630 -8
- data/Gemfile +2 -0
- data/Gemfile.lock +1 -1
- data/README.md +315 -39
- data/Rakefile +2 -0
- data/bin/moose-inventory +2 -1
- data/docs/architecture/architecture-and-trust-boundaries.md +444 -0
- data/docs/compatibility/cli-output-compatibility.md +76 -0
- data/docs/governance/approval-register.md +37 -0
- data/docs/maintenance/database-backup-restore-guidance.md +162 -0
- data/docs/maintenance/package-maintenance-and-agent-boundaries.md +260 -0
- data/docs/process/conformance-gap-analysis-2026-05-28.md +192 -0
- data/docs/product/product-brief.md +161 -0
- data/docs/product/requirements-baseline.md +477 -0
- data/docs/qa/qa-documentation-and-release-gates.md +283 -0
- data/docs/release/package-provenance-hardening.md +126 -0
- data/docs/release/publishing.md +11 -3
- data/docs/release/release-environment-protection.md +70 -0
- data/docs/release/release-readiness.md +23 -4
- data/docs/security/accepted-risk-register.md +84 -0
- data/docs/security/security-privacy-process.md +287 -0
- data/docs/security-audit-2026-05-26-rerun.md +2 -2
- data/docs/ux/cli-workflow-notes.md +287 -0
- data/examples/ansible/ansible.cfg +3 -0
- data/examples/ansible/inventory/moose_inventory.yml +5 -0
- data/examples/ansible/inventory_plugins/moose_inventory.py +100 -0
- data/examples/ci/README.md +16 -0
- data/examples/ci/github-actions/inventory-review.yml +38 -0
- data/examples/ci/inventory/example-snapshot.yml +19 -0
- data/examples/ci/scripts/validate-inventory-snapshot.sh +30 -0
- data/lib/moose_inventory/cli/application.rb +133 -5
- data/lib/moose_inventory/cli/association_rendering.rb +74 -0
- data/lib/moose_inventory/cli/association_rendering_support.rb +89 -0
- data/lib/moose_inventory/cli/audit.rb +62 -0
- data/lib/moose_inventory/cli/audit_recording.rb +40 -0
- data/lib/moose_inventory/cli/child_relation_rendering.rb +110 -0
- data/lib/moose_inventory/cli/console.rb +135 -0
- data/lib/moose_inventory/cli/db.rb +64 -0
- data/lib/moose_inventory/cli/factory.rb +28 -0
- data/lib/moose_inventory/cli/formatter.rb +8 -12
- data/lib/moose_inventory/cli/group.rb +5 -2
- data/lib/moose_inventory/cli/group_add.rb +11 -9
- data/lib/moose_inventory/cli/group_addchild.rb +23 -65
- data/lib/moose_inventory/cli/group_addhost.rb +16 -67
- data/lib/moose_inventory/cli/group_addvar.rb +27 -47
- data/lib/moose_inventory/cli/group_get.rb +8 -42
- data/lib/moose_inventory/cli/group_list.rb +7 -40
- data/lib/moose_inventory/cli/group_listvars.rb +9 -55
- data/lib/moose_inventory/cli/group_rm.rb +12 -10
- data/lib/moose_inventory/cli/group_rmchild.rb +26 -82
- data/lib/moose_inventory/cli/group_rmhost.rb +18 -53
- data/lib/moose_inventory/cli/group_rmvar.rb +30 -41
- data/lib/moose_inventory/cli/group_tags.rb +33 -0
- data/lib/moose_inventory/cli/helpers.rb +68 -1
- data/lib/moose_inventory/cli/host.rb +6 -3
- data/lib/moose_inventory/cli/host_add.rb +69 -29
- data/lib/moose_inventory/cli/host_addgroup.rb +22 -58
- data/lib/moose_inventory/cli/host_addvar.rb +28 -52
- data/lib/moose_inventory/cli/host_get.rb +9 -37
- data/lib/moose_inventory/cli/host_list.rb +24 -21
- data/lib/moose_inventory/cli/host_listvars.rb +9 -62
- data/lib/moose_inventory/cli/host_rm.rb +60 -42
- data/lib/moose_inventory/cli/host_rmgroup.rb +25 -44
- data/lib/moose_inventory/cli/host_rmvar.rb +31 -45
- data/lib/moose_inventory/cli/host_tags.rb +33 -0
- data/lib/moose_inventory/cli/listvars_support.rb +55 -0
- data/lib/moose_inventory/cli/plan_rendering.rb +50 -0
- data/lib/moose_inventory/cli/relation_transaction_support.rb +51 -0
- data/lib/moose_inventory/cli/tag_support.rb +97 -0
- data/lib/moose_inventory/cli/variable_rendering.rb +67 -0
- data/lib/moose_inventory/config/config.rb +185 -108
- data/lib/moose_inventory/db/db.rb +170 -195
- data/lib/moose_inventory/db/exceptions.rb +6 -3
- data/lib/moose_inventory/db/models.rb +16 -0
- data/lib/moose_inventory/db/schema_migrations.rb +248 -0
- data/lib/moose_inventory/inventory_context.rb +68 -2
- data/lib/moose_inventory/operations/add_associations.rb +20 -16
- data/lib/moose_inventory/operations/add_groups.rb +21 -13
- data/lib/moose_inventory/operations/add_hosts.rb +30 -17
- data/lib/moose_inventory/operations/add_variables.rb +77 -0
- data/lib/moose_inventory/operations/entity_variable_operation_support.rb +46 -0
- data/lib/moose_inventory/operations/group_child_relations.rb +23 -16
- data/lib/moose_inventory/operations/group_cleanup.rb +23 -8
- data/lib/moose_inventory/operations/import_inventory_snapshot.rb +41 -0
- data/lib/moose_inventory/operations/inventory_doctor.rb +172 -0
- data/lib/moose_inventory/operations/inventory_snapshot.rb +60 -0
- data/lib/moose_inventory/operations/inventory_snapshot_applier.rb +112 -0
- data/lib/moose_inventory/operations/inventory_snapshot_preview.rb +174 -0
- data/lib/moose_inventory/operations/inventory_snapshot_validator.rb +134 -0
- data/lib/moose_inventory/operations/operation_event_support.rb +27 -0
- data/lib/moose_inventory/operations/query_inventory/base_query.rb +24 -0
- data/lib/moose_inventory/operations/query_inventory/group_queries.rb +86 -0
- data/lib/moose_inventory/operations/query_inventory/host_queries.rb +106 -0
- data/lib/moose_inventory/operations/query_inventory.rb +47 -0
- data/lib/moose_inventory/operations/remove_associations.rb +30 -18
- data/lib/moose_inventory/operations/remove_groups.rb +12 -12
- data/lib/moose_inventory/operations/remove_hosts.rb +68 -0
- data/lib/moose_inventory/operations/remove_variables.rb +67 -0
- data/lib/moose_inventory/runtime_options.rb +31 -0
- data/lib/moose_inventory/version.rb +3 -1
- data/lib/moose_inventory.rb +10 -7
- data/moose-inventory.gemspec +19 -35
- data/scripts/check.sh +1 -0
- data/scripts/ci/check_generated_artifacts.sh +41 -0
- data/scripts/ci/check_permissions.sh +2 -0
- data/scripts/ci/check_rubocop.sh +30 -25
- data/scripts/files.rb +5 -4
- data/spec/examples/ci_examples_spec.rb +37 -0
- data/spec/lib/moose_inventory/ansible_plugin_examples_spec.rb +29 -0
- data/spec/lib/moose_inventory/cli/application_doctor_spec.rb +50 -0
- data/spec/lib/moose_inventory/cli/application_import_export_spec.rb +100 -0
- data/spec/lib/moose_inventory/cli/application_spec.rb +25 -15
- data/spec/lib/moose_inventory/cli/audit_spec.rb +56 -0
- data/spec/lib/moose_inventory/cli/cli_spec.rb +15 -19
- data/spec/lib/moose_inventory/cli/console_spec.rb +98 -0
- data/spec/lib/moose_inventory/cli/factory_spec.rb +27 -0
- data/spec/lib/moose_inventory/cli/formatter_spec.rb +95 -3
- data/spec/lib/moose_inventory/cli/group_add_spec.rb +140 -116
- data/spec/lib/moose_inventory/cli/group_addchild_spec.rb +89 -35
- data/spec/lib/moose_inventory/cli/group_addhost_spec.rb +81 -84
- data/spec/lib/moose_inventory/cli/group_addvar_spec.rb +65 -68
- data/spec/lib/moose_inventory/cli/group_get_spec.rb +17 -33
- data/spec/lib/moose_inventory/cli/group_list_spec.rb +16 -38
- data/spec/lib/moose_inventory/cli/group_listvar_spec.rb +33 -40
- data/spec/lib/moose_inventory/cli/group_rm_spec.rb +136 -96
- data/spec/lib/moose_inventory/cli/group_rmchild_spec.rb +66 -41
- data/spec/lib/moose_inventory/cli/group_rmhost_spec.rb +76 -78
- data/spec/lib/moose_inventory/cli/group_rmvar_spec.rb +57 -63
- data/spec/lib/moose_inventory/cli/group_spec.rb +2 -0
- data/spec/lib/moose_inventory/cli/helpers_spec.rb +146 -0
- data/spec/lib/moose_inventory/cli/host_add_spec.rb +170 -116
- data/spec/lib/moose_inventory/cli/host_addgroup_spec.rb +100 -83
- data/spec/lib/moose_inventory/cli/host_addvar_spec.rb +92 -74
- data/spec/lib/moose_inventory/cli/host_get_spec.rb +14 -33
- data/spec/lib/moose_inventory/cli/host_list_spec.rb +41 -33
- data/spec/lib/moose_inventory/cli/host_listvar_spec.rb +45 -53
- data/spec/lib/moose_inventory/cli/host_rm_spec.rb +66 -48
- data/spec/lib/moose_inventory/cli/host_rmgroup_spec.rb +73 -83
- data/spec/lib/moose_inventory/cli/host_rmvar_spec.rb +56 -63
- data/spec/lib/moose_inventory/cli/host_spec.rb +2 -0
- data/spec/lib/moose_inventory/cli/tags_spec.rb +81 -0
- data/spec/lib/moose_inventory/config/config_spec.rb +41 -3
- data/spec/lib/moose_inventory/db/db_spec.rb +396 -36
- data/spec/lib/moose_inventory/db/exceptions_spec.rb +18 -0
- data/spec/lib/moose_inventory/db/models_spec.rb +7 -3
- data/spec/lib/moose_inventory/db_lifecycle_spec.rb +73 -0
- data/spec/lib/moose_inventory/inventory_context_spec.rb +10 -0
- data/spec/lib/moose_inventory/operations/add_associations_spec.rb +34 -0
- data/spec/lib/moose_inventory/operations/add_groups_spec.rb +15 -0
- data/spec/lib/moose_inventory/operations/add_hosts_spec.rb +13 -0
- data/spec/lib/moose_inventory/operations/add_variables_spec.rb +103 -0
- data/spec/lib/moose_inventory/operations/group_child_relations_spec.rb +46 -0
- data/spec/lib/moose_inventory/operations/import_inventory_snapshot_spec.rb +226 -0
- data/spec/lib/moose_inventory/operations/inventory_doctor_spec.rb +77 -0
- data/spec/lib/moose_inventory/operations/inventory_snapshot_spec.rb +50 -0
- data/spec/lib/moose_inventory/operations/operation_event_support_spec.rb +78 -0
- data/spec/lib/moose_inventory/operations/query_inventory_spec.rb +146 -0
- data/spec/lib/moose_inventory/operations/remove_associations_spec.rb +35 -0
- data/spec/lib/moose_inventory/operations/remove_groups_spec.rb +21 -0
- data/spec/lib/moose_inventory/operations/remove_hosts_spec.rb +55 -0
- data/spec/lib/moose_inventory/operations/remove_variables_spec.rb +83 -0
- data/spec/shared/shared_config_setup.rb +4 -3
- data/spec/spec_helper.rb +50 -40
- data/spec/support/cli_harness.rb +33 -0
- metadata +80 -41
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'entity_variable_operation_support'
|
|
4
|
+
|
|
5
|
+
module Moose
|
|
6
|
+
module Inventory
|
|
7
|
+
module Operations
|
|
8
|
+
# Adds host/group variables and updates existing values when needed.
|
|
9
|
+
class AddVariables
|
|
10
|
+
include EntityVariableOperationSupport
|
|
11
|
+
|
|
12
|
+
def call(name:, vars:, dry_run: false)
|
|
13
|
+
@events = []
|
|
14
|
+
@dry_run = dry_run
|
|
15
|
+
|
|
16
|
+
emit(:entity_started, name: name)
|
|
17
|
+
emit(:retrieving_entity, name: name)
|
|
18
|
+
entity = find_entity(name)
|
|
19
|
+
raise_missing_entity(name) if entity.nil?
|
|
20
|
+
|
|
21
|
+
emit(:ok, indent: 4)
|
|
22
|
+
|
|
23
|
+
dataset = entity.public_send("#{entity_type}vars_dataset")
|
|
24
|
+
vars.each do |variable|
|
|
25
|
+
add_variable(entity, dataset, variable)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
emit(:entity_complete)
|
|
29
|
+
emit(:dry_run_summary) if dry_run
|
|
30
|
+
operation_result(events: events)
|
|
31
|
+
ensure
|
|
32
|
+
@events = nil
|
|
33
|
+
@dry_run = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :dry_run
|
|
39
|
+
|
|
40
|
+
def add_variable(entity, dataset, variable)
|
|
41
|
+
emit(:adding_variable, variable: variable)
|
|
42
|
+
key, value = parse_variable(variable)
|
|
43
|
+
|
|
44
|
+
existing = dataset[name: key]
|
|
45
|
+
if existing.nil?
|
|
46
|
+
unless dry_run
|
|
47
|
+
record = context.create_variable(entity_type, name: key, value: value)
|
|
48
|
+
entity.public_send("add_#{entity_type}var", record)
|
|
49
|
+
end
|
|
50
|
+
elsif existing[:value] != value
|
|
51
|
+
emit(:updating_existing_variable)
|
|
52
|
+
unless dry_run
|
|
53
|
+
update = context.find_variable(entity_type, existing[:id])
|
|
54
|
+
update[:value] = value
|
|
55
|
+
update.save
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
emit(:ok, indent: 4)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_variable(variable)
|
|
63
|
+
parts = variable.split('=')
|
|
64
|
+
invalid = variable.start_with?('=') || variable.end_with?('=') || parts.length != 2
|
|
65
|
+
raise_invalid_variable(variable) if invalid
|
|
66
|
+
|
|
67
|
+
parts
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def raise_invalid_variable(variable)
|
|
71
|
+
raise context.moose_exception_class,
|
|
72
|
+
"Incorrect format in '{#{variable}}'. Expected 'key=value'."
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'operation_event_support'
|
|
4
|
+
|
|
5
|
+
module Moose
|
|
6
|
+
module Inventory
|
|
7
|
+
module Operations
|
|
8
|
+
module EntityVariableOperationSupport
|
|
9
|
+
include OperationEventSupport
|
|
10
|
+
|
|
11
|
+
def initialize(context:, entity_type:, emitter: nil)
|
|
12
|
+
@context = context
|
|
13
|
+
@entity_type = entity_type
|
|
14
|
+
@emitter = emitter
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :context, :emitter, :entity_type, :events
|
|
20
|
+
|
|
21
|
+
def find_entity(name)
|
|
22
|
+
case entity_type
|
|
23
|
+
when :host
|
|
24
|
+
context.find_host(name)
|
|
25
|
+
when :group
|
|
26
|
+
context.find_group(name)
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError, "Unsupported entity type: #{entity_type.inspect}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def raise_missing_entity(name)
|
|
33
|
+
label = entity_type == :host ? 'host' : 'group'
|
|
34
|
+
raise context.moose_exception_class, "The #{label} '#{name}' does not exist."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def emit(type, payload = {})
|
|
38
|
+
event = build_event(type, payload)
|
|
39
|
+
events << event
|
|
40
|
+
emitter&.call(event)
|
|
41
|
+
event
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'operation_event_support'
|
|
4
|
+
|
|
3
5
|
require_relative 'group_cleanup'
|
|
4
6
|
|
|
5
7
|
module Moose
|
|
6
8
|
module Inventory
|
|
7
9
|
module Operations
|
|
8
10
|
class GroupChildRelations
|
|
9
|
-
|
|
10
|
-
Result = Struct.new(:events, :warning_count, keyword_init: true)
|
|
11
|
+
include OperationEventSupport
|
|
11
12
|
|
|
12
13
|
def initialize(context:)
|
|
13
14
|
@context = context
|
|
@@ -17,10 +18,11 @@ module Moose
|
|
|
17
18
|
)
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
def add_children(parent_group:, parent_name:, child_names:)
|
|
21
|
+
def add_children(parent_group:, parent_name:, child_names:, dry_run: false)
|
|
21
22
|
events = []
|
|
22
23
|
warning_count = 0
|
|
23
24
|
children_dataset = parent_group.children_dataset
|
|
25
|
+
@dry_run = dry_run
|
|
24
26
|
|
|
25
27
|
child_names.each do |child_name|
|
|
26
28
|
next if child_name.nil? || child_name.empty?
|
|
@@ -28,13 +30,17 @@ module Moose
|
|
|
28
30
|
warning_count += add_child(parent_group, parent_name, child_name, children_dataset, events)
|
|
29
31
|
end
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
emit(events, :dry_run_summary) if dry_run
|
|
34
|
+
|
|
35
|
+
operation_result(events: events, warning_count: warning_count)
|
|
32
36
|
end
|
|
33
37
|
|
|
34
|
-
def remove_children(parent_group:, parent_name:, child_names:, delete_orphans: false)
|
|
38
|
+
def remove_children(parent_group:, parent_name:, child_names:, delete_orphans: false, dry_run: false)
|
|
35
39
|
events = []
|
|
36
40
|
warning_count = 0
|
|
37
41
|
children_dataset = parent_group.children_dataset
|
|
42
|
+
@dry_run = dry_run
|
|
43
|
+
cleanup.dry_run = dry_run
|
|
38
44
|
|
|
39
45
|
child_names.each do |child_name|
|
|
40
46
|
next if child_name.nil? || child_name.empty?
|
|
@@ -46,17 +52,20 @@ module Moose
|
|
|
46
52
|
child_name: child_name,
|
|
47
53
|
children_dataset: children_dataset,
|
|
48
54
|
events: events,
|
|
49
|
-
delete_orphans: delete_orphans
|
|
55
|
+
delete_orphans: delete_orphans,
|
|
56
|
+
dry_run: dry_run
|
|
50
57
|
}
|
|
51
58
|
)
|
|
52
59
|
end
|
|
53
60
|
|
|
54
|
-
|
|
61
|
+
emit(events, :dry_run_summary) if dry_run
|
|
62
|
+
|
|
63
|
+
operation_result(events: events, warning_count: warning_count)
|
|
55
64
|
end
|
|
56
65
|
|
|
57
66
|
private
|
|
58
67
|
|
|
59
|
-
attr_reader :cleanup, :context
|
|
68
|
+
attr_reader :cleanup, :context, :dry_run
|
|
60
69
|
|
|
61
70
|
def add_child(parent_group, parent_name, child_name, children_dataset, events)
|
|
62
71
|
emit(events, :adding_child_association, parent: parent_name, child: child_name)
|
|
@@ -73,12 +82,12 @@ module Moose
|
|
|
73
82
|
if child_group.nil?
|
|
74
83
|
emit(events, :child_group_missing, name: child_name)
|
|
75
84
|
emit(events, :child_group_creating_now, name: child_name)
|
|
76
|
-
child_group = context.create_group(child_name)
|
|
85
|
+
child_group = context.create_group(child_name) unless dry_run
|
|
77
86
|
emit(events, :ok, indent: 6)
|
|
78
87
|
warning_count = 1
|
|
79
88
|
end
|
|
80
89
|
|
|
81
|
-
parent_group.add_child(child_group)
|
|
90
|
+
parent_group.add_child(child_group) unless dry_run
|
|
82
91
|
emit(events, :ok, indent: 4)
|
|
83
92
|
warning_count
|
|
84
93
|
end
|
|
@@ -99,19 +108,17 @@ module Moose
|
|
|
99
108
|
end
|
|
100
109
|
|
|
101
110
|
child_group = context.find_group(input[:child_name])
|
|
102
|
-
input[:parent_group].remove_child(child_group)
|
|
111
|
+
input[:parent_group].remove_child(child_group) unless input[:dry_run]
|
|
103
112
|
emit(input[:events], :ok, indent: 4)
|
|
104
|
-
|
|
113
|
+
if input[:delete_orphans]
|
|
114
|
+
cleanup.delete_orphaned_group(child_group, input[:events], ignored_parent: input[:parent_group])
|
|
115
|
+
end
|
|
105
116
|
0
|
|
106
117
|
end
|
|
107
118
|
|
|
108
119
|
def association_exists?(dataset, name)
|
|
109
120
|
!dataset.nil? && !dataset[name: name].nil?
|
|
110
121
|
end
|
|
111
|
-
|
|
112
|
-
def emit(events, type, payload = {})
|
|
113
|
-
events << Event.new(type: type, payload: payload)
|
|
114
|
-
end
|
|
115
122
|
end
|
|
116
123
|
end
|
|
117
124
|
end
|
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'operation_event_support'
|
|
4
|
+
|
|
3
5
|
module Moose
|
|
4
6
|
module Inventory
|
|
5
7
|
module Operations
|
|
6
8
|
# Recursively cleans up orphaned groups and their dependent relations.
|
|
7
9
|
class GroupCleanup
|
|
10
|
+
include OperationEventSupport
|
|
11
|
+
|
|
8
12
|
AUTOMATIC_GROUP = 'ungrouped'
|
|
9
13
|
|
|
14
|
+
attr_accessor :dry_run
|
|
15
|
+
|
|
10
16
|
def initialize(context:, emitter:)
|
|
11
17
|
@context = context
|
|
12
18
|
@emitter = emitter
|
|
19
|
+
@dry_run = false
|
|
13
20
|
end
|
|
14
21
|
|
|
15
|
-
def delete_orphaned_group(group, events)
|
|
22
|
+
def delete_orphaned_group(group, events, ignored_parent: nil)
|
|
16
23
|
return if group.name == AUTOMATIC_GROUP
|
|
17
|
-
return unless group
|
|
24
|
+
return unless orphaned_after_planned_removal?(group, ignored_parent)
|
|
18
25
|
|
|
19
26
|
emit(events, :recursively_delete_orphaned_group, name: group.name)
|
|
20
27
|
group.children_dataset.each do |child|
|
|
21
28
|
emit(events, :removing_recursive_child_association, parent: group.name, child: child.name)
|
|
22
|
-
group.remove_child(child)
|
|
29
|
+
group.remove_child(child) unless dry_run
|
|
23
30
|
emit(events, :ok, indent: 6)
|
|
24
|
-
delete_orphaned_group(child, events)
|
|
31
|
+
delete_orphaned_group(child, events, ignored_parent: group)
|
|
25
32
|
end
|
|
26
33
|
destroy_group(group, events, indent: 4)
|
|
27
34
|
end
|
|
@@ -31,14 +38,16 @@ module Moose
|
|
|
31
38
|
next unless host.groups_dataset.one?
|
|
32
39
|
|
|
33
40
|
emit(events, :adding_automatic_group_to_host, host: host[:name], indent: indent)
|
|
34
|
-
host.add_group(context.automatic_group)
|
|
41
|
+
host.add_group(context.automatic_group) unless dry_run
|
|
35
42
|
emit(events, :ok, indent: indent + 2)
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
emit(events, :destroying_group, name: group.name, indent: indent)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
unless dry_run
|
|
47
|
+
group.remove_all_groupvars
|
|
48
|
+
group.remove_all_hosts
|
|
49
|
+
group.destroy
|
|
50
|
+
end
|
|
42
51
|
emit(events, :ok, indent: indent + 2)
|
|
43
52
|
end
|
|
44
53
|
|
|
@@ -46,6 +55,12 @@ module Moose
|
|
|
46
55
|
|
|
47
56
|
attr_reader :context, :emitter
|
|
48
57
|
|
|
58
|
+
def orphaned_after_planned_removal?(group, ignored_parent)
|
|
59
|
+
group.parents_dataset.none? do |parent|
|
|
60
|
+
!ignored_parent || parent.name != ignored_parent.name
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
49
64
|
def emit(events, type, payload = {})
|
|
50
65
|
emitter.call(events, type, payload)
|
|
51
66
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'inventory_snapshot_applier'
|
|
4
|
+
require_relative 'inventory_snapshot_preview'
|
|
5
|
+
require_relative 'inventory_snapshot_validator'
|
|
6
|
+
|
|
7
|
+
module Moose
|
|
8
|
+
module Inventory
|
|
9
|
+
module Operations
|
|
10
|
+
# Validates and imports a portable inventory snapshot.
|
|
11
|
+
class ImportInventorySnapshot
|
|
12
|
+
Result = InventorySnapshotApplier::Result
|
|
13
|
+
|
|
14
|
+
def initialize(context:)
|
|
15
|
+
@context = context
|
|
16
|
+
@validator = InventorySnapshotValidator.new(context: context)
|
|
17
|
+
@applier = InventorySnapshotApplier.new(context: context)
|
|
18
|
+
@previewer = InventorySnapshotPreview.new(context: context)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(snapshot:)
|
|
22
|
+
normalized = validator.call(snapshot: snapshot)
|
|
23
|
+
|
|
24
|
+
context.transaction do
|
|
25
|
+
applier.call(snapshot: normalized)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def preview(snapshot:)
|
|
30
|
+
normalized = validator.call(snapshot: snapshot)
|
|
31
|
+
|
|
32
|
+
previewer.call(snapshot: normalized)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :context, :validator, :applier, :previewer
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ClassLength
|
|
4
|
+
module Moose
|
|
5
|
+
module Inventory
|
|
6
|
+
module Operations
|
|
7
|
+
# Runs read-only inventory health checks for humans and CI.
|
|
8
|
+
class InventoryDoctor
|
|
9
|
+
AUTOMATIC_GROUP = 'ungrouped'
|
|
10
|
+
|
|
11
|
+
def initialize(context:, config: Moose::Inventory::Config)
|
|
12
|
+
@context = context
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
issues = []
|
|
18
|
+
issues.concat(check_database_config)
|
|
19
|
+
issues.concat(check_plaintext_password_config)
|
|
20
|
+
issues.concat(check_hosts_only_in_automatic_group)
|
|
21
|
+
issues.concat(check_orphaned_groups)
|
|
22
|
+
issues.concat(check_empty_groups)
|
|
23
|
+
issues.concat(check_duplicateish_names)
|
|
24
|
+
issues.concat(check_invalid_variables)
|
|
25
|
+
issues.concat(check_group_cycles)
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
ok: issues.empty?,
|
|
29
|
+
issue_count: issues.length,
|
|
30
|
+
issues: issues
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :context, :config
|
|
37
|
+
|
|
38
|
+
def check_database_config
|
|
39
|
+
settings = config.db_settings
|
|
40
|
+
return [issue('missing_db_config', 'error', 'Database configuration is missing.')] unless settings.is_a?(Hash)
|
|
41
|
+
return [] if settings[:adapter].to_s.strip != ''
|
|
42
|
+
|
|
43
|
+
[issue('missing_db_adapter', 'error', 'Database adapter is missing from configuration.')]
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
[issue('missing_db_config', 'error', "Database configuration could not be read: #{e.message}")]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def check_plaintext_password_config
|
|
49
|
+
settings = config.db_settings
|
|
50
|
+
return [] unless settings.is_a?(Hash) && settings.key?(:password)
|
|
51
|
+
|
|
52
|
+
[
|
|
53
|
+
issue('plaintext_password_config', 'warning',
|
|
54
|
+
'Database configuration uses plaintext password; prefer password_env.')
|
|
55
|
+
]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_hosts_only_in_automatic_group
|
|
59
|
+
context.all_hosts.filter_map do |host|
|
|
60
|
+
groups = host.groups_dataset.map(:name)
|
|
61
|
+
next unless groups == [AUTOMATIC_GROUP]
|
|
62
|
+
|
|
63
|
+
issue('host_only_in_ungrouped', 'warning', "Host '#{host.name}' is only in automatic group 'ungrouped'.",
|
|
64
|
+
subject: host.name)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_orphaned_groups
|
|
69
|
+
context.all_groups.filter_map do |group|
|
|
70
|
+
next if group.name == AUTOMATIC_GROUP
|
|
71
|
+
next unless group.parents_dataset.empty? && group.hosts_dataset.empty?
|
|
72
|
+
|
|
73
|
+
issue('orphaned_group', 'warning', "Group '#{group.name}' has no parents and no hosts.",
|
|
74
|
+
subject: group.name)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def check_empty_groups
|
|
79
|
+
context.all_groups.filter_map do |group|
|
|
80
|
+
next if group.name == AUTOMATIC_GROUP
|
|
81
|
+
next unless group.hosts_dataset.empty? && group.children_dataset.empty? && group.groupvars_dataset.empty?
|
|
82
|
+
|
|
83
|
+
issue('empty_group', 'warning', "Group '#{group.name}' is empty.", subject: group.name)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_duplicateish_names
|
|
88
|
+
host_issues = duplicateish_issues(context.all_hosts.map(&:name), 'host')
|
|
89
|
+
group_issues = duplicateish_issues(context.all_groups.map(&:name), 'group')
|
|
90
|
+
host_issues + group_issues
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def duplicateish_issues(names, label)
|
|
94
|
+
names.group_by { |name| normalize_name(name) }.filter_map do |normalized, originals|
|
|
95
|
+
unique = originals.uniq
|
|
96
|
+
next if normalized.empty? || unique.length < 2
|
|
97
|
+
|
|
98
|
+
issue("duplicateish_#{label}_names", 'warning',
|
|
99
|
+
"#{label.capitalize} names look duplicate-ish: #{unique.sort.join(', ')}.", subject: unique.sort)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def normalize_name(name)
|
|
104
|
+
name.to_s.downcase.gsub(/[^a-z0-9]/, '')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def check_invalid_variables
|
|
108
|
+
host_var_issues = context.all_hosts.flat_map do |host|
|
|
109
|
+
invalid_variable_issues(host.hostvars_dataset, "host '#{host.name}'")
|
|
110
|
+
end
|
|
111
|
+
group_var_issues = context.all_groups.flat_map do |group|
|
|
112
|
+
invalid_variable_issues(group.groupvars_dataset, "group '#{group.name}'")
|
|
113
|
+
end
|
|
114
|
+
host_var_issues + group_var_issues
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def invalid_variable_issues(dataset, owner)
|
|
118
|
+
dataset.filter_map do |variable|
|
|
119
|
+
next unless variable.name.to_s.strip.empty? || variable.value.nil?
|
|
120
|
+
|
|
121
|
+
issue('invalid_variable_shape', 'error', "Variable on #{owner} has an empty name or nil value.",
|
|
122
|
+
subject: owner)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def check_group_cycles
|
|
127
|
+
groups = context.all_groups.to_h { |group| [group.name, group.children_dataset.map(:name)] }
|
|
128
|
+
visiting = {}
|
|
129
|
+
visited = {}
|
|
130
|
+
cycles = []
|
|
131
|
+
|
|
132
|
+
state = { groups: groups, visiting: visiting, visited: visited, cycles: cycles }
|
|
133
|
+
groups.each_key do |name|
|
|
134
|
+
visit_group(name, state, [])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
cycles.uniq.map do |cycle|
|
|
138
|
+
issue('circular_group_relationship', 'error', "Group hierarchy contains a cycle: #{cycle.join(' -> ')}.",
|
|
139
|
+
subject: cycle)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def visit_group(name, state, path)
|
|
144
|
+
return if state[:visited][name]
|
|
145
|
+
|
|
146
|
+
if state[:visiting][name]
|
|
147
|
+
cycle_start = path.index(name) || 0
|
|
148
|
+
state[:cycles] << (path[cycle_start..] + [name])
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
state[:visiting][name] = true
|
|
153
|
+
state[:groups].fetch(name, []).each do |child|
|
|
154
|
+
visit_group(child, state, path + [name])
|
|
155
|
+
end
|
|
156
|
+
state[:visiting].delete(name)
|
|
157
|
+
state[:visited][name] = true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def issue(id, severity, message, subject: nil)
|
|
161
|
+
{
|
|
162
|
+
id: id,
|
|
163
|
+
severity: severity,
|
|
164
|
+
message: message,
|
|
165
|
+
subject: subject
|
|
166
|
+
}.compact
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
# rubocop:enable Metrics/ClassLength
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moose
|
|
4
|
+
module Inventory
|
|
5
|
+
module Operations
|
|
6
|
+
# Builds a canonical, portable representation of the current inventory.
|
|
7
|
+
class InventorySnapshot
|
|
8
|
+
VERSION = 1
|
|
9
|
+
|
|
10
|
+
def initialize(context:)
|
|
11
|
+
@context = context
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def export
|
|
15
|
+
{
|
|
16
|
+
'version' => VERSION,
|
|
17
|
+
'hosts' => export_hosts,
|
|
18
|
+
'groups' => export_groups
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :context
|
|
25
|
+
|
|
26
|
+
def export_hosts
|
|
27
|
+
context.all_hosts.sort_by(&:name).to_h do |host|
|
|
28
|
+
[host.name, host_payload(host)]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def host_payload(host)
|
|
33
|
+
{
|
|
34
|
+
'groups' => host.groups_dataset.map(:name).sort,
|
|
35
|
+
'tags' => host.tags_dataset.map(:name).sort,
|
|
36
|
+
'vars' => variables_hash(host.hostvars_dataset)
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def export_groups
|
|
41
|
+
context.all_groups.sort_by(&:name).to_h do |group|
|
|
42
|
+
[group.name, group_payload(group)]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def group_payload(group)
|
|
47
|
+
{
|
|
48
|
+
'children' => group.children_dataset.map(:name).sort,
|
|
49
|
+
'tags' => group.tags_dataset.map(:name).sort,
|
|
50
|
+
'vars' => variables_hash(group.groupvars_dataset)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def variables_hash(dataset)
|
|
55
|
+
dataset.all.sort_by(&:name).to_h { |entry| [entry.name, entry.value] }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moose
|
|
4
|
+
module Inventory
|
|
5
|
+
module Operations
|
|
6
|
+
# Applies an already-validated inventory snapshot to the current inventory.
|
|
7
|
+
class InventorySnapshotApplier
|
|
8
|
+
Result = Struct.new(:created_hosts, :created_groups, :updated_variables, :associations, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(context:)
|
|
11
|
+
@context = context
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(snapshot:)
|
|
15
|
+
apply_snapshot(snapshot)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :context
|
|
21
|
+
|
|
22
|
+
def apply_snapshot(snapshot)
|
|
23
|
+
result = Result.new(created_hosts: 0, created_groups: 0, updated_variables: 0, associations: 0)
|
|
24
|
+
|
|
25
|
+
snapshot['groups'].each_key { |name| result.created_groups += 1 if ensure_group(name).nil? }
|
|
26
|
+
snapshot['hosts'].each_key { |name| result.created_hosts += 1 if ensure_host(name).nil? }
|
|
27
|
+
apply_group_payloads(snapshot['groups'], result)
|
|
28
|
+
apply_host_payloads(snapshot['hosts'], result)
|
|
29
|
+
result
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def apply_group_payloads(groups, result)
|
|
33
|
+
groups.each do |name, payload|
|
|
34
|
+
group = context.find_group(name)
|
|
35
|
+
result.updated_variables += apply_variables(group, :group, payload.fetch('vars', {}))
|
|
36
|
+
apply_tags(group, array_value(payload, 'tags'), result)
|
|
37
|
+
array_value(payload, 'children').each do |child_name|
|
|
38
|
+
child = context.find_group(child_name)
|
|
39
|
+
next unless group.children_dataset[name: child_name].nil?
|
|
40
|
+
|
|
41
|
+
group.add_child(child)
|
|
42
|
+
result.associations += 1
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def apply_host_payloads(hosts, result)
|
|
48
|
+
hosts.each do |name, payload|
|
|
49
|
+
host = context.find_host(name)
|
|
50
|
+
result.updated_variables += apply_variables(host, :host, payload.fetch('vars', {}))
|
|
51
|
+
apply_tags(host, array_value(payload, 'tags'), result)
|
|
52
|
+
array_value(payload, 'groups').each do |group_name|
|
|
53
|
+
group = context.find_group(group_name)
|
|
54
|
+
next unless host.groups_dataset[name: group_name].nil?
|
|
55
|
+
|
|
56
|
+
host.add_group(group)
|
|
57
|
+
result.associations += 1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def apply_tags(entity, tags, result)
|
|
63
|
+
context.normalize_tag_names(tags).each do |tag_name|
|
|
64
|
+
tag = context.find_or_create_tag(tag_name)
|
|
65
|
+
next unless entity.tags_dataset[name: tag_name].nil?
|
|
66
|
+
|
|
67
|
+
entity.add_tag(tag)
|
|
68
|
+
result.associations += 1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apply_variables(entity, type, variables)
|
|
73
|
+
variables.count do |name, value|
|
|
74
|
+
dataset = entity.public_send("#{type}vars_dataset")
|
|
75
|
+
existing = dataset[name: name]
|
|
76
|
+
if existing.nil?
|
|
77
|
+
record = context.create_variable(type, name: name, value: value.to_s)
|
|
78
|
+
entity.public_send("add_#{type}var", record)
|
|
79
|
+
true
|
|
80
|
+
elsif existing.value != value.to_s
|
|
81
|
+
existing.value = value.to_s
|
|
82
|
+
existing.save
|
|
83
|
+
true
|
|
84
|
+
else
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ensure_group(name)
|
|
91
|
+
existing = context.find_group(name)
|
|
92
|
+
return existing unless existing.nil?
|
|
93
|
+
|
|
94
|
+
context.create_group(name)
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def ensure_host(name)
|
|
99
|
+
existing = context.find_host(name)
|
|
100
|
+
return existing unless existing.nil?
|
|
101
|
+
|
|
102
|
+
context.create_host(name)
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def array_value(payload, key)
|
|
107
|
+
payload.fetch(key, []).map(&:to_s)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|