moose-inventory 2.0 → 2.1.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 (171) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +2 -0
  3. data/.gitignore +6 -1
  4. data/.rubocop.yml +21 -0
  5. data/BACKLOG.md +638 -9
  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 +78 -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/security-audit-2026-05-29-snapshot-import-fuzz.md +58 -0
  28. data/docs/ux/cli-workflow-notes.md +287 -0
  29. data/examples/ansible/ansible.cfg +3 -0
  30. data/examples/ansible/inventory/moose_inventory.yml +5 -0
  31. data/examples/ansible/inventory_plugins/moose_inventory.py +100 -0
  32. data/examples/ci/README.md +16 -0
  33. data/examples/ci/github-actions/inventory-review.yml +38 -0
  34. data/examples/ci/inventory/example-snapshot.yml +19 -0
  35. data/examples/ci/scripts/validate-inventory-snapshot.sh +30 -0
  36. data/lib/moose_inventory/cli/application.rb +135 -5
  37. data/lib/moose_inventory/cli/association_rendering.rb +74 -0
  38. data/lib/moose_inventory/cli/association_rendering_support.rb +89 -0
  39. data/lib/moose_inventory/cli/audit.rb +62 -0
  40. data/lib/moose_inventory/cli/audit_recording.rb +40 -0
  41. data/lib/moose_inventory/cli/child_relation_rendering.rb +110 -0
  42. data/lib/moose_inventory/cli/console.rb +135 -0
  43. data/lib/moose_inventory/cli/db.rb +64 -0
  44. data/lib/moose_inventory/cli/factory.rb +28 -0
  45. data/lib/moose_inventory/cli/formatter.rb +8 -12
  46. data/lib/moose_inventory/cli/group.rb +5 -2
  47. data/lib/moose_inventory/cli/group_add.rb +11 -9
  48. data/lib/moose_inventory/cli/group_addchild.rb +23 -65
  49. data/lib/moose_inventory/cli/group_addhost.rb +16 -67
  50. data/lib/moose_inventory/cli/group_addvar.rb +27 -47
  51. data/lib/moose_inventory/cli/group_get.rb +8 -42
  52. data/lib/moose_inventory/cli/group_list.rb +7 -40
  53. data/lib/moose_inventory/cli/group_listvars.rb +9 -55
  54. data/lib/moose_inventory/cli/group_rm.rb +12 -10
  55. data/lib/moose_inventory/cli/group_rmchild.rb +26 -82
  56. data/lib/moose_inventory/cli/group_rmhost.rb +18 -53
  57. data/lib/moose_inventory/cli/group_rmvar.rb +30 -41
  58. data/lib/moose_inventory/cli/group_tags.rb +33 -0
  59. data/lib/moose_inventory/cli/helpers.rb +68 -1
  60. data/lib/moose_inventory/cli/host.rb +6 -3
  61. data/lib/moose_inventory/cli/host_add.rb +69 -29
  62. data/lib/moose_inventory/cli/host_addgroup.rb +22 -58
  63. data/lib/moose_inventory/cli/host_addvar.rb +28 -52
  64. data/lib/moose_inventory/cli/host_get.rb +9 -37
  65. data/lib/moose_inventory/cli/host_list.rb +24 -21
  66. data/lib/moose_inventory/cli/host_listvars.rb +9 -62
  67. data/lib/moose_inventory/cli/host_rm.rb +60 -42
  68. data/lib/moose_inventory/cli/host_rmgroup.rb +25 -44
  69. data/lib/moose_inventory/cli/host_rmvar.rb +31 -45
  70. data/lib/moose_inventory/cli/host_tags.rb +33 -0
  71. data/lib/moose_inventory/cli/listvars_support.rb +55 -0
  72. data/lib/moose_inventory/cli/plan_rendering.rb +50 -0
  73. data/lib/moose_inventory/cli/relation_transaction_support.rb +51 -0
  74. data/lib/moose_inventory/cli/tag_support.rb +97 -0
  75. data/lib/moose_inventory/cli/variable_rendering.rb +67 -0
  76. data/lib/moose_inventory/config/config.rb +185 -108
  77. data/lib/moose_inventory/db/db.rb +170 -195
  78. data/lib/moose_inventory/db/exceptions.rb +6 -3
  79. data/lib/moose_inventory/db/models.rb +16 -0
  80. data/lib/moose_inventory/db/schema_migrations.rb +248 -0
  81. data/lib/moose_inventory/inventory_context.rb +68 -2
  82. data/lib/moose_inventory/operations/add_associations.rb +20 -16
  83. data/lib/moose_inventory/operations/add_groups.rb +21 -13
  84. data/lib/moose_inventory/operations/add_hosts.rb +30 -17
  85. data/lib/moose_inventory/operations/add_variables.rb +77 -0
  86. data/lib/moose_inventory/operations/entity_variable_operation_support.rb +46 -0
  87. data/lib/moose_inventory/operations/group_child_relations.rb +23 -16
  88. data/lib/moose_inventory/operations/group_cleanup.rb +23 -8
  89. data/lib/moose_inventory/operations/import_inventory_snapshot.rb +41 -0
  90. data/lib/moose_inventory/operations/inventory_doctor.rb +172 -0
  91. data/lib/moose_inventory/operations/inventory_snapshot.rb +60 -0
  92. data/lib/moose_inventory/operations/inventory_snapshot_applier.rb +112 -0
  93. data/lib/moose_inventory/operations/inventory_snapshot_preview.rb +174 -0
  94. data/lib/moose_inventory/operations/inventory_snapshot_validator.rb +174 -0
  95. data/lib/moose_inventory/operations/operation_event_support.rb +27 -0
  96. data/lib/moose_inventory/operations/query_inventory/base_query.rb +24 -0
  97. data/lib/moose_inventory/operations/query_inventory/group_queries.rb +86 -0
  98. data/lib/moose_inventory/operations/query_inventory/host_queries.rb +106 -0
  99. data/lib/moose_inventory/operations/query_inventory.rb +47 -0
  100. data/lib/moose_inventory/operations/remove_associations.rb +30 -18
  101. data/lib/moose_inventory/operations/remove_groups.rb +12 -12
  102. data/lib/moose_inventory/operations/remove_hosts.rb +68 -0
  103. data/lib/moose_inventory/operations/remove_variables.rb +67 -0
  104. data/lib/moose_inventory/runtime_options.rb +31 -0
  105. data/lib/moose_inventory/version.rb +3 -1
  106. data/lib/moose_inventory.rb +10 -7
  107. data/moose-inventory.gemspec +19 -35
  108. data/scripts/check.sh +1 -0
  109. data/scripts/ci/check_generated_artifacts.sh +41 -0
  110. data/scripts/ci/check_permissions.sh +2 -0
  111. data/scripts/ci/check_rubocop.sh +30 -25
  112. data/scripts/ci/check_security.sh +4 -1
  113. data/scripts/files.rb +5 -4
  114. data/spec/examples/ci_examples_spec.rb +37 -0
  115. data/spec/lib/moose_inventory/ansible_plugin_examples_spec.rb +29 -0
  116. data/spec/lib/moose_inventory/cli/application_doctor_spec.rb +50 -0
  117. data/spec/lib/moose_inventory/cli/application_import_export_spec.rb +132 -0
  118. data/spec/lib/moose_inventory/cli/application_spec.rb +25 -15
  119. data/spec/lib/moose_inventory/cli/audit_spec.rb +56 -0
  120. data/spec/lib/moose_inventory/cli/cli_spec.rb +15 -19
  121. data/spec/lib/moose_inventory/cli/console_spec.rb +98 -0
  122. data/spec/lib/moose_inventory/cli/factory_spec.rb +27 -0
  123. data/spec/lib/moose_inventory/cli/formatter_spec.rb +95 -3
  124. data/spec/lib/moose_inventory/cli/group_add_spec.rb +140 -116
  125. data/spec/lib/moose_inventory/cli/group_addchild_spec.rb +89 -35
  126. data/spec/lib/moose_inventory/cli/group_addhost_spec.rb +81 -84
  127. data/spec/lib/moose_inventory/cli/group_addvar_spec.rb +65 -68
  128. data/spec/lib/moose_inventory/cli/group_get_spec.rb +17 -33
  129. data/spec/lib/moose_inventory/cli/group_list_spec.rb +16 -38
  130. data/spec/lib/moose_inventory/cli/group_listvar_spec.rb +33 -40
  131. data/spec/lib/moose_inventory/cli/group_rm_spec.rb +136 -96
  132. data/spec/lib/moose_inventory/cli/group_rmchild_spec.rb +66 -41
  133. data/spec/lib/moose_inventory/cli/group_rmhost_spec.rb +76 -78
  134. data/spec/lib/moose_inventory/cli/group_rmvar_spec.rb +57 -63
  135. data/spec/lib/moose_inventory/cli/group_spec.rb +2 -0
  136. data/spec/lib/moose_inventory/cli/helpers_spec.rb +146 -0
  137. data/spec/lib/moose_inventory/cli/host_add_spec.rb +170 -116
  138. data/spec/lib/moose_inventory/cli/host_addgroup_spec.rb +100 -83
  139. data/spec/lib/moose_inventory/cli/host_addvar_spec.rb +92 -74
  140. data/spec/lib/moose_inventory/cli/host_get_spec.rb +14 -33
  141. data/spec/lib/moose_inventory/cli/host_list_spec.rb +41 -33
  142. data/spec/lib/moose_inventory/cli/host_listvar_spec.rb +45 -53
  143. data/spec/lib/moose_inventory/cli/host_rm_spec.rb +66 -48
  144. data/spec/lib/moose_inventory/cli/host_rmgroup_spec.rb +73 -83
  145. data/spec/lib/moose_inventory/cli/host_rmvar_spec.rb +56 -63
  146. data/spec/lib/moose_inventory/cli/host_spec.rb +2 -0
  147. data/spec/lib/moose_inventory/cli/tags_spec.rb +81 -0
  148. data/spec/lib/moose_inventory/config/config_spec.rb +41 -3
  149. data/spec/lib/moose_inventory/db/db_spec.rb +396 -36
  150. data/spec/lib/moose_inventory/db/exceptions_spec.rb +18 -0
  151. data/spec/lib/moose_inventory/db/models_spec.rb +7 -3
  152. data/spec/lib/moose_inventory/db_lifecycle_spec.rb +73 -0
  153. data/spec/lib/moose_inventory/inventory_context_spec.rb +10 -0
  154. data/spec/lib/moose_inventory/operations/add_associations_spec.rb +34 -0
  155. data/spec/lib/moose_inventory/operations/add_groups_spec.rb +15 -0
  156. data/spec/lib/moose_inventory/operations/add_hosts_spec.rb +13 -0
  157. data/spec/lib/moose_inventory/operations/add_variables_spec.rb +103 -0
  158. data/spec/lib/moose_inventory/operations/group_child_relations_spec.rb +46 -0
  159. data/spec/lib/moose_inventory/operations/import_inventory_snapshot_spec.rb +239 -0
  160. data/spec/lib/moose_inventory/operations/inventory_doctor_spec.rb +77 -0
  161. data/spec/lib/moose_inventory/operations/inventory_snapshot_spec.rb +50 -0
  162. data/spec/lib/moose_inventory/operations/operation_event_support_spec.rb +78 -0
  163. data/spec/lib/moose_inventory/operations/query_inventory_spec.rb +146 -0
  164. data/spec/lib/moose_inventory/operations/remove_associations_spec.rb +35 -0
  165. data/spec/lib/moose_inventory/operations/remove_groups_spec.rb +21 -0
  166. data/spec/lib/moose_inventory/operations/remove_hosts_spec.rb +55 -0
  167. data/spec/lib/moose_inventory/operations/remove_variables_spec.rb +83 -0
  168. data/spec/shared/shared_config_setup.rb +4 -3
  169. data/spec/spec_helper.rb +50 -40
  170. data/spec/support/cli_harness.rb +33 -0
  171. metadata +81 -41
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moose
4
+ module Inventory
5
+ module Operations
6
+ # Builds a non-mutating preview/diff for an already-validated inventory snapshot.
7
+ # rubocop:disable Metrics/ClassLength
8
+ class InventorySnapshotPreview
9
+ def initialize(context:)
10
+ @context = context
11
+ end
12
+
13
+ def call(snapshot:)
14
+ preview = empty_preview
15
+ preview_existing_entities(snapshot, preview)
16
+ preview_group_payloads(snapshot.fetch('groups'), preview)
17
+ preview_host_payloads(snapshot.fetch('hosts'), preview)
18
+ preview_ignored_existing(snapshot, preview)
19
+ preview
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :context
25
+
26
+ def empty_preview
27
+ {
28
+ 'schema_version' => 'snapshot-import-preview-v1',
29
+ 'changes_applied' => false,
30
+ 'summary' => {
31
+ 'hosts_created' => 0,
32
+ 'groups_created' => 0,
33
+ 'variables_changed' => 0,
34
+ 'associations_added' => 0,
35
+ 'unchanged' => 0,
36
+ 'ignored_existing_hosts' => 0,
37
+ 'ignored_existing_groups' => 0,
38
+ 'destructive_changes' => 0
39
+ },
40
+ 'creates' => { 'hosts' => [], 'groups' => [] },
41
+ 'updates' => { 'host_vars' => [], 'group_vars' => [] },
42
+ 'associations' => { 'host_groups' => [], 'group_children' => [], 'tags' => [] },
43
+ 'unchanged' => {
44
+ 'hosts' => [], 'groups' => [], 'host_vars' => [], 'group_vars' => [], 'associations' => []
45
+ },
46
+ 'ignored' => { 'existing_hosts_not_in_snapshot' => [], 'existing_groups_not_in_snapshot' => [] },
47
+ 'unsupported_destructive_implications' => []
48
+ }
49
+ end
50
+
51
+ def preview_existing_entities(snapshot, preview)
52
+ snapshot.fetch('groups').each_key do |name|
53
+ if context.find_group(name).nil?
54
+ add_create(preview, 'groups', name, 'groups_created')
55
+ else
56
+ add_unchanged(preview, 'groups', name)
57
+ end
58
+ end
59
+
60
+ snapshot.fetch('hosts').each_key do |name|
61
+ if context.find_host(name).nil?
62
+ add_create(preview, 'hosts', name, 'hosts_created')
63
+ else
64
+ add_unchanged(preview, 'hosts', name)
65
+ end
66
+ end
67
+ end
68
+
69
+ def preview_group_payloads(groups, preview)
70
+ groups.each do |name, payload|
71
+ group = context.find_group(name)
72
+ preview_variables(preview, group, :group, name, payload.fetch('vars', {}))
73
+ preview_tags(preview, group, 'group', name, array_value(payload, 'tags'))
74
+ array_value(payload, 'children').each do |child_name|
75
+ preview_association(preview, group, 'group_children', name, child_name) do |entity|
76
+ entity.children_dataset[name: child_name]
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ def preview_host_payloads(hosts, preview)
83
+ hosts.each do |name, payload|
84
+ host = context.find_host(name)
85
+ preview_variables(preview, host, :host, name, payload.fetch('vars', {}))
86
+ preview_tags(preview, host, 'host', name, array_value(payload, 'tags'))
87
+ array_value(payload, 'groups').each do |group_name|
88
+ preview_association(preview, host, 'host_groups', name, group_name) do |entity|
89
+ entity.groups_dataset[name: group_name]
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ def preview_variables(preview, entity, type, entity_name, variables)
96
+ variables.each do |name, value|
97
+ existing = entity&.public_send("#{type}vars_dataset")&.[](name: name)
98
+ entry = { 'entity' => entity_name, 'name' => name, 'to' => value.to_s }
99
+ if existing.nil?
100
+ add_update(preview, "#{type}_vars", entry)
101
+ elsif existing.value != value.to_s
102
+ add_update(preview, "#{type}_vars", entry.merge('from' => existing.value))
103
+ else
104
+ add_unchanged(preview, "#{type}_vars", entry)
105
+ end
106
+ end
107
+ end
108
+
109
+ def preview_tags(preview, entity, entity_type, entity_name, tags)
110
+ context.normalize_tag_names(tags).each do |tag_name|
111
+ entry = { 'entity_type' => entity_type, 'entity' => entity_name, 'tag' => tag_name }
112
+ if entity.nil? || entity.tags_dataset[name: tag_name].nil?
113
+ add_association(preview, 'tags', entry)
114
+ else
115
+ add_unchanged(preview, 'associations', entry)
116
+ end
117
+ end
118
+ end
119
+
120
+ def preview_association(preview, entity, key, source, target)
121
+ entry = { 'source' => source, 'target' => target }
122
+ if entity.nil? || yield(entity).nil?
123
+ add_association(preview, key, entry)
124
+ else
125
+ add_unchanged(preview, 'associations', entry.merge('type' => key))
126
+ end
127
+ end
128
+
129
+ def preview_ignored_existing(snapshot, preview)
130
+ snapshot_hosts = snapshot.fetch('hosts').keys
131
+ context.all_hosts.each do |host|
132
+ next if snapshot_hosts.include?(host.name)
133
+
134
+ preview['ignored']['existing_hosts_not_in_snapshot'] << host.name
135
+ preview['summary']['ignored_existing_hosts'] += 1
136
+ end
137
+
138
+ snapshot_groups = snapshot.fetch('groups').keys
139
+ context.all_groups.each do |group|
140
+ next if snapshot_groups.include?(group.name)
141
+
142
+ preview['ignored']['existing_groups_not_in_snapshot'] << group.name
143
+ preview['summary']['ignored_existing_groups'] += 1
144
+ end
145
+ end
146
+
147
+ def add_create(preview, key, value, counter)
148
+ preview['creates'][key] << value
149
+ preview['summary'][counter] += 1
150
+ end
151
+
152
+ def add_update(preview, key, entry)
153
+ preview['updates'][key] << entry
154
+ preview['summary']['variables_changed'] += 1
155
+ end
156
+
157
+ def add_association(preview, key, entry)
158
+ preview['associations'][key] << entry
159
+ preview['summary']['associations_added'] += 1
160
+ end
161
+
162
+ def add_unchanged(preview, key, entry)
163
+ preview['unchanged'][key] << entry
164
+ preview['summary']['unchanged'] += 1
165
+ end
166
+
167
+ def array_value(payload, key)
168
+ payload.fetch(key, []).map(&:to_s)
169
+ end
170
+ end
171
+ # rubocop:enable Metrics/ClassLength
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moose
4
+ module Inventory
5
+ module Operations
6
+ # Validates group hierarchy cycle safety without recursive traversal.
7
+ class GroupCycleValidator
8
+ def initialize(context:)
9
+ @context = context
10
+ end
11
+
12
+ def call(groups)
13
+ visiting = {}
14
+ visited = {}
15
+
16
+ groups.each_key do |root|
17
+ validate_from_root!(root, groups, visiting, visited)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :context
24
+
25
+ def validate_from_root!(root, groups, visiting, visited)
26
+ stack = [[root, false]]
27
+ until stack.empty?
28
+ name, expanded = stack.pop
29
+ next if visited[name]
30
+
31
+ if expanded
32
+ visiting.delete(name)
33
+ visited[name] = true
34
+ else
35
+ queue_group_children!(name, groups, visiting, stack)
36
+ end
37
+ end
38
+ end
39
+
40
+ def queue_group_children!(name, groups, visiting, stack)
41
+ raise_invalid("group hierarchy contains a cycle at '#{name}'") if visiting[name]
42
+
43
+ visiting[name] = true
44
+ stack << [name, true]
45
+ array_value(groups[name], 'children', label: "group '#{name}' children").reverse_each do |child_name|
46
+ raise_invalid("group hierarchy contains a cycle at '#{child_name}'") if visiting[child_name]
47
+
48
+ stack << [child_name, false]
49
+ end
50
+ end
51
+
52
+ def array_value(payload, key, label:)
53
+ value = payload.fetch(key, [])
54
+ raise_invalid("#{label} must be a list") unless value.is_a?(Array)
55
+
56
+ value.map(&:to_s)
57
+ end
58
+
59
+ def raise_invalid(message)
60
+ raise context.moose_exception_class, "Invalid inventory snapshot: #{message}."
61
+ end
62
+ end
63
+
64
+ # Normalizes and validates portable inventory snapshot input before import.
65
+ class InventorySnapshotValidator
66
+ def initialize(context:)
67
+ @context = context
68
+ end
69
+
70
+ def call(snapshot:)
71
+ normalized = deep_stringify_keys(snapshot)
72
+ validate_snapshot!(normalized)
73
+ normalized
74
+ end
75
+
76
+ private
77
+
78
+ attr_reader :context
79
+
80
+ def validate_snapshot!(snapshot)
81
+ raise_invalid('snapshot must be a mapping') unless snapshot.is_a?(Hash)
82
+ raise_invalid('version must be 1') unless snapshot['version'].to_i == InventorySnapshot::VERSION
83
+ raise_invalid('hosts must be a mapping') unless snapshot['hosts'].is_a?(Hash)
84
+ raise_invalid('groups must be a mapping') unless snapshot['groups'].is_a?(Hash)
85
+
86
+ validate_hosts!(snapshot)
87
+ validate_groups!(snapshot)
88
+ validate_group_cycles!(snapshot['groups'])
89
+ end
90
+
91
+ def validate_hosts!(snapshot)
92
+ snapshot['hosts'].each do |name, payload|
93
+ validate_entity_payload!(name, payload, 'host', allowed_keys: %w[groups tags vars])
94
+ groups = array_value(payload, 'groups', label: "host '#{name}' groups")
95
+ groups.each do |group_name|
96
+ next if snapshot['groups'].key?(group_name)
97
+
98
+ raise_invalid("host '#{name}' references unknown group '#{group_name}'")
99
+ end
100
+ end
101
+ end
102
+
103
+ def validate_groups!(snapshot)
104
+ snapshot['groups'].each do |name, payload|
105
+ validate_entity_payload!(name, payload, 'group', allowed_keys: %w[children tags vars])
106
+ children = array_value(payload, 'children', label: "group '#{name}' children")
107
+ children.each do |child_name|
108
+ next if snapshot['groups'].key?(child_name)
109
+
110
+ raise_invalid("group '#{name}' references unknown child group '#{child_name}'")
111
+ end
112
+ end
113
+ end
114
+
115
+ def validate_entity_payload!(name, payload, label, allowed_keys:)
116
+ raise_invalid("#{label} name cannot be empty") if blank_string?(name)
117
+ raise_invalid("#{label} '#{name}' must be a mapping") unless payload.is_a?(Hash)
118
+
119
+ unsupported = payload.keys - allowed_keys
120
+ unless unsupported.empty?
121
+ raise_invalid("#{label} '#{name}' has unsupported fields: #{unsupported.join(', ')}")
122
+ end
123
+
124
+ variables = payload.fetch('vars', {})
125
+ raise_invalid("#{label} '#{name}' vars must be a mapping") unless variables.is_a?(Hash)
126
+ variables.each_key do |variable_name|
127
+ raise_invalid("#{label} '#{name}' variable name cannot be empty") if blank_string?(variable_name)
128
+ end
129
+
130
+ payload['tags'] = context.normalize_tag_names(array_value(payload, 'tags', label: "#{label} '#{name}' tags"))
131
+ end
132
+
133
+ def validate_group_cycles!(groups)
134
+ GroupCycleValidator.new(context: context).call(groups)
135
+ end
136
+
137
+ def array_value(payload, key, label:)
138
+ value = payload.fetch(key, [])
139
+ raise_invalid("#{label} must be a list") unless value.is_a?(Array)
140
+
141
+ value.map(&:to_s)
142
+ end
143
+
144
+ def deep_stringify_keys(value)
145
+ case value
146
+ when Hash
147
+ stringify_hash_keys(value)
148
+ when Array
149
+ value.map { |entry| deep_stringify_keys(entry) }
150
+ else
151
+ value
152
+ end
153
+ end
154
+
155
+ def stringify_hash_keys(hash)
156
+ hash.each_with_object({}) do |(key, val), result|
157
+ normalized_key = key.to_s
158
+ raise_invalid("duplicate normalized key '#{normalized_key}'") if result.key?(normalized_key)
159
+
160
+ result[normalized_key] = deep_stringify_keys(val)
161
+ end
162
+ end
163
+
164
+ def blank_string?(value)
165
+ value.to_s.strip.empty?
166
+ end
167
+
168
+ def raise_invalid(message)
169
+ raise context.moose_exception_class, "Invalid inventory snapshot: #{message}."
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moose
4
+ module Inventory
5
+ module Operations
6
+ # Shared structured event/result plumbing for inventory operations.
7
+ module OperationEventSupport
8
+ Event = Struct.new(:type, :payload, keyword_init: true)
9
+ Result = Struct.new(:events, :warning_count, keyword_init: true)
10
+
11
+ private
12
+
13
+ def build_event(type, payload = {})
14
+ Event.new(type: type, payload: payload)
15
+ end
16
+
17
+ def emit(events, type, payload = {})
18
+ events << build_event(type, payload)
19
+ end
20
+
21
+ def operation_result(events:, warning_count: 0)
22
+ Result.new(events: events, warning_count: warning_count)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moose
4
+ module Inventory
5
+ module Operations
6
+ class QueryInventory
7
+ # Shared helpers for query subcomponents.
8
+ class BaseQuery
9
+ def initialize(context:)
10
+ @context = context
11
+ end
12
+
13
+ private
14
+
15
+ attr_reader :context
16
+
17
+ def variables_hash(dataset)
18
+ dataset.order(:id).to_h { |variable| [variable[:name].to_sym, variable[:value]] }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moose
4
+ module Inventory
5
+ module Operations
6
+ class QueryInventory
7
+ # Group-focused read queries.
8
+ class GroupQueries < BaseQuery
9
+ def get_groups(names:)
10
+ names.each_with_object({}) do |name, results|
11
+ group = context.find_group(name)
12
+ next if group.nil?
13
+
14
+ results[group.name.to_sym] = group_data(group)
15
+ end
16
+ end
17
+
18
+ def list_groups(ansible:)
19
+ context.all_groups.each_with_object({}) do |group, results|
20
+ hosts = group.hosts_dataset.map(:name)
21
+ next if hide_empty_automatic_group?(group, hosts)
22
+
23
+ results[group.name.to_sym] = list_group_data(group, hosts, ansible: ansible)
24
+ end
25
+ end
26
+
27
+ def list_group_vars(names:, ansible:)
28
+ return {} if names.empty?
29
+ return ansible_group_vars(names.first) if ansible
30
+
31
+ names.each_with_object({}) do |name, results|
32
+ group = context.find_group(name)
33
+ next if group.nil?
34
+
35
+ results[name.to_sym] = variables_hash(group.groupvars_dataset)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def group_data(group)
42
+ {}.tap do |data|
43
+ hosts = group.hosts_dataset.map(:name)
44
+ data[:hosts] = hosts unless hosts.empty?
45
+
46
+ children = group.children_dataset.map(:name)
47
+ data[:children] = children unless children.empty?
48
+
49
+ groupvars = variables_hash(group.groupvars_dataset)
50
+ data[:groupvars] = groupvars unless groupvars.empty?
51
+ end
52
+ end
53
+
54
+ def hide_empty_automatic_group?(group, hosts)
55
+ group.name == 'ungrouped' && hosts.empty?
56
+ end
57
+
58
+ def list_group_data(group, hosts, ansible:)
59
+ {}.tap do |data|
60
+ data[:hosts] = hosts if ansible || !hosts.empty?
61
+
62
+ children = group.children_dataset.map(:name)
63
+ data[:children] = children unless children.empty?
64
+
65
+ append_group_vars(data, group, ansible: ansible)
66
+ end
67
+ end
68
+
69
+ def append_group_vars(data, group, ansible:)
70
+ groupvars = variables_hash(group.groupvars_dataset)
71
+ return if groupvars.empty?
72
+
73
+ data[ansible ? :vars : :groupvars] = groupvars
74
+ end
75
+
76
+ def ansible_group_vars(name)
77
+ group = context.find_group(name)
78
+ return {} if group.nil?
79
+
80
+ variables_hash(group.groupvars_dataset)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moose
4
+ module Inventory
5
+ module Operations
6
+ class QueryInventory
7
+ # Host-focused read queries.
8
+ class HostQueries < BaseQuery
9
+ def get_hosts(names:)
10
+ names.each_with_object({}) do |name, results|
11
+ host = context.find_host(name)
12
+ next if host.nil?
13
+
14
+ results[host.name.to_sym] = host_data(host)
15
+ end
16
+ end
17
+
18
+ def list_hosts(filters: {})
19
+ dataset = filtered_hosts_dataset(filters)
20
+ return {} if dataset.nil?
21
+
22
+ dataset.order(:id).all.to_h do |host|
23
+ [host.name.to_sym, host_data(host)]
24
+ end
25
+ end
26
+
27
+ def list_host_vars(names:, ansible:)
28
+ return ansible_host_vars(names.first) if ansible
29
+
30
+ names.each_with_object({}) do |name, results|
31
+ host = context.find_host(name)
32
+ next if host.nil?
33
+
34
+ results[name.to_sym] = variables_hash(host.hostvars_dataset)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def host_data(host)
41
+ {}.tap do |data|
42
+ groups = host.groups_dataset.map(:name)
43
+ data[:groups] = groups unless groups.empty?
44
+
45
+ tags = host.tags_dataset.map(:name).sort
46
+ data[:tags] = tags unless tags.empty?
47
+
48
+ hostvars = variables_hash(host.hostvars_dataset)
49
+ data[:hostvars] = hostvars unless hostvars.empty?
50
+ end
51
+ end
52
+
53
+ def filtered_hosts_dataset(filters)
54
+ dataset = context.hosts_dataset
55
+ dataset = filter_hosts_by_groups(dataset, filters.fetch(:groups, []))
56
+ return nil if dataset.nil?
57
+
58
+ dataset = filter_hosts_by_tags(dataset, filters.fetch(:tags, []))
59
+ return nil if dataset.nil?
60
+
61
+ filter_hosts_by_variables(dataset, filters.fetch(:variables, {}))
62
+ end
63
+
64
+ def filter_hosts_by_groups(dataset, groups)
65
+ groups.reduce(dataset) do |current_dataset, group_name|
66
+ group = context.find_group(group_name)
67
+ return nil if group.nil?
68
+
69
+ current_dataset.where(id: context.db_dataset(:groups_hosts).where(group_id: group.id).select(:host_id))
70
+ end
71
+ end
72
+
73
+ def filter_hosts_by_tags(dataset, tags)
74
+ tags.reduce(dataset) do |current_dataset, tag_name|
75
+ tag = context.find_tag(tag_name)
76
+ return nil if tag.nil?
77
+
78
+ current_dataset.where(id: context.db_dataset(:hosts_tags).where(tag_id: tag.id).select(:host_id))
79
+ end
80
+ end
81
+
82
+ def filter_hosts_by_variables(dataset, variables)
83
+ variables.reduce(dataset) do |current_dataset, (name, value)|
84
+ current_dataset.where(
85
+ id: context.db_dataset(:hostvars).where(name: name, value: value).select(:host_id)
86
+ )
87
+ end
88
+ end
89
+
90
+ def ansible_host_vars(name)
91
+ results = {}
92
+ host = context.find_host(name)
93
+ results.merge!(variables_hash(host.hostvars_dataset)) unless host.nil?
94
+
95
+ results[:_meta] = {
96
+ hostvars: context.all_hosts.to_h do |entry|
97
+ [entry.name.to_sym, variables_hash(entry.hostvars_dataset)]
98
+ end
99
+ }
100
+ results
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'query_inventory/base_query'
4
+ require_relative 'query_inventory/host_queries'
5
+ require_relative 'query_inventory/group_queries'
6
+
7
+ module Moose
8
+ module Inventory
9
+ module Operations
10
+ # Read-only inventory query seam for host/group CLI commands.
11
+ class QueryInventory
12
+ def initialize(context:)
13
+ @host_queries = HostQueries.new(context: context)
14
+ @group_queries = GroupQueries.new(context: context)
15
+ end
16
+
17
+ def get_hosts(names:)
18
+ host_queries.get_hosts(names: names)
19
+ end
20
+
21
+ def list_hosts(filters: {})
22
+ host_queries.list_hosts(filters: filters)
23
+ end
24
+
25
+ def list_host_vars(names:, ansible:)
26
+ host_queries.list_host_vars(names: names, ansible: ansible)
27
+ end
28
+
29
+ def get_groups(names:)
30
+ group_queries.get_groups(names: names)
31
+ end
32
+
33
+ def list_groups(ansible:)
34
+ group_queries.list_groups(ansible: ansible)
35
+ end
36
+
37
+ def list_group_vars(names:, ansible:)
38
+ group_queries.list_group_vars(names: names, ansible: ansible)
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :group_queries, :host_queries
44
+ end
45
+ end
46
+ end
47
+ end