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,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ require 'spec_helper'
5
+ require 'inventory_context'
6
+ require 'operations/inventory_snapshot'
7
+ require 'operations/import_inventory_snapshot'
8
+
9
+ RSpec.describe Moose::Inventory::Operations::ImportInventorySnapshot do
10
+ before(:all) do
11
+ @mockargs = [
12
+ '--config', File.join(spec_root, 'config/config.yml'),
13
+ '--format', 'yaml',
14
+ '--env', 'test'
15
+ ]
16
+
17
+ Moose::Inventory::Config.init(@mockargs)
18
+ @db = Moose::Inventory::DB
19
+ @db.init if @db.db.nil?
20
+ end
21
+
22
+ before(:each) do
23
+ @db.reset
24
+ end
25
+
26
+ def operation
27
+ described_class.new(context: Moose::Inventory::InventoryContext.new(db: @db))
28
+ end
29
+
30
+ it 'imports a validated snapshot transactionally' do
31
+ snapshot = {
32
+ 'version' => 1,
33
+ 'hosts' => {
34
+ 'web01' => { 'groups' => ['web'], 'tags' => [], 'vars' => { 'env' => 'prod' } }
35
+ },
36
+ 'groups' => {
37
+ 'web' => { 'children' => ['blue'], 'tags' => [], 'vars' => { 'role' => 'frontend' } },
38
+ 'blue' => { 'children' => [], 'tags' => [], 'vars' => {} }
39
+ }
40
+ }
41
+
42
+ result = operation.call(snapshot: snapshot)
43
+
44
+ expect(result.created_hosts).to eq(1)
45
+ expect(result.created_groups).to eq(2)
46
+ expect(result.updated_variables).to eq(2)
47
+ expect(result.associations).to eq(2)
48
+ host = @db.models[:host].find(name: 'web01')
49
+ group = @db.models[:group].find(name: 'web')
50
+ expect(host.groups_dataset[name: 'web']).not_to be_nil
51
+ expect(host.hostvars_dataset[name: 'env'][:value]).to eq('prod')
52
+ expect(group.children_dataset[name: 'blue']).not_to be_nil
53
+ expect(group.groupvars_dataset[name: 'role'][:value]).to eq('frontend')
54
+ end
55
+
56
+ it 'normalizes imported tag casing and deduplicates tags before applying' do
57
+ snapshot = {
58
+ version: 1,
59
+ hosts: {
60
+ web01: { groups: ['web'], tags: ['Prod', 'prod', ' OWNER-Platform ', ''], vars: {} }
61
+ },
62
+ groups: {
63
+ web: { children: [], tags: %w[Frontend frontend], vars: {} }
64
+ }
65
+ }
66
+
67
+ result = operation.call(snapshot: snapshot)
68
+
69
+ host = @db.models[:host].find(name: 'web01')
70
+ group = @db.models[:group].find(name: 'web')
71
+ expect(result.associations).to eq(4)
72
+ expect(host.tags_dataset.order(:name).map(:name)).to eq(%w[owner-platform prod])
73
+ expect(group.tags_dataset.order(:name).map(:name)).to eq(%w[frontend])
74
+ expect(@db.models[:tag].order(:name).map(:name)).to eq(%w[frontend owner-platform prod])
75
+ expect(@db.models[:tag].where(name: 'Prod').count).to eq(0)
76
+
77
+ exported = Moose::Inventory::Operations::InventorySnapshot.new(
78
+ context: Moose::Inventory::InventoryContext.new(db: @db)
79
+ ).export
80
+ expect(exported.dig('hosts', 'web01', 'tags')).to eq(%w[owner-platform prod])
81
+ expect(exported.dig('groups', 'web', 'tags')).to eq(%w[frontend])
82
+ end
83
+
84
+ it 'rejects unknown group references before writing anything' do
85
+ snapshot = {
86
+ version: 1,
87
+ hosts: { web01: { groups: ['missing'], vars: {} } },
88
+ groups: {}
89
+ }
90
+
91
+ expect do
92
+ operation.call(snapshot: snapshot)
93
+ end.to raise_error(Moose::Inventory::DB.exceptions[:moose], /references unknown group 'missing'/)
94
+
95
+ expect(@db.models[:host].count).to eq(0)
96
+ expect(@db.models[:group].count).to eq(0)
97
+ end
98
+
99
+ it 'rejects whitespace-only entity names before writing anything' do
100
+ snapshot = {
101
+ version: 1,
102
+ hosts: { ' ' => { groups: [], vars: {} } },
103
+ groups: {}
104
+ }
105
+
106
+ expect do
107
+ operation.call(snapshot: snapshot)
108
+ end.to raise_error(Moose::Inventory::DB.exceptions[:moose], /host name cannot be empty/)
109
+
110
+ expect(@db.models[:host].count).to eq(0)
111
+ end
112
+
113
+ it 'rejects duplicate normalized keys before applying the snapshot' do
114
+ snapshot = {
115
+ 'version' => 1,
116
+ :version => 1,
117
+ hosts: {},
118
+ groups: {}
119
+ }
120
+
121
+ expect do
122
+ operation.call(snapshot: snapshot)
123
+ end.to raise_error(Moose::Inventory::DB.exceptions[:moose], /duplicate normalized key 'version'/)
124
+
125
+ expect(@db.models[:host].count).to eq(0)
126
+ expect(@db.models[:group].count).to eq(0)
127
+ end
128
+
129
+ it 'rejects whitespace-only variable names before writing anything' do
130
+ snapshot = {
131
+ version: 1,
132
+ hosts: { web01: { groups: [], vars: { ' ' => 'prod' } } },
133
+ groups: {}
134
+ }
135
+
136
+ expect do
137
+ operation.call(snapshot: snapshot)
138
+ end.to raise_error(Moose::Inventory::DB.exceptions[:moose], /variable name cannot be empty/)
139
+
140
+ expect(@db.models[:host].count).to eq(0)
141
+ end
142
+
143
+ it 'rejects circular group hierarchies before writing anything' do
144
+ snapshot = {
145
+ version: 1,
146
+ hosts: {},
147
+ groups: {
148
+ parent: { children: ['child'], vars: {} },
149
+ child: { children: ['parent'], vars: {} }
150
+ }
151
+ }
152
+
153
+ expect do
154
+ operation.call(snapshot: snapshot)
155
+ end.to raise_error(Moose::Inventory::DB.exceptions[:moose], /contains a cycle/)
156
+
157
+ expect(@db.models[:group].count).to eq(0)
158
+ end
159
+ end
160
+ # rubocop:enable Metrics/BlockLength
161
+
162
+ # rubocop:disable Metrics/BlockLength
163
+
164
+ RSpec.describe Moose::Inventory::Operations::ImportInventorySnapshot, '#preview' do
165
+ before(:all) do
166
+ @mockargs = [
167
+ '--config', File.join(spec_root, 'config/config.yml'),
168
+ '--format', 'yaml',
169
+ '--env', 'test'
170
+ ]
171
+
172
+ Moose::Inventory::Config.init(@mockargs)
173
+ @db = Moose::Inventory::DB
174
+ @db.init if @db.db.nil?
175
+ end
176
+
177
+ before(:each) do
178
+ @db.reset
179
+ end
180
+
181
+ def operation
182
+ described_class.new(context: Moose::Inventory::InventoryContext.new(db: @db))
183
+ end
184
+
185
+ it 'returns a non-mutating snapshot import diff' do
186
+ runner = operation
187
+ @db.models[:host].create(name: 'existing')
188
+ group = @db.models[:group].create(name: 'web')
189
+ var = @db.models[:groupvar].create(name: 'role', value: 'old')
190
+ group.add_groupvar(var)
191
+
192
+ snapshot = {
193
+ version: 1,
194
+ hosts: {
195
+ web01: { groups: ['web'], tags: ['Prod'], vars: { env: 'prod' } }
196
+ },
197
+ groups: {
198
+ web: { children: ['blue'], tags: ['Frontend'], vars: { role: 'frontend' } },
199
+ blue: { children: [], tags: [], vars: {} }
200
+ }
201
+ }
202
+
203
+ preview = runner.preview(snapshot: snapshot)
204
+
205
+ expect(preview['schema_version']).to eq('snapshot-import-preview-v1')
206
+ expect(preview['changes_applied']).to eq(false)
207
+ expect(preview['summary']).to include(
208
+ 'hosts_created' => 1,
209
+ 'groups_created' => 1,
210
+ 'variables_changed' => 2,
211
+ 'associations_added' => 4,
212
+ 'ignored_existing_hosts' => 1,
213
+ 'destructive_changes' => 0
214
+ )
215
+ expect(preview.dig('creates', 'hosts')).to eq(['web01'])
216
+ expect(preview.dig('creates', 'groups')).to eq(['blue'])
217
+ expect(preview.dig('updates', 'group_vars')).to include(
218
+ 'entity' => 'web', 'name' => 'role', 'from' => 'old', 'to' => 'frontend'
219
+ )
220
+ expect(preview.dig('ignored', 'existing_hosts_not_in_snapshot')).to eq(['existing'])
221
+ expect(@db.models[:host].where(name: 'web01').count).to eq(0)
222
+ expect(@db.models[:group].where(name: 'blue').count).to eq(0)
223
+ expect(group.groupvars_dataset[name: 'role'][:value]).to eq('old')
224
+ end
225
+ end
226
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ require 'spec_helper'
5
+ require 'inventory_context'
6
+ require 'operations/inventory_doctor'
7
+
8
+ RSpec.describe Moose::Inventory::Operations::InventoryDoctor do
9
+ before(:all) do
10
+ @mockargs = [
11
+ '--config', File.join(spec_root, 'config/config.yml'),
12
+ '--format', 'yaml',
13
+ '--env', 'test'
14
+ ]
15
+
16
+ Moose::Inventory::Config.init(@mockargs)
17
+ @db = Moose::Inventory::DB
18
+ @db.init if @db.db.nil?
19
+ end
20
+
21
+ before(:each) do
22
+ @db.reset
23
+ end
24
+
25
+ def doctor(config: Moose::Inventory::Config)
26
+ described_class.new(context: Moose::Inventory::InventoryContext.new(db: @db), config: config)
27
+ end
28
+
29
+ it 'reports ok when the inventory and config have no findings' do
30
+ web = @db.models[:group].create(name: 'web')
31
+ host = @db.models[:host].create(name: 'web01')
32
+ host.add_group(web)
33
+
34
+ report = doctor.call
35
+
36
+ expect(report[:ok]).to eq(true)
37
+ expect(report[:issues]).to eq([])
38
+ end
39
+
40
+ it 'detects inventory health findings' do
41
+ ungrouped = @db.models[:group].create(name: 'ungrouped')
42
+ orphan = @db.models[:group].create(name: 'orphan')
43
+ duplicate = @db.models[:group].create(name: 'or-phan')
44
+ parent = @db.models[:group].create(name: 'parent')
45
+ child = @db.models[:group].create(name: 'child')
46
+ host = @db.models[:host].create(name: 'lonely')
47
+ bad_var = @db.models[:hostvar].create(name: '', value: 'oops')
48
+ host.add_group(ungrouped)
49
+ host.add_hostvar(bad_var)
50
+ parent.add_child(child)
51
+ child.add_child(parent)
52
+
53
+ report = doctor.call
54
+ @db.db[:groups_groups].delete
55
+
56
+ expect(report[:ok]).to eq(false)
57
+ expect(report[:issues].map { |issue| issue[:id] }).to include(
58
+ 'host_only_in_ungrouped',
59
+ 'orphaned_group',
60
+ 'empty_group',
61
+ 'duplicateish_group_names',
62
+ 'invalid_variable_shape',
63
+ 'circular_group_relationship'
64
+ )
65
+ expect(report[:issues].map { |issue| issue[:subject] }).to include(orphan.name)
66
+ expect(report[:issues].map { |issue| issue[:subject] }).to include([duplicate.name, orphan.name].sort)
67
+ end
68
+
69
+ it 'detects plaintext password configuration' do
70
+ config = instance_double('Config', db_settings: { adapter: 'mysql', password: 'secret' })
71
+
72
+ report = doctor(config: config).call
73
+
74
+ expect(report[:issues].map { |issue| issue[:id] }).to include('plaintext_password_config')
75
+ end
76
+ end
77
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ require 'spec_helper'
5
+ require 'inventory_context'
6
+ require 'operations/inventory_snapshot'
7
+
8
+ RSpec.describe Moose::Inventory::Operations::InventorySnapshot do
9
+ before(:all) do
10
+ @mockargs = [
11
+ '--config', File.join(spec_root, 'config/config.yml'),
12
+ '--format', 'yaml',
13
+ '--env', 'test'
14
+ ]
15
+
16
+ Moose::Inventory::Config.init(@mockargs)
17
+ @db = Moose::Inventory::DB
18
+ @db.init if @db.db.nil?
19
+ end
20
+
21
+ before(:each) do
22
+ @db.reset
23
+ end
24
+
25
+ it 'exports hosts, groups, variables, and child relationships in canonical order' do
26
+ host = @db.models[:host].create(name: 'web01')
27
+ group = @db.models[:group].create(name: 'web')
28
+ child = @db.models[:group].create(name: 'blue')
29
+ hostvar = @db.models[:hostvar].create(name: 'env', value: 'prod')
30
+ groupvar = @db.models[:groupvar].create(name: 'role', value: 'frontend')
31
+ host.add_group(group)
32
+ host.add_hostvar(hostvar)
33
+ group.add_child(child)
34
+ group.add_groupvar(groupvar)
35
+
36
+ snapshot = described_class.new(context: Moose::Inventory::InventoryContext.new(db: @db)).export
37
+
38
+ expect(snapshot).to eq(
39
+ 'version' => 1,
40
+ 'hosts' => {
41
+ 'web01' => { 'groups' => ['web'], 'tags' => [], 'vars' => { 'env' => 'prod' } }
42
+ },
43
+ 'groups' => {
44
+ 'blue' => { 'children' => [], 'tags' => [], 'vars' => {} },
45
+ 'web' => { 'children' => ['blue'], 'tags' => [], 'vars' => { 'role' => 'frontend' } }
46
+ }
47
+ )
48
+ end
49
+ end
50
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ require 'spec_helper'
5
+ require 'operations/operation_event_support'
6
+
7
+ RSpec.describe Moose::Inventory::Operations::OperationEventSupport do
8
+ subject(:operation) { operation_class.new }
9
+
10
+ let(:operation_class) do
11
+ Class.new do
12
+ include Moose::Inventory::Operations::OperationEventSupport
13
+
14
+ def event(type, payload = {})
15
+ build_event(type, payload)
16
+ end
17
+
18
+ def append_event(events, type, payload = {})
19
+ emit(events, type, payload)
20
+ end
21
+
22
+ def result(events:, warning_count: 0)
23
+ operation_result(events: events, warning_count: warning_count)
24
+ end
25
+ end
26
+ end
27
+
28
+ describe '#build_event' do
29
+ it 'builds structured events with an empty default payload' do
30
+ event = operation.event(:started)
31
+
32
+ expect(event).to be_a(described_class::Event)
33
+ expect(event.type).to eq(:started)
34
+ expect(event.payload).to eq({})
35
+ end
36
+
37
+ it 'preserves the provided event payload' do
38
+ event = operation.event(:created, name: 'testhost')
39
+
40
+ expect(event.type).to eq(:created)
41
+ expect(event.payload).to eq(name: 'testhost')
42
+ end
43
+ end
44
+
45
+ describe '#emit' do
46
+ it 'appends a constructed event to the provided collection' do
47
+ events = []
48
+
49
+ result = operation.append_event(events, :warning, name: 'missinggroup')
50
+ event = events.fetch(0)
51
+
52
+ expect(result).to eq(events)
53
+ expect(event).to be_a(described_class::Event)
54
+ expect(event.type).to eq(:warning)
55
+ expect(event.payload).to eq(name: 'missinggroup')
56
+ end
57
+ end
58
+
59
+ describe '#operation_result' do
60
+ it 'defaults warning_count to zero' do
61
+ events = [operation.event(:ok)]
62
+
63
+ result = operation.result(events: events)
64
+
65
+ expect(result).to be_a(described_class::Result)
66
+ expect(result.events).to eq(events)
67
+ expect(result.warning_count).to eq(0)
68
+ expect(result.warning_count.zero?).to eq(true)
69
+ end
70
+
71
+ it 'preserves an explicit warning_count' do
72
+ result = operation.result(events: [], warning_count: 2)
73
+
74
+ expect(result.warning_count).to eq(2)
75
+ end
76
+ end
77
+ end
78
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'inventory_context'
5
+ require 'operations/query_inventory'
6
+
7
+ RSpec.describe Moose::Inventory::Operations::QueryInventory do
8
+ before(:all) do
9
+ @mockargs = [
10
+ '--config', File.join(spec_root, 'config/config.yml'),
11
+ '--format', 'yaml',
12
+ '--env', 'test'
13
+ ]
14
+
15
+ Moose::Inventory::Config.init(@mockargs)
16
+ @db = Moose::Inventory::DB
17
+ @db.init if @db.db.nil?
18
+ end
19
+
20
+ before(:each) do
21
+ @db.reset
22
+ end
23
+
24
+ def operation
25
+ described_class.new(
26
+ context: Moose::Inventory::InventoryContext.new(db: @db)
27
+ )
28
+ end
29
+
30
+ it 'gets host data without rendering output' do
31
+ host = @db.models[:host].create(name: 'test1')
32
+ host.add_group(@db.models[:group].find_or_create(name: 'ungrouped'))
33
+ var = @db.models[:hostvar].create(name: 'foo', value: 'bar')
34
+ host.add_hostvar(var)
35
+
36
+ actual = runner do
37
+ @result = operation.get_hosts(names: ['test1'])
38
+ end
39
+
40
+ expected(actual, STDOUT: '', STDERR: '')
41
+ expect(@result).to eq(
42
+ test1: {
43
+ groups: ['ungrouped'],
44
+ hostvars: { foo: 'bar' }
45
+ }
46
+ )
47
+ end
48
+
49
+ it 'filters listed hosts by group, tag, and variable' do
50
+ host = @db.models[:host].create(name: 'web01')
51
+ other = @db.models[:host].create(name: 'db01')
52
+ group = @db.models[:group].find_or_create(name: 'web')
53
+ tag = @db.models[:tag].find_or_create(name: 'prod')
54
+ host.add_group(group)
55
+ host.add_tag(tag)
56
+ other.add_tag(tag)
57
+ hostvar = @db.models[:hostvar].create(name: 'os', value: 'fedora')
58
+ other_var = @db.models[:hostvar].create(name: 'os', value: 'debian')
59
+ host.add_hostvar(hostvar)
60
+ other.add_hostvar(other_var)
61
+
62
+ expect(operation.list_hosts(filters: { groups: ['web'], tags: ['prod'], variables: { 'os' => 'fedora' } })).to eq(
63
+ web01: {
64
+ groups: ['web'],
65
+ tags: ['prod'],
66
+ hostvars: { os: 'fedora' }
67
+ }
68
+ )
69
+ end
70
+
71
+ it 'uses DB-backed host filters without loading all hosts first' do
72
+ host = @db.models[:host].create(name: 'web01')
73
+ other = @db.models[:host].create(name: 'db01')
74
+ group = @db.models[:group].find_or_create(name: 'web')
75
+ tag = @db.models[:tag].find_or_create(name: 'prod')
76
+ host.add_group(group)
77
+ host.add_tag(tag)
78
+ other.add_tag(tag)
79
+ host.add_hostvar(@db.models[:hostvar].create(name: 'os', value: 'fedora'))
80
+ other.add_hostvar(@db.models[:hostvar].create(name: 'os', value: 'debian'))
81
+ context = Moose::Inventory::InventoryContext.new(db: @db)
82
+ expect(context).not_to receive(:all_hosts)
83
+
84
+ result = described_class.new(context: context).list_hosts(
85
+ filters: { groups: ['web'], tags: ['prod'], variables: { 'os' => 'fedora' } }
86
+ )
87
+
88
+ expect(result.keys).to eq([:web01])
89
+ end
90
+
91
+ it 'returns no listed hosts when a DB-backed filter references a missing group or tag' do
92
+ @db.models[:host].create(name: 'web01')
93
+
94
+ expect(operation.list_hosts(filters: { groups: ['missing'] })).to eq({})
95
+ expect(operation.list_hosts(filters: { tags: ['missing'] })).to eq({})
96
+ end
97
+
98
+ it 'applies multiple group filters as AND predicates' do
99
+ host = @db.models[:host].create(name: 'web01')
100
+ partial = @db.models[:host].create(name: 'web02')
101
+ web = @db.models[:group].find_or_create(name: 'web')
102
+ prod = @db.models[:group].find_or_create(name: 'prod')
103
+ host.add_group(web)
104
+ host.add_group(prod)
105
+ partial.add_group(web)
106
+
107
+ expect(operation.list_hosts(filters: { groups: %w[web prod] }).keys).to eq([:web01])
108
+ end
109
+
110
+ it 'gets group data while omitting empty relationship collections' do
111
+ @db.models[:group].create(name: 'group1')
112
+
113
+ expect(operation.get_groups(names: ['group1'])).to eq(
114
+ group1: {}
115
+ )
116
+ end
117
+
118
+ it 'lists groups in ansible mode with hosts arrays and vars key' do
119
+ group = @db.models[:group].create(name: 'group1')
120
+ var = @db.models[:groupvar].create(name: 'foo', value: 'bar')
121
+ group.add_groupvar(var)
122
+
123
+ expect(operation.list_groups(ansible: true)).to eq(
124
+ group1: {
125
+ hosts: [],
126
+ vars: { foo: 'bar' }
127
+ }
128
+ )
129
+ end
130
+
131
+ it 'builds ansible hostvars metadata for host listvars queries' do
132
+ host = @db.models[:host].create(name: 'test1')
133
+ host.add_group(@db.models[:group].find_or_create(name: 'ungrouped'))
134
+ var = @db.models[:hostvar].create(name: 'foo', value: 'bar')
135
+ host.add_hostvar(var)
136
+
137
+ expect(operation.list_host_vars(names: ['test1'], ansible: true)).to eq(
138
+ foo: 'bar',
139
+ _meta: {
140
+ hostvars: {
141
+ test1: { foo: 'bar' }
142
+ }
143
+ }
144
+ )
145
+ end
146
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'inventory_context'
5
+ require 'operations/remove_associations'
6
+
7
+ RSpec.describe Moose::Inventory::Operations::RemoveAssociations do
8
+ before(:all) do
9
+ @mockargs = [
10
+ '--config', File.join(spec_root, 'config/config.yml'),
11
+ '--format', 'yaml',
12
+ '--env', 'test'
13
+ ]
14
+
15
+ Moose::Inventory::Config.init(@mockargs)
16
+ @db = Moose::Inventory::DB
17
+ @db.init if @db.db.nil?
18
+ end
19
+
20
+ before(:each) do
21
+ @db.reset
22
+ end
23
+
24
+ def operation
25
+ described_class.new(
26
+ context: Moose::Inventory::InventoryContext.new(db: @db)
27
+ )
28
+ end
29
+
30
+ it 'removes groups from a host and reports missing associations and ungrouped reattachment' do
31
+ host = @db.models[:host].create(name: 'host1')
32
+ group = @db.models[:group].create(name: 'group1')
33
+ other = @db.models[:group].create(name: 'group2')
34
+ host.add_group(group)
35
+ host.add_group(other)
36
+
37
+ result = operation.host_from_groups(
38
+ host: host,
39
+ host_name: 'host1',
40
+ group_names: %w[group1 missing group2]
41
+ )
42
+
43
+ expect(result.warning_count).to eq(1)
44
+ expect(result.events.map(&:type)).to include(
45
+ :host_group_association_missing,
46
+ :adding_automatic_group
47
+ )
48
+ expect(host.groups_dataset[name: 'group1']).to be_nil
49
+ expect(host.groups_dataset[name: 'group2']).to be_nil
50
+ expect(host.groups_dataset[name: 'ungrouped']).not_to be_nil
51
+ end
52
+
53
+ it 'dry-runs removing groups from a host without removing associations or adding ungrouped' do
54
+ host = @db.models[:host].create(name: 'host1')
55
+ group = @db.models[:group].create(name: 'group1')
56
+ host.add_group(group)
57
+
58
+ result = operation.host_from_groups(
59
+ host: host,
60
+ host_name: 'host1',
61
+ group_names: ['group1'],
62
+ dry_run: true
63
+ )
64
+
65
+ expect(result.warning_count).to eq(0)
66
+ expect(result.events.map(&:type)).to include(:adding_automatic_group, :dry_run_summary)
67
+ expect(host.groups_dataset[name: 'group1']).not_to be_nil
68
+ expect(host.groups_dataset[name: 'ungrouped']).to be_nil
69
+ end
70
+
71
+ it 'dry-runs removing hosts from a group without removing associations or adding ungrouped' do
72
+ group = @db.models[:group].create(name: 'group1')
73
+ host = @db.models[:host].create(name: 'host1')
74
+ group.add_host(host)
75
+
76
+ result = operation.group_from_hosts(
77
+ group: group,
78
+ group_name: 'group1',
79
+ host_names: ['host1'],
80
+ dry_run: true
81
+ )
82
+
83
+ expect(result.warning_count).to eq(0)
84
+ expect(result.events.map(&:type)).to include(:adding_automatic_group, :dry_run_summary)
85
+ expect(group.hosts_dataset[name: 'host1']).not_to be_nil
86
+ expect(host.groups_dataset[name: 'ungrouped']).to be_nil
87
+ end
88
+ it 'removes hosts from a group and reports missing associations and ungrouped reattachment' do
89
+ group = @db.models[:group].create(name: 'group1')
90
+ host1 = @db.models[:host].create(name: 'host1')
91
+ host2 = @db.models[:host].create(name: 'host2')
92
+ extra = @db.models[:group].create(name: 'extra')
93
+ host2.add_group(extra)
94
+ group.add_host(host1)
95
+ group.add_host(host2)
96
+
97
+ result = operation.group_from_hosts(
98
+ group: group,
99
+ group_name: 'group1',
100
+ host_names: %w[host1 missing host2]
101
+ )
102
+
103
+ expect(result.warning_count).to eq(1)
104
+ expect(result.events.map(&:type)).to include(
105
+ :group_host_association_missing,
106
+ :adding_automatic_group
107
+ )
108
+ expect(group.hosts_dataset[name: 'host1']).to be_nil
109
+ expect(group.hosts_dataset[name: 'host2']).to be_nil
110
+ expect(host1.groups_dataset[name: 'ungrouped']).not_to be_nil
111
+ expect(host2.groups_dataset[name: 'ungrouped']).to be_nil
112
+ end
113
+ end