moose-inventory 1.0.9 → 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.
Files changed (176) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +15 -1
  3. data/.github/workflows/release.yml +60 -0
  4. data/.gitignore +2 -1
  5. data/.gitleaks.toml +9 -0
  6. data/.rubocop.yml +49 -0
  7. data/BACKLOG.md +752 -24
  8. data/Gemfile +2 -0
  9. data/Gemfile.lock +36 -1
  10. data/README.md +340 -44
  11. data/Rakefile +2 -0
  12. data/bin/moose-inventory +2 -1
  13. data/docs/architecture/architecture-and-trust-boundaries.md +444 -0
  14. data/docs/compatibility/cli-output-compatibility.md +76 -0
  15. data/docs/governance/approval-register.md +37 -0
  16. data/docs/maintenance/database-backup-restore-guidance.md +162 -0
  17. data/docs/maintenance/package-maintenance-and-agent-boundaries.md +260 -0
  18. data/docs/process/conformance-gap-analysis-2026-05-28.md +192 -0
  19. data/docs/product/product-brief.md +161 -0
  20. data/docs/product/requirements-baseline.md +477 -0
  21. data/docs/qa/qa-documentation-and-release-gates.md +283 -0
  22. data/docs/release/package-provenance-hardening.md +126 -0
  23. data/docs/release/publishing.md +54 -50
  24. data/docs/release/release-environment-protection.md +70 -0
  25. data/docs/release/release-readiness.md +37 -4
  26. data/docs/security/accepted-risk-register.md +84 -0
  27. data/docs/security/security-privacy-process.md +287 -0
  28. data/docs/security-audit-2026-05-26-rerun.md +75 -0
  29. data/docs/security-audit-2026-05-26.md +63 -0
  30. data/docs/ux/cli-workflow-notes.md +287 -0
  31. data/examples/ansible/ansible.cfg +3 -0
  32. data/examples/ansible/inventory/moose_inventory.yml +5 -0
  33. data/examples/ansible/inventory_plugins/moose_inventory.py +100 -0
  34. data/examples/ci/README.md +16 -0
  35. data/examples/ci/github-actions/inventory-review.yml +38 -0
  36. data/examples/ci/inventory/example-snapshot.yml +19 -0
  37. data/examples/ci/scripts/validate-inventory-snapshot.sh +30 -0
  38. data/lib/moose_inventory/cli/application.rb +133 -5
  39. data/lib/moose_inventory/cli/association_rendering.rb +74 -0
  40. data/lib/moose_inventory/cli/association_rendering_support.rb +89 -0
  41. data/lib/moose_inventory/cli/audit.rb +62 -0
  42. data/lib/moose_inventory/cli/audit_recording.rb +40 -0
  43. data/lib/moose_inventory/cli/child_relation_rendering.rb +110 -0
  44. data/lib/moose_inventory/cli/console.rb +135 -0
  45. data/lib/moose_inventory/cli/db.rb +64 -0
  46. data/lib/moose_inventory/cli/factory.rb +28 -0
  47. data/lib/moose_inventory/cli/formatter.rb +8 -12
  48. data/lib/moose_inventory/cli/group.rb +7 -1
  49. data/lib/moose_inventory/cli/group_add.rb +91 -73
  50. data/lib/moose_inventory/cli/group_addchild.rb +41 -66
  51. data/lib/moose_inventory/cli/group_addhost.rb +33 -71
  52. data/lib/moose_inventory/cli/group_addvar.rb +27 -47
  53. data/lib/moose_inventory/cli/group_get.rb +8 -42
  54. data/lib/moose_inventory/cli/group_list.rb +7 -40
  55. data/lib/moose_inventory/cli/group_listvars.rb +9 -55
  56. data/lib/moose_inventory/cli/group_rm.rb +105 -73
  57. data/lib/moose_inventory/cli/group_rmchild.rb +47 -57
  58. data/lib/moose_inventory/cli/group_rmhost.rb +34 -61
  59. data/lib/moose_inventory/cli/group_rmvar.rb +30 -41
  60. data/lib/moose_inventory/cli/group_tags.rb +33 -0
  61. data/lib/moose_inventory/cli/helpers.rb +143 -0
  62. data/lib/moose_inventory/cli/host.rb +8 -2
  63. data/lib/moose_inventory/cli/host_add.rb +91 -66
  64. data/lib/moose_inventory/cli/host_addgroup.rb +39 -66
  65. data/lib/moose_inventory/cli/host_addvar.rb +28 -52
  66. data/lib/moose_inventory/cli/host_get.rb +9 -37
  67. data/lib/moose_inventory/cli/host_list.rb +24 -21
  68. data/lib/moose_inventory/cli/host_listvars.rb +9 -62
  69. data/lib/moose_inventory/cli/host_rm.rb +60 -42
  70. data/lib/moose_inventory/cli/host_rmgroup.rb +39 -55
  71. data/lib/moose_inventory/cli/host_rmvar.rb +31 -45
  72. data/lib/moose_inventory/cli/host_tags.rb +33 -0
  73. data/lib/moose_inventory/cli/listvars_support.rb +55 -0
  74. data/lib/moose_inventory/cli/plan_rendering.rb +50 -0
  75. data/lib/moose_inventory/cli/relation_transaction_support.rb +51 -0
  76. data/lib/moose_inventory/cli/tag_support.rb +97 -0
  77. data/lib/moose_inventory/cli/variable_rendering.rb +67 -0
  78. data/lib/moose_inventory/config/config.rb +185 -108
  79. data/lib/moose_inventory/db/db.rb +188 -193
  80. data/lib/moose_inventory/db/exceptions.rb +6 -3
  81. data/lib/moose_inventory/db/models.rb +16 -0
  82. data/lib/moose_inventory/db/schema_migrations.rb +248 -0
  83. data/lib/moose_inventory/inventory_context.rb +116 -0
  84. data/lib/moose_inventory/operations/add_associations.rb +131 -0
  85. data/lib/moose_inventory/operations/add_groups.rb +123 -0
  86. data/lib/moose_inventory/operations/add_hosts.rb +123 -0
  87. data/lib/moose_inventory/operations/add_variables.rb +77 -0
  88. data/lib/moose_inventory/operations/entity_variable_operation_support.rb +46 -0
  89. data/lib/moose_inventory/operations/group_child_relations.rb +125 -0
  90. data/lib/moose_inventory/operations/group_cleanup.rb +70 -0
  91. data/lib/moose_inventory/operations/import_inventory_snapshot.rb +41 -0
  92. data/lib/moose_inventory/operations/inventory_doctor.rb +172 -0
  93. data/lib/moose_inventory/operations/inventory_snapshot.rb +60 -0
  94. data/lib/moose_inventory/operations/inventory_snapshot_applier.rb +112 -0
  95. data/lib/moose_inventory/operations/inventory_snapshot_preview.rb +174 -0
  96. data/lib/moose_inventory/operations/inventory_snapshot_validator.rb +134 -0
  97. data/lib/moose_inventory/operations/operation_event_support.rb +27 -0
  98. data/lib/moose_inventory/operations/query_inventory/base_query.rb +24 -0
  99. data/lib/moose_inventory/operations/query_inventory/group_queries.rb +86 -0
  100. data/lib/moose_inventory/operations/query_inventory/host_queries.rb +106 -0
  101. data/lib/moose_inventory/operations/query_inventory.rb +47 -0
  102. data/lib/moose_inventory/operations/remove_associations.rb +113 -0
  103. data/lib/moose_inventory/operations/remove_groups.rb +79 -0
  104. data/lib/moose_inventory/operations/remove_hosts.rb +68 -0
  105. data/lib/moose_inventory/operations/remove_variables.rb +67 -0
  106. data/lib/moose_inventory/runtime_options.rb +31 -0
  107. data/lib/moose_inventory/version.rb +3 -1
  108. data/lib/moose_inventory.rb +10 -7
  109. data/moose-inventory.gemspec +22 -35
  110. data/scripts/check.sh +3 -0
  111. data/scripts/ci/check_generated_artifacts.sh +41 -0
  112. data/scripts/ci/check_permissions.sh +5 -0
  113. data/scripts/ci/check_rubocop.sh +33 -0
  114. data/scripts/ci/check_secrets.sh +26 -0
  115. data/scripts/ci/check_security.sh +18 -0
  116. data/scripts/ci/install_security_tools.sh +47 -0
  117. data/scripts/files.rb +5 -4
  118. data/scripts/install_dependencies.sh +2 -0
  119. data/spec/examples/ci_examples_spec.rb +37 -0
  120. data/spec/lib/moose_inventory/ansible_plugin_examples_spec.rb +29 -0
  121. data/spec/lib/moose_inventory/cli/application_doctor_spec.rb +50 -0
  122. data/spec/lib/moose_inventory/cli/application_import_export_spec.rb +100 -0
  123. data/spec/lib/moose_inventory/cli/application_spec.rb +25 -15
  124. data/spec/lib/moose_inventory/cli/audit_spec.rb +56 -0
  125. data/spec/lib/moose_inventory/cli/cli_spec.rb +15 -19
  126. data/spec/lib/moose_inventory/cli/console_spec.rb +98 -0
  127. data/spec/lib/moose_inventory/cli/factory_spec.rb +27 -0
  128. data/spec/lib/moose_inventory/cli/formatter_spec.rb +95 -3
  129. data/spec/lib/moose_inventory/cli/group_add_spec.rb +140 -116
  130. data/spec/lib/moose_inventory/cli/group_addchild_spec.rb +89 -35
  131. data/spec/lib/moose_inventory/cli/group_addhost_spec.rb +81 -84
  132. data/spec/lib/moose_inventory/cli/group_addvar_spec.rb +65 -68
  133. data/spec/lib/moose_inventory/cli/group_get_spec.rb +17 -33
  134. data/spec/lib/moose_inventory/cli/group_list_spec.rb +16 -38
  135. data/spec/lib/moose_inventory/cli/group_listvar_spec.rb +33 -40
  136. data/spec/lib/moose_inventory/cli/group_rm_spec.rb +165 -85
  137. data/spec/lib/moose_inventory/cli/group_rmchild_spec.rb +100 -30
  138. data/spec/lib/moose_inventory/cli/group_rmhost_spec.rb +76 -78
  139. data/spec/lib/moose_inventory/cli/group_rmvar_spec.rb +57 -63
  140. data/spec/lib/moose_inventory/cli/group_spec.rb +2 -0
  141. data/spec/lib/moose_inventory/cli/helpers_spec.rb +146 -0
  142. data/spec/lib/moose_inventory/cli/host_add_spec.rb +170 -116
  143. data/spec/lib/moose_inventory/cli/host_addgroup_spec.rb +100 -83
  144. data/spec/lib/moose_inventory/cli/host_addvar_spec.rb +92 -74
  145. data/spec/lib/moose_inventory/cli/host_get_spec.rb +14 -33
  146. data/spec/lib/moose_inventory/cli/host_list_spec.rb +41 -33
  147. data/spec/lib/moose_inventory/cli/host_listvar_spec.rb +45 -53
  148. data/spec/lib/moose_inventory/cli/host_rm_spec.rb +66 -48
  149. data/spec/lib/moose_inventory/cli/host_rmgroup_spec.rb +73 -83
  150. data/spec/lib/moose_inventory/cli/host_rmvar_spec.rb +56 -63
  151. data/spec/lib/moose_inventory/cli/host_spec.rb +2 -0
  152. data/spec/lib/moose_inventory/cli/tags_spec.rb +81 -0
  153. data/spec/lib/moose_inventory/config/config_spec.rb +41 -3
  154. data/spec/lib/moose_inventory/db/db_spec.rb +551 -29
  155. data/spec/lib/moose_inventory/db/exceptions_spec.rb +18 -0
  156. data/spec/lib/moose_inventory/db/models_spec.rb +7 -3
  157. data/spec/lib/moose_inventory/db_lifecycle_spec.rb +73 -0
  158. data/spec/lib/moose_inventory/inventory_context_spec.rb +10 -0
  159. data/spec/lib/moose_inventory/operations/add_associations_spec.rb +111 -0
  160. data/spec/lib/moose_inventory/operations/add_groups_spec.rb +80 -0
  161. data/spec/lib/moose_inventory/operations/add_hosts_spec.rb +82 -0
  162. data/spec/lib/moose_inventory/operations/add_variables_spec.rb +103 -0
  163. data/spec/lib/moose_inventory/operations/group_child_relations_spec.rb +122 -0
  164. data/spec/lib/moose_inventory/operations/import_inventory_snapshot_spec.rb +226 -0
  165. data/spec/lib/moose_inventory/operations/inventory_doctor_spec.rb +77 -0
  166. data/spec/lib/moose_inventory/operations/inventory_snapshot_spec.rb +50 -0
  167. data/spec/lib/moose_inventory/operations/operation_event_support_spec.rb +78 -0
  168. data/spec/lib/moose_inventory/operations/query_inventory_spec.rb +146 -0
  169. data/spec/lib/moose_inventory/operations/remove_associations_spec.rb +113 -0
  170. data/spec/lib/moose_inventory/operations/remove_groups_spec.rb +78 -0
  171. data/spec/lib/moose_inventory/operations/remove_hosts_spec.rb +55 -0
  172. data/spec/lib/moose_inventory/operations/remove_variables_spec.rb +83 -0
  173. data/spec/shared/shared_config_setup.rb +4 -3
  174. data/spec/spec_helper.rb +50 -40
  175. data/spec/support/cli_harness.rb +33 -0
  176. metadata +163 -35
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'operation_event_support'
4
+
5
+ module Moose
6
+ module Inventory
7
+ module Operations
8
+ ##
9
+ # Adds hosts and their optional group associations.
10
+ #
11
+ # The operation mutates inventory state and returns structured events for
12
+ # the CLI adapter to render. Keeping output out of this class makes the
13
+ # inventory behavior easier to exercise without binding every domain test
14
+ # to progress text.
15
+ class AddHosts
16
+ AUTOMATIC_GROUP = 'ungrouped'
17
+ include OperationEventSupport
18
+
19
+ def initialize(context:)
20
+ @context = context
21
+ end
22
+
23
+ def call(names:, groups:, dry_run: false)
24
+ events = []
25
+ @dry_run = dry_run
26
+
27
+ if dry_run
28
+ names.each do |name|
29
+ add_host(name, groups, events)
30
+ end
31
+ emit(events, :dry_run_summary)
32
+ return operation_result(events: events)
33
+ end
34
+
35
+ context.transaction do
36
+ names.each do |name|
37
+ add_host(name, groups, events)
38
+ end
39
+ end
40
+ operation_result(events: events)
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :context, :dry_run
46
+
47
+ def add_host(name, groups, events)
48
+ emit(events, :host_started, name: name)
49
+ host, groups_dataset = create_or_find_host(name, events)
50
+
51
+ groups.each do |group_name|
52
+ add_group_association(host, name, group_name, groups_dataset, events)
53
+ end
54
+
55
+ add_automatic_group_if_needed(host, name, groups, groups_dataset, events)
56
+ emit(events, :host_complete)
57
+ end
58
+
59
+ def create_or_find_host(name, events)
60
+ emit(events, :creating_host, name: name)
61
+ host = context.find_host(name)
62
+ groups_dataset = nil
63
+
64
+ if host.nil?
65
+ host = context.create_host(name) unless dry_run
66
+ else
67
+ emit(events, :host_exists, name: name)
68
+ groups_dataset = host.groups_dataset
69
+ end
70
+
71
+ emit(events, :ok, indent: 4)
72
+ [host, groups_dataset]
73
+ end
74
+
75
+ def add_group_association(host, host_name, group_name, groups_dataset, events)
76
+ return if group_name.nil? || group_name.empty?
77
+
78
+ emit(events, :adding_association, host: host_name, group: group_name)
79
+ group = find_or_create_group(group_name, events)
80
+
81
+ if association_exists?(groups_dataset, group_name)
82
+ emit(events, :association_exists, host: host_name, group: group_name)
83
+ elsif !dry_run
84
+ host.add_group(group)
85
+ end
86
+
87
+ emit(events, :ok, indent: 4)
88
+ end
89
+
90
+ def find_or_create_group(name, events)
91
+ group = context.find_group(name)
92
+ return group unless group.nil?
93
+
94
+ emit(events, :group_missing_created, name: name)
95
+ context.create_group(name) unless dry_run
96
+ end
97
+
98
+ def add_automatic_group_if_needed(host, host_name, requested_groups, groups_dataset, events)
99
+ return unless automatic_group_needed?(host, requested_groups, groups_dataset)
100
+
101
+ emit(events, :adding_automatic_group, host: host_name, group: AUTOMATIC_GROUP)
102
+ host.add_group(automatic_group) unless dry_run
103
+ emit(events, :ok, indent: 4)
104
+ end
105
+
106
+ def automatic_group_needed?(host, requested_groups, groups_dataset)
107
+ return requested_groups.empty? && (host.nil? || groups_dataset.nil? || groups_dataset.none?) if dry_run
108
+
109
+ groups_dataset = host.groups_dataset
110
+ !groups_dataset.nil? && groups_dataset.none?
111
+ end
112
+
113
+ def automatic_group
114
+ context.automatic_group
115
+ end
116
+
117
+ def association_exists?(dataset, name)
118
+ !dataset.nil? && !dataset[name: name].nil?
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -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
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'operation_event_support'
4
+
5
+ require_relative 'group_cleanup'
6
+
7
+ module Moose
8
+ module Inventory
9
+ module Operations
10
+ class GroupChildRelations
11
+ include OperationEventSupport
12
+
13
+ def initialize(context:)
14
+ @context = context
15
+ @cleanup = Moose::Inventory::Operations::GroupCleanup.new(
16
+ context: context,
17
+ emitter: method(:emit)
18
+ )
19
+ end
20
+
21
+ def add_children(parent_group:, parent_name:, child_names:, dry_run: false)
22
+ events = []
23
+ warning_count = 0
24
+ children_dataset = parent_group.children_dataset
25
+ @dry_run = dry_run
26
+
27
+ child_names.each do |child_name|
28
+ next if child_name.nil? || child_name.empty?
29
+
30
+ warning_count += add_child(parent_group, parent_name, child_name, children_dataset, events)
31
+ end
32
+
33
+ emit(events, :dry_run_summary) if dry_run
34
+
35
+ operation_result(events: events, warning_count: warning_count)
36
+ end
37
+
38
+ def remove_children(parent_group:, parent_name:, child_names:, delete_orphans: false, dry_run: false)
39
+ events = []
40
+ warning_count = 0
41
+ children_dataset = parent_group.children_dataset
42
+ @dry_run = dry_run
43
+ cleanup.dry_run = dry_run
44
+
45
+ child_names.each do |child_name|
46
+ next if child_name.nil? || child_name.empty?
47
+
48
+ warning_count += remove_child(
49
+ {
50
+ parent_group: parent_group,
51
+ parent_name: parent_name,
52
+ child_name: child_name,
53
+ children_dataset: children_dataset,
54
+ events: events,
55
+ delete_orphans: delete_orphans,
56
+ dry_run: dry_run
57
+ }
58
+ )
59
+ end
60
+
61
+ emit(events, :dry_run_summary) if dry_run
62
+
63
+ operation_result(events: events, warning_count: warning_count)
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :cleanup, :context, :dry_run
69
+
70
+ def add_child(parent_group, parent_name, child_name, children_dataset, events)
71
+ emit(events, :adding_child_association, parent: parent_name, child: child_name)
72
+
73
+ if association_exists?(children_dataset, child_name)
74
+ emit(events, :child_association_exists, parent: parent_name, child: child_name)
75
+ emit(events, :already_exists_skipping, indent: 4)
76
+ emit(events, :ok, indent: 4)
77
+ return 1
78
+ end
79
+
80
+ child_group = context.find_group(child_name)
81
+ warning_count = 0
82
+ if child_group.nil?
83
+ emit(events, :child_group_missing, name: child_name)
84
+ emit(events, :child_group_creating_now, name: child_name)
85
+ child_group = context.create_group(child_name) unless dry_run
86
+ emit(events, :ok, indent: 6)
87
+ warning_count = 1
88
+ end
89
+
90
+ parent_group.add_child(child_group) unless dry_run
91
+ emit(events, :ok, indent: 4)
92
+ warning_count
93
+ end
94
+
95
+ def remove_child(input)
96
+ emit(
97
+ input[:events],
98
+ :removing_child_association,
99
+ parent: input[:parent_name],
100
+ child: input[:child_name]
101
+ )
102
+
103
+ unless association_exists?(input[:children_dataset], input[:child_name])
104
+ emit(input[:events], :child_association_missing, parent: input[:parent_name], child: input[:child_name])
105
+ emit(input[:events], :missing_skipping, indent: 4)
106
+ emit(input[:events], :ok, indent: 4)
107
+ return 1
108
+ end
109
+
110
+ child_group = context.find_group(input[:child_name])
111
+ input[:parent_group].remove_child(child_group) unless input[:dry_run]
112
+ emit(input[:events], :ok, indent: 4)
113
+ if input[:delete_orphans]
114
+ cleanup.delete_orphaned_group(child_group, input[:events], ignored_parent: input[:parent_group])
115
+ end
116
+ 0
117
+ end
118
+
119
+ def association_exists?(dataset, name)
120
+ !dataset.nil? && !dataset[name: name].nil?
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'operation_event_support'
4
+
5
+ module Moose
6
+ module Inventory
7
+ module Operations
8
+ # Recursively cleans up orphaned groups and their dependent relations.
9
+ class GroupCleanup
10
+ include OperationEventSupport
11
+
12
+ AUTOMATIC_GROUP = 'ungrouped'
13
+
14
+ attr_accessor :dry_run
15
+
16
+ def initialize(context:, emitter:)
17
+ @context = context
18
+ @emitter = emitter
19
+ @dry_run = false
20
+ end
21
+
22
+ def delete_orphaned_group(group, events, ignored_parent: nil)
23
+ return if group.name == AUTOMATIC_GROUP
24
+ return unless orphaned_after_planned_removal?(group, ignored_parent)
25
+
26
+ emit(events, :recursively_delete_orphaned_group, name: group.name)
27
+ group.children_dataset.each do |child|
28
+ emit(events, :removing_recursive_child_association, parent: group.name, child: child.name)
29
+ group.remove_child(child) unless dry_run
30
+ emit(events, :ok, indent: 6)
31
+ delete_orphaned_group(child, events, ignored_parent: group)
32
+ end
33
+ destroy_group(group, events, indent: 4)
34
+ end
35
+
36
+ def destroy_group(group, events, indent:)
37
+ group.hosts_dataset.each do |host|
38
+ next unless host.groups_dataset.one?
39
+
40
+ emit(events, :adding_automatic_group_to_host, host: host[:name], indent: indent)
41
+ host.add_group(context.automatic_group) unless dry_run
42
+ emit(events, :ok, indent: indent + 2)
43
+ end
44
+
45
+ emit(events, :destroying_group, name: group.name, indent: indent)
46
+ unless dry_run
47
+ group.remove_all_groupvars
48
+ group.remove_all_hosts
49
+ group.destroy
50
+ end
51
+ emit(events, :ok, indent: indent + 2)
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :context, :emitter
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
+
64
+ def emit(events, type, payload = {})
65
+ emitter.call(events, type, payload)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ 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