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,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
@@ -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