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.
Files changed (169) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +2 -0
  3. data/.gitignore +2 -1
  4. data/.rubocop.yml +21 -0
  5. data/BACKLOG.md +630 -8
  6. data/Gemfile +2 -0
  7. data/Gemfile.lock +1 -1
  8. data/README.md +315 -39
  9. data/Rakefile +2 -0
  10. data/bin/moose-inventory +2 -1
  11. data/docs/architecture/architecture-and-trust-boundaries.md +444 -0
  12. data/docs/compatibility/cli-output-compatibility.md +76 -0
  13. data/docs/governance/approval-register.md +37 -0
  14. data/docs/maintenance/database-backup-restore-guidance.md +162 -0
  15. data/docs/maintenance/package-maintenance-and-agent-boundaries.md +260 -0
  16. data/docs/process/conformance-gap-analysis-2026-05-28.md +192 -0
  17. data/docs/product/product-brief.md +161 -0
  18. data/docs/product/requirements-baseline.md +477 -0
  19. data/docs/qa/qa-documentation-and-release-gates.md +283 -0
  20. data/docs/release/package-provenance-hardening.md +126 -0
  21. data/docs/release/publishing.md +11 -3
  22. data/docs/release/release-environment-protection.md +70 -0
  23. data/docs/release/release-readiness.md +23 -4
  24. data/docs/security/accepted-risk-register.md +84 -0
  25. data/docs/security/security-privacy-process.md +287 -0
  26. data/docs/security-audit-2026-05-26-rerun.md +2 -2
  27. data/docs/ux/cli-workflow-notes.md +287 -0
  28. data/examples/ansible/ansible.cfg +3 -0
  29. data/examples/ansible/inventory/moose_inventory.yml +5 -0
  30. data/examples/ansible/inventory_plugins/moose_inventory.py +100 -0
  31. data/examples/ci/README.md +16 -0
  32. data/examples/ci/github-actions/inventory-review.yml +38 -0
  33. data/examples/ci/inventory/example-snapshot.yml +19 -0
  34. data/examples/ci/scripts/validate-inventory-snapshot.sh +30 -0
  35. data/lib/moose_inventory/cli/application.rb +133 -5
  36. data/lib/moose_inventory/cli/association_rendering.rb +74 -0
  37. data/lib/moose_inventory/cli/association_rendering_support.rb +89 -0
  38. data/lib/moose_inventory/cli/audit.rb +62 -0
  39. data/lib/moose_inventory/cli/audit_recording.rb +40 -0
  40. data/lib/moose_inventory/cli/child_relation_rendering.rb +110 -0
  41. data/lib/moose_inventory/cli/console.rb +135 -0
  42. data/lib/moose_inventory/cli/db.rb +64 -0
  43. data/lib/moose_inventory/cli/factory.rb +28 -0
  44. data/lib/moose_inventory/cli/formatter.rb +8 -12
  45. data/lib/moose_inventory/cli/group.rb +5 -2
  46. data/lib/moose_inventory/cli/group_add.rb +11 -9
  47. data/lib/moose_inventory/cli/group_addchild.rb +23 -65
  48. data/lib/moose_inventory/cli/group_addhost.rb +16 -67
  49. data/lib/moose_inventory/cli/group_addvar.rb +27 -47
  50. data/lib/moose_inventory/cli/group_get.rb +8 -42
  51. data/lib/moose_inventory/cli/group_list.rb +7 -40
  52. data/lib/moose_inventory/cli/group_listvars.rb +9 -55
  53. data/lib/moose_inventory/cli/group_rm.rb +12 -10
  54. data/lib/moose_inventory/cli/group_rmchild.rb +26 -82
  55. data/lib/moose_inventory/cli/group_rmhost.rb +18 -53
  56. data/lib/moose_inventory/cli/group_rmvar.rb +30 -41
  57. data/lib/moose_inventory/cli/group_tags.rb +33 -0
  58. data/lib/moose_inventory/cli/helpers.rb +68 -1
  59. data/lib/moose_inventory/cli/host.rb +6 -3
  60. data/lib/moose_inventory/cli/host_add.rb +69 -29
  61. data/lib/moose_inventory/cli/host_addgroup.rb +22 -58
  62. data/lib/moose_inventory/cli/host_addvar.rb +28 -52
  63. data/lib/moose_inventory/cli/host_get.rb +9 -37
  64. data/lib/moose_inventory/cli/host_list.rb +24 -21
  65. data/lib/moose_inventory/cli/host_listvars.rb +9 -62
  66. data/lib/moose_inventory/cli/host_rm.rb +60 -42
  67. data/lib/moose_inventory/cli/host_rmgroup.rb +25 -44
  68. data/lib/moose_inventory/cli/host_rmvar.rb +31 -45
  69. data/lib/moose_inventory/cli/host_tags.rb +33 -0
  70. data/lib/moose_inventory/cli/listvars_support.rb +55 -0
  71. data/lib/moose_inventory/cli/plan_rendering.rb +50 -0
  72. data/lib/moose_inventory/cli/relation_transaction_support.rb +51 -0
  73. data/lib/moose_inventory/cli/tag_support.rb +97 -0
  74. data/lib/moose_inventory/cli/variable_rendering.rb +67 -0
  75. data/lib/moose_inventory/config/config.rb +185 -108
  76. data/lib/moose_inventory/db/db.rb +170 -195
  77. data/lib/moose_inventory/db/exceptions.rb +6 -3
  78. data/lib/moose_inventory/db/models.rb +16 -0
  79. data/lib/moose_inventory/db/schema_migrations.rb +248 -0
  80. data/lib/moose_inventory/inventory_context.rb +68 -2
  81. data/lib/moose_inventory/operations/add_associations.rb +20 -16
  82. data/lib/moose_inventory/operations/add_groups.rb +21 -13
  83. data/lib/moose_inventory/operations/add_hosts.rb +30 -17
  84. data/lib/moose_inventory/operations/add_variables.rb +77 -0
  85. data/lib/moose_inventory/operations/entity_variable_operation_support.rb +46 -0
  86. data/lib/moose_inventory/operations/group_child_relations.rb +23 -16
  87. data/lib/moose_inventory/operations/group_cleanup.rb +23 -8
  88. data/lib/moose_inventory/operations/import_inventory_snapshot.rb +41 -0
  89. data/lib/moose_inventory/operations/inventory_doctor.rb +172 -0
  90. data/lib/moose_inventory/operations/inventory_snapshot.rb +60 -0
  91. data/lib/moose_inventory/operations/inventory_snapshot_applier.rb +112 -0
  92. data/lib/moose_inventory/operations/inventory_snapshot_preview.rb +174 -0
  93. data/lib/moose_inventory/operations/inventory_snapshot_validator.rb +134 -0
  94. data/lib/moose_inventory/operations/operation_event_support.rb +27 -0
  95. data/lib/moose_inventory/operations/query_inventory/base_query.rb +24 -0
  96. data/lib/moose_inventory/operations/query_inventory/group_queries.rb +86 -0
  97. data/lib/moose_inventory/operations/query_inventory/host_queries.rb +106 -0
  98. data/lib/moose_inventory/operations/query_inventory.rb +47 -0
  99. data/lib/moose_inventory/operations/remove_associations.rb +30 -18
  100. data/lib/moose_inventory/operations/remove_groups.rb +12 -12
  101. data/lib/moose_inventory/operations/remove_hosts.rb +68 -0
  102. data/lib/moose_inventory/operations/remove_variables.rb +67 -0
  103. data/lib/moose_inventory/runtime_options.rb +31 -0
  104. data/lib/moose_inventory/version.rb +3 -1
  105. data/lib/moose_inventory.rb +10 -7
  106. data/moose-inventory.gemspec +19 -35
  107. data/scripts/check.sh +1 -0
  108. data/scripts/ci/check_generated_artifacts.sh +41 -0
  109. data/scripts/ci/check_permissions.sh +2 -0
  110. data/scripts/ci/check_rubocop.sh +30 -25
  111. data/scripts/files.rb +5 -4
  112. data/spec/examples/ci_examples_spec.rb +37 -0
  113. data/spec/lib/moose_inventory/ansible_plugin_examples_spec.rb +29 -0
  114. data/spec/lib/moose_inventory/cli/application_doctor_spec.rb +50 -0
  115. data/spec/lib/moose_inventory/cli/application_import_export_spec.rb +100 -0
  116. data/spec/lib/moose_inventory/cli/application_spec.rb +25 -15
  117. data/spec/lib/moose_inventory/cli/audit_spec.rb +56 -0
  118. data/spec/lib/moose_inventory/cli/cli_spec.rb +15 -19
  119. data/spec/lib/moose_inventory/cli/console_spec.rb +98 -0
  120. data/spec/lib/moose_inventory/cli/factory_spec.rb +27 -0
  121. data/spec/lib/moose_inventory/cli/formatter_spec.rb +95 -3
  122. data/spec/lib/moose_inventory/cli/group_add_spec.rb +140 -116
  123. data/spec/lib/moose_inventory/cli/group_addchild_spec.rb +89 -35
  124. data/spec/lib/moose_inventory/cli/group_addhost_spec.rb +81 -84
  125. data/spec/lib/moose_inventory/cli/group_addvar_spec.rb +65 -68
  126. data/spec/lib/moose_inventory/cli/group_get_spec.rb +17 -33
  127. data/spec/lib/moose_inventory/cli/group_list_spec.rb +16 -38
  128. data/spec/lib/moose_inventory/cli/group_listvar_spec.rb +33 -40
  129. data/spec/lib/moose_inventory/cli/group_rm_spec.rb +136 -96
  130. data/spec/lib/moose_inventory/cli/group_rmchild_spec.rb +66 -41
  131. data/spec/lib/moose_inventory/cli/group_rmhost_spec.rb +76 -78
  132. data/spec/lib/moose_inventory/cli/group_rmvar_spec.rb +57 -63
  133. data/spec/lib/moose_inventory/cli/group_spec.rb +2 -0
  134. data/spec/lib/moose_inventory/cli/helpers_spec.rb +146 -0
  135. data/spec/lib/moose_inventory/cli/host_add_spec.rb +170 -116
  136. data/spec/lib/moose_inventory/cli/host_addgroup_spec.rb +100 -83
  137. data/spec/lib/moose_inventory/cli/host_addvar_spec.rb +92 -74
  138. data/spec/lib/moose_inventory/cli/host_get_spec.rb +14 -33
  139. data/spec/lib/moose_inventory/cli/host_list_spec.rb +41 -33
  140. data/spec/lib/moose_inventory/cli/host_listvar_spec.rb +45 -53
  141. data/spec/lib/moose_inventory/cli/host_rm_spec.rb +66 -48
  142. data/spec/lib/moose_inventory/cli/host_rmgroup_spec.rb +73 -83
  143. data/spec/lib/moose_inventory/cli/host_rmvar_spec.rb +56 -63
  144. data/spec/lib/moose_inventory/cli/host_spec.rb +2 -0
  145. data/spec/lib/moose_inventory/cli/tags_spec.rb +81 -0
  146. data/spec/lib/moose_inventory/config/config_spec.rb +41 -3
  147. data/spec/lib/moose_inventory/db/db_spec.rb +396 -36
  148. data/spec/lib/moose_inventory/db/exceptions_spec.rb +18 -0
  149. data/spec/lib/moose_inventory/db/models_spec.rb +7 -3
  150. data/spec/lib/moose_inventory/db_lifecycle_spec.rb +73 -0
  151. data/spec/lib/moose_inventory/inventory_context_spec.rb +10 -0
  152. data/spec/lib/moose_inventory/operations/add_associations_spec.rb +34 -0
  153. data/spec/lib/moose_inventory/operations/add_groups_spec.rb +15 -0
  154. data/spec/lib/moose_inventory/operations/add_hosts_spec.rb +13 -0
  155. data/spec/lib/moose_inventory/operations/add_variables_spec.rb +103 -0
  156. data/spec/lib/moose_inventory/operations/group_child_relations_spec.rb +46 -0
  157. data/spec/lib/moose_inventory/operations/import_inventory_snapshot_spec.rb +226 -0
  158. data/spec/lib/moose_inventory/operations/inventory_doctor_spec.rb +77 -0
  159. data/spec/lib/moose_inventory/operations/inventory_snapshot_spec.rb +50 -0
  160. data/spec/lib/moose_inventory/operations/operation_event_support_spec.rb +78 -0
  161. data/spec/lib/moose_inventory/operations/query_inventory_spec.rb +146 -0
  162. data/spec/lib/moose_inventory/operations/remove_associations_spec.rb +35 -0
  163. data/spec/lib/moose_inventory/operations/remove_groups_spec.rb +21 -0
  164. data/spec/lib/moose_inventory/operations/remove_hosts_spec.rb +55 -0
  165. data/spec/lib/moose_inventory/operations/remove_variables_spec.rb +83 -0
  166. data/spec/shared/shared_config_setup.rb +4 -3
  167. data/spec/spec_helper.rb +50 -40
  168. data/spec/support/cli_harness.rb +33 -0
  169. 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
- Event = Struct.new(:type, :payload, keyword_init: true)
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
- Result.new(events: events, warning_count: warning_count)
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
- Result.new(events: events, warning_count: warning_count)
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
- cleanup.delete_orphaned_group(child_group, input[:events]) if input[:delete_orphans]
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.parents_dataset.none?
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
- group.remove_all_groupvars
40
- group.remove_all_hosts
41
- group.destroy
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