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,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,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moose
4
+ module Inventory
5
+ module Operations
6
+ # Normalizes and validates portable inventory snapshot input before import.
7
+ class InventorySnapshotValidator
8
+ def initialize(context:)
9
+ @context = context
10
+ end
11
+
12
+ def call(snapshot:)
13
+ normalized = deep_stringify_keys(snapshot)
14
+ validate_snapshot!(normalized)
15
+ normalized
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :context
21
+
22
+ def validate_snapshot!(snapshot)
23
+ raise_invalid('snapshot must be a mapping') unless snapshot.is_a?(Hash)
24
+ raise_invalid('version must be 1') unless snapshot['version'].to_i == InventorySnapshot::VERSION
25
+ raise_invalid('hosts must be a mapping') unless snapshot['hosts'].is_a?(Hash)
26
+ raise_invalid('groups must be a mapping') unless snapshot['groups'].is_a?(Hash)
27
+
28
+ validate_hosts!(snapshot)
29
+ validate_groups!(snapshot)
30
+ validate_group_cycles!(snapshot['groups'])
31
+ end
32
+
33
+ def validate_hosts!(snapshot)
34
+ snapshot['hosts'].each do |name, payload|
35
+ validate_entity_payload!(name, payload, 'host', allowed_keys: %w[groups tags vars])
36
+ groups = array_value(payload, 'groups', label: "host '#{name}' groups")
37
+ groups.each do |group_name|
38
+ next if snapshot['groups'].key?(group_name)
39
+
40
+ raise_invalid("host '#{name}' references unknown group '#{group_name}'")
41
+ end
42
+ end
43
+ end
44
+
45
+ def validate_groups!(snapshot)
46
+ snapshot['groups'].each do |name, payload|
47
+ validate_entity_payload!(name, payload, 'group', allowed_keys: %w[children tags vars])
48
+ children = array_value(payload, 'children', label: "group '#{name}' children")
49
+ children.each do |child_name|
50
+ next if snapshot['groups'].key?(child_name)
51
+
52
+ raise_invalid("group '#{name}' references unknown child group '#{child_name}'")
53
+ end
54
+ end
55
+ end
56
+
57
+ def validate_entity_payload!(name, payload, label, allowed_keys:)
58
+ raise_invalid("#{label} name cannot be empty") if blank_string?(name)
59
+ raise_invalid("#{label} '#{name}' must be a mapping") unless payload.is_a?(Hash)
60
+
61
+ unsupported = payload.keys - allowed_keys
62
+ unless unsupported.empty?
63
+ raise_invalid("#{label} '#{name}' has unsupported fields: #{unsupported.join(', ')}")
64
+ end
65
+
66
+ variables = payload.fetch('vars', {})
67
+ raise_invalid("#{label} '#{name}' vars must be a mapping") unless variables.is_a?(Hash)
68
+ variables.each_key do |variable_name|
69
+ raise_invalid("#{label} '#{name}' variable name cannot be empty") if blank_string?(variable_name)
70
+ end
71
+
72
+ payload['tags'] = context.normalize_tag_names(array_value(payload, 'tags', label: "#{label} '#{name}' tags"))
73
+ end
74
+
75
+ def validate_group_cycles!(groups)
76
+ visiting = {}
77
+ visited = {}
78
+
79
+ groups.each_key do |name|
80
+ visit_group!(name, groups, visiting, visited)
81
+ end
82
+ end
83
+
84
+ def visit_group!(name, groups, visiting, visited)
85
+ return if visited[name]
86
+
87
+ raise_invalid("group hierarchy contains a cycle at '#{name}'") if visiting[name]
88
+
89
+ visiting[name] = true
90
+ array_value(groups[name], 'children', label: "group '#{name}' children").each do |child_name|
91
+ visit_group!(child_name, groups, visiting, visited)
92
+ end
93
+ visiting.delete(name)
94
+ visited[name] = true
95
+ end
96
+
97
+ def array_value(payload, key, label:)
98
+ value = payload.fetch(key, [])
99
+ raise_invalid("#{label} must be a list") unless value.is_a?(Array)
100
+
101
+ value.map(&:to_s)
102
+ end
103
+
104
+ def deep_stringify_keys(value)
105
+ case value
106
+ when Hash
107
+ stringify_hash_keys(value)
108
+ when Array
109
+ value.map { |entry| deep_stringify_keys(entry) }
110
+ else
111
+ value
112
+ end
113
+ end
114
+
115
+ def stringify_hash_keys(hash)
116
+ hash.each_with_object({}) do |(key, val), result|
117
+ normalized_key = key.to_s
118
+ raise_invalid("duplicate normalized key '#{normalized_key}'") if result.key?(normalized_key)
119
+
120
+ result[normalized_key] = deep_stringify_keys(val)
121
+ end
122
+ end
123
+
124
+ def blank_string?(value)
125
+ value.to_s.strip.empty?
126
+ end
127
+
128
+ def raise_invalid(message)
129
+ raise context.moose_exception_class, "Invalid inventory snapshot: #{message}."
130
+ end
131
+ end
132
+ end
133
+ end
134
+ 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