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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'spec_helper'
2
4
  require 'fileutils'
3
5
  require 'tmpdir'
@@ -5,17 +7,23 @@ require 'tmpdir'
5
7
  RSpec.describe 'Moose::Inventory::DB' do
6
8
  def with_db_config(db_config)
7
9
  saved_db = @db.instance_variable_get(:@db)
10
+ saved_models = @db.instance_variable_get(:@models)
11
+ saved_exceptions = @db.instance_variable_get(:@exceptions)
8
12
  saved_settings = @config._settings.dup
9
13
 
10
14
  begin
11
- @db.instance_variable_set(:@db, nil)
15
+ @db.reset_runtime_state
16
+ @db.init_exceptions
12
17
  @config._settings.clear
13
18
  @config._settings[:config] = { db: db_config }
14
19
  yield
15
20
  ensure
16
21
  current_db = @db.instance_variable_get(:@db)
17
22
  current_db.disconnect if current_db.respond_to?(:disconnect)
23
+ @db.reset_runtime_state
18
24
  @db.instance_variable_set(:@db, saved_db)
25
+ @db.instance_variable_set(:@models, saved_models)
26
+ @db.instance_variable_set(:@exceptions, saved_exceptions)
19
27
  @config._settings.clear
20
28
  @config._settings.merge!(saved_settings)
21
29
  end
@@ -28,9 +36,9 @@ RSpec.describe 'Moose::Inventory::DB' do
28
36
  before(:all) do
29
37
  # Set up the configuration object
30
38
  @mockarg_parts = {
31
- config: File.join(spec_root, 'config/config.yml'),
32
- format: 'yaml',
33
- env: 'test',
39
+ config: File.join(spec_root, 'config/config.yml'),
40
+ format: 'yaml',
41
+ env: 'test'
34
42
  }
35
43
 
36
44
  @mockargs = []
@@ -69,39 +77,235 @@ RSpec.describe 'Moose::Inventory::DB' do
69
77
  end
70
78
 
71
79
  it 'raises a Moose DB exception for unsupported adapters' do
72
- saved_db = @db.instance_variable_get(:@db)
73
- saved_models = @db.instance_variable_get(:@models)
74
- saved_exceptions = @db.instance_variable_get(:@exceptions)
75
- saved_settings = @config._settings.dup
76
-
77
- begin
78
- @db.instance_variable_set(:@db, nil)
79
- @db.instance_variable_set(:@models, nil)
80
- @db.instance_variable_set(:@exceptions, nil)
81
- @config._settings.clear
82
- @config._settings[:config] = { db: { adapter: 'unsupported' } }
83
-
80
+ with_db_config(adapter: 'unsupported') do
84
81
  expect { @db.init }.to raise_error(
85
82
  Moose::Inventory::DB::MooseDBException,
86
83
  /database adapter unsupported is not yet supported/
87
84
  )
88
85
  expect(@db.exceptions[:moose]).to eq(Moose::Inventory::DB::MooseDBException)
89
- ensure
90
- @db.instance_variable_set(:@db, saved_db)
91
- @db.instance_variable_set(:@models, saved_models)
92
- @db.instance_variable_set(:@exceptions, saved_exceptions)
93
- @config._settings.clear
94
- @config._settings.merge!(saved_settings)
95
86
  end
96
87
  end
97
88
  end
98
89
 
90
+ describe 'existing database upgrade behavior' do
91
+ def with_sqlite_fixture(schema_version: nil, tables: [])
92
+ Dir.mktmpdir('moose-schema-fixture') do |dir|
93
+ dbfile = File.join(dir, 'inventory.sqlite3')
94
+ seed_sqlite_fixture(dbfile, schema_version: schema_version, tables: tables)
95
+
96
+ with_db_config(adapter: 'sqlite3', file: dbfile) do
97
+ yield dbfile
98
+ end
99
+ end
100
+ end
101
+
102
+ def seed_sqlite_fixture(dbfile, schema_version:, tables:)
103
+ fixture = Sequel.sqlite(dbfile)
104
+ tables.each { |table| create_fixture_table(fixture, table) }
105
+ unless schema_version.nil?
106
+ create_fixture_table(fixture, :schema_info)
107
+ fixture[:schema_info].insert(version: schema_version)
108
+ end
109
+ ensure
110
+ fixture&.disconnect
111
+ end
112
+
113
+ def create_fixture_table(db, table)
114
+ return if db.table_exists?(table)
115
+
116
+ @db::TABLE_DEFINITIONS.fetch(table).call(db)
117
+ end
118
+
119
+ def fixture_tables_through(version)
120
+ @db::SCHEMA_MIGRATIONS.values_at(*(1..version)).flatten
121
+ end
122
+
123
+ def expect_current_schema_after_init
124
+ @db.init
125
+ expect(@db.schema_version).to eq(@db::SCHEMA_VERSION)
126
+ expect(@db.status[:tables].values).to all(eq(true))
127
+ end
128
+
129
+ it 'declares ordered migrations through the current schema version' do
130
+ expect(@db.migration_versions).to eq((1..@db::SCHEMA_VERSION).to_a)
131
+ end
132
+
133
+ it 'exposes schema definitions from the schema migration module' do
134
+ expect(@db::SCHEMA_VERSION).to eq(@db::SchemaMigrations::SCHEMA_VERSION)
135
+ expect(@db::TABLE_DEFINITIONS).to equal(@db::SchemaMigrations::TABLE_DEFINITIONS)
136
+ expect(@db::SCHEMA_MIGRATIONS).to equal(@db::SchemaMigrations::SCHEMA_MIGRATIONS)
137
+ expect(@db::INDEX_DEFINITIONS).to equal(@db::SchemaMigrations::INDEX_DEFINITIONS)
138
+ end
139
+
140
+ it 'applies schema migrations one version at a time' do
141
+ with_sqlite_fixture(schema_version: 1, tables: %i[hosts groups schema_info]) do
142
+ @db.init_sqlite3
143
+
144
+ expect(@db).to receive(:apply_schema_migration!).ordered.with(1).and_call_original
145
+ expect(@db).to receive(:apply_schema_migration!).ordered.with(2).and_call_original
146
+ expect(@db).to receive(:apply_schema_migration!).ordered.with(3).and_call_original
147
+ expect(@db).to receive(:apply_schema_migration!).ordered.with(4).and_call_original
148
+
149
+ @db.migrate_schema!
150
+
151
+ expect(@db.schema_version).to eq(@db::SCHEMA_VERSION)
152
+ expect(@db.status[:tables][:audit_events]).to eq(true)
153
+ expect(@db.status[:tables][:tags]).to eq(true)
154
+ end
155
+ end
156
+
157
+ it 'creates unique and lookup indexes in schema version 4' do
158
+ expected = {
159
+ hostvars: :idx_hostvars_host_id_name,
160
+ groupvars: :idx_groupvars_group_id_name,
161
+ groups_hosts: :idx_groups_hosts_host_id_group_id,
162
+ groups_groups: :idx_groups_groups_parent_id_child_id,
163
+ hosts_tags: :idx_hosts_tags_host_id_tag_id,
164
+ groups_tags: :idx_groups_tags_group_id_tag_id
165
+ }
166
+
167
+ expected.each do |table, index_name|
168
+ expect(@db.db.indexes(table)).to include(index_name)
169
+ expect(@db.db.indexes(table).fetch(index_name)[:unique]).to eq(true)
170
+ end
171
+ end
172
+
173
+ it 'enforces unique host variable names per host' do
174
+ @db.reset
175
+ host = @db.models[:host].create(name: 'indexed-host')
176
+ @db.db[:hostvars].insert(host_id: host.id, name: 'env', value: 'prod')
177
+
178
+ expect do
179
+ @db.db[:hostvars].insert(host_id: host.id, name: 'env', value: 'prod')
180
+ end.to raise_error(Sequel::UniqueConstraintViolation)
181
+ end
182
+
183
+ it 'enforces unique host-group associations' do
184
+ @db.reset
185
+ host = @db.models[:host].create(name: 'indexed-host')
186
+ group = @db.models[:group].create(name: 'indexed-group')
187
+ @db.db[:groups_hosts].insert(host_id: host.id, group_id: group.id)
188
+
189
+ expect do
190
+ @db.db[:groups_hosts].insert(host_id: host.id, group_id: group.id)
191
+ end.to raise_error(Sequel::UniqueConstraintViolation)
192
+ end
193
+
194
+ it 'removes duplicate rows with identical values before adding unique indexes' do
195
+ with_sqlite_fixture(schema_version: 3, tables: fixture_tables_through(3)) do
196
+ @db.init_sqlite3
197
+ host_id = @db.db[:hosts].insert(name: 'dup-host')
198
+ group_id = @db.db[:groups].insert(name: 'dup-group')
199
+ @db.db[:hostvars].insert(host_id: host_id, name: 'env', value: 'prod')
200
+ @db.db[:hostvars].insert(host_id: host_id, name: 'env', value: 'prod')
201
+ @db.db[:groups_hosts].insert(host_id: host_id, group_id: group_id)
202
+ @db.db[:groups_hosts].insert(host_id: host_id, group_id: group_id)
203
+
204
+ @db.migrate_schema!
205
+
206
+ expect(@db.schema_version).to eq(@db::SCHEMA_VERSION)
207
+ expect(@db.db[:hostvars].where(host_id: host_id, name: 'env').count).to eq(1)
208
+ expect(@db.db[:groups_hosts].where(host_id: host_id, group_id: group_id).count).to eq(1)
209
+ end
210
+ end
211
+
212
+ it 'refuses index migration when duplicate variable rows have conflicting values' do
213
+ with_sqlite_fixture(schema_version: 3, tables: fixture_tables_through(3)) do
214
+ @db.init_sqlite3
215
+ host_id = @db.db[:hosts].insert(name: 'conflict-host')
216
+ @db.db[:hostvars].insert(host_id: host_id, name: 'env', value: 'prod')
217
+ @db.db[:hostvars].insert(host_id: host_id, name: 'env', value: 'dev')
218
+
219
+ expect { @db.migrate_schema! }.to raise_error(
220
+ Moose::Inventory::DB::MooseDBException,
221
+ /conflicting duplicates/
222
+ )
223
+ end
224
+ end
225
+
226
+ it 'upgrades a pre-schema-info sqlite database by creating missing additive tables' do
227
+ with_sqlite_fixture(tables: %i[hosts groups]) do
228
+ expect_current_schema_after_init
229
+ end
230
+ end
231
+
232
+ it 'upgrades a schema version 1 sqlite database to the current schema' do
233
+ with_sqlite_fixture(schema_version: 1, tables: %i[hosts groups]) do
234
+ expect_current_schema_after_init
235
+ end
236
+ end
237
+
238
+ it 'upgrades a schema version 2 sqlite database to the current schema' do
239
+ with_sqlite_fixture(schema_version: 2, tables: %i[hosts groups schema_info]) do
240
+ expect_current_schema_after_init
241
+ end
242
+ end
243
+
244
+ it 'opens a current sqlite database without changing its schema version' do
245
+ with_sqlite_fixture(schema_version: @db::SCHEMA_VERSION, tables: %i[hosts groups schema_info tags]) do
246
+ expect_current_schema_after_init
247
+ end
248
+ end
249
+
250
+ it 'refuses to open a database from a future schema version' do
251
+ with_sqlite_fixture(schema_version: @db::SCHEMA_VERSION + 1, tables: %i[hosts groups schema_info]) do
252
+ expect { @db.init }.to raise_error(
253
+ Moose::Inventory::DB::MooseDBException,
254
+ /newer than supported version #{@db::SCHEMA_VERSION}/
255
+ )
256
+ end
257
+ end
258
+
259
+ it 'refuses to migrate a database from a future schema version' do
260
+ with_sqlite_fixture(schema_version: @db::SCHEMA_VERSION + 1, tables: %i[hosts groups schema_info]) do
261
+ @db.init_sqlite3
262
+
263
+ expect { @db.migrate! }.to raise_error(
264
+ Moose::Inventory::DB::MooseDBException,
265
+ /newer than supported version #{@db::SCHEMA_VERSION}/
266
+ )
267
+ end
268
+ end
269
+
270
+ it 'repairs dirty partial schemas during startup without changing the final schema version' do
271
+ with_sqlite_fixture(schema_version: @db::SCHEMA_VERSION, tables: %i[hosts groups schema_info]) do
272
+ @db.init
273
+
274
+ status = @db.status
275
+
276
+ expect(status[:schema_version]).to eq(@db::SCHEMA_VERSION)
277
+ expect(status[:tables].values).to all(eq(true))
278
+ end
279
+ end
280
+ end
99
281
  describe '.init_exceptions()' do
100
282
  it 'is responsive' do
101
283
  expect(@db.respond_to?(:init_exceptions)).to eq(true)
102
284
  end
103
285
  end
104
286
 
287
+ describe '.reset_runtime_state()' do
288
+ it 'clears cached db, models, and exceptions' do
289
+ saved_db = @db.instance_variable_get(:@db)
290
+ saved_models = @db.instance_variable_get(:@models)
291
+ saved_exceptions = @db.instance_variable_get(:@exceptions)
292
+
293
+ @db.instance_variable_set(:@db, :fake_db)
294
+ @db.instance_variable_set(:@models, { fake: true })
295
+ @db.instance_variable_set(:@exceptions, { fake: true })
296
+
297
+ @db.reset_runtime_state
298
+
299
+ expect(@db.db).to be_nil
300
+ expect(@db.models).to be_nil
301
+ expect(@db.exceptions).to be_nil
302
+
303
+ @db.instance_variable_set(:@db, saved_db)
304
+ @db.instance_variable_set(:@models, saved_models)
305
+ @db.instance_variable_set(:@exceptions, saved_exceptions)
306
+ end
307
+ end
308
+
105
309
  describe '.connect()' do
106
310
  it 'dispatches the documented sqlite3 adapter to the sqlite initializer' do
107
311
  with_db_config(adapter: 'sqlite3') do
@@ -146,18 +350,20 @@ RSpec.describe 'Moose::Inventory::DB' do
146
350
 
147
351
  it 'creates nested parent directories for configured database files' do
148
352
  saved_db = @db.instance_variable_get(:@db)
353
+ saved_models = @db.instance_variable_get(:@models)
354
+ saved_exceptions = @db.instance_variable_get(:@exceptions)
149
355
  saved_settings = @config._settings.dup
150
356
  tmpdir = Dir.mktmpdir('moose-inventory-sqlite')
151
357
  nested_dbfile = File.join(tmpdir, 'one', 'two', 'inventory.db')
152
358
 
153
359
  begin
154
- @db.instance_variable_set(:@db, nil)
360
+ @db.reset_runtime_state
155
361
  @config._settings.clear
156
362
  @config._settings[:config] = {
157
363
  db: {
158
364
  adapter: 'sqlite3',
159
- file: nested_dbfile,
160
- },
365
+ file: nested_dbfile
366
+ }
161
367
  }
162
368
 
163
369
  @db.init_sqlite3
@@ -167,7 +373,10 @@ RSpec.describe 'Moose::Inventory::DB' do
167
373
  ensure
168
374
  current_db = @db.instance_variable_get(:@db)
169
375
  current_db.disconnect if current_db.respond_to?(:disconnect)
376
+ @db.reset_runtime_state
170
377
  @db.instance_variable_set(:@db, saved_db)
378
+ @db.instance_variable_set(:@models, saved_models)
379
+ @db.instance_variable_set(:@exceptions, saved_exceptions)
171
380
  @config._settings.clear
172
381
  @config._settings.merge!(saved_settings)
173
382
  FileUtils.remove_entry(tmpdir) if tmpdir && Dir.exist?(tmpdir)
@@ -207,13 +416,13 @@ RSpec.describe 'Moose::Inventory::DB' do
207
416
  it 'uses a mysql password from the configured environment variable' do
208
417
  saved_db = @db.instance_variable_get(:@db)
209
418
  saved_settings = @config._settings.dup
210
- saved_password = ENV['MOOSE_INVENTORY_MYSQL_PASSWORD']
419
+ saved_password = ENV.fetch('MOOSE_INVENTORY_MYSQL_PASSWORD', nil)
211
420
  mysql_config = {
212
421
  adapter: 'mysql',
213
422
  host: 'localhost',
214
423
  database: 'moose_inventory_test',
215
424
  user: 'moose',
216
- password_env: 'MOOSE_INVENTORY_MYSQL_PASSWORD',
425
+ password_env: 'MOOSE_INVENTORY_MYSQL_PASSWORD'
217
426
  }
218
427
 
219
428
  begin
@@ -251,7 +460,7 @@ RSpec.describe 'Moose::Inventory::DB' do
251
460
  host: 'localhost',
252
461
  database: 'moose_inventory_test',
253
462
  user: 'moose',
254
- password: 'secret',
463
+ password: 'secret'
255
464
  }
256
465
 
257
466
  begin
@@ -292,7 +501,7 @@ RSpec.describe 'Moose::Inventory::DB' do
292
501
  end
293
502
 
294
503
  it 'raises a Moose DB exception when password_env points to an unset variable' do
295
- saved_password = ENV['MOOSE_INVENTORY_POSTGRES_PASSWORD']
504
+ saved_password = ENV.fetch('MOOSE_INVENTORY_POSTGRES_PASSWORD', nil)
296
505
  ENV.delete('MOOSE_INVENTORY_POSTGRES_PASSWORD')
297
506
 
298
507
  begin
@@ -316,13 +525,13 @@ RSpec.describe 'Moose::Inventory::DB' do
316
525
  it 'uses a postgresql password from the configured environment variable' do
317
526
  saved_db = @db.instance_variable_get(:@db)
318
527
  saved_settings = @config._settings.dup
319
- saved_password = ENV['MOOSE_INVENTORY_POSTGRES_PASSWORD']
528
+ saved_password = ENV.fetch('MOOSE_INVENTORY_POSTGRES_PASSWORD', nil)
320
529
  postgresql_config = {
321
530
  adapter: 'postgresql',
322
531
  host: 'localhost',
323
532
  database: 'moose_inventory_test',
324
533
  user: 'moose',
325
- password_env: 'MOOSE_INVENTORY_POSTGRES_PASSWORD',
534
+ password_env: 'MOOSE_INVENTORY_POSTGRES_PASSWORD'
326
535
  }
327
536
 
328
537
  begin
@@ -360,7 +569,7 @@ RSpec.describe 'Moose::Inventory::DB' do
360
569
  host: 'localhost',
361
570
  database: 'moose_inventory_test',
362
571
  user: 'moose',
363
- password: 'secret',
572
+ password: 'secret'
364
573
  }
365
574
 
366
575
  begin
@@ -441,6 +650,70 @@ RSpec.describe 'Moose::Inventory::DB' do
441
650
  end
442
651
  end
443
652
 
653
+ describe '.busy_retry_delay()' do
654
+ it 'uses deterministic capped exponential backoff delays' do
655
+ expect(@db.busy_retry_delay(1)).to eq(0.05)
656
+ expect(@db.busy_retry_delay(2)).to eq(0.1)
657
+ expect(@db.busy_retry_delay(5)).to eq(0.8)
658
+ expect(@db.busy_retry_delay(6)).to eq(1.0)
659
+ expect(@db.busy_retry_delay(10)).to eq(1.0)
660
+ end
661
+ end
662
+
663
+ describe '.busy_database_error?()' do
664
+ it 'identifies Sequel busy database errors by message' do
665
+ expect(@db.busy_database_error?(Sequel::DatabaseError.new('BusyException: locked'))).to eq(true)
666
+ expect(@db.busy_database_error?(Sequel::DatabaseError.new('other database failure'))).to eq(false)
667
+ end
668
+ end
669
+
670
+ describe '.retry_busy_transaction()' do
671
+ it 'sleeps for the deterministic retry delay before another busy retry' do
672
+ delays = []
673
+ error = Sequel::DatabaseError.new('BusyException: database is locked')
674
+
675
+ @db.retry_busy_transaction(error, 3, sleeper: ->(delay) { delays << delay })
676
+
677
+ expect(delays).to eq([0.2])
678
+ end
679
+
680
+ it 'raises the original error when the retry limit is exceeded' do
681
+ error = Sequel::DatabaseError.new('BusyException: database is locked')
682
+
683
+ actual_stderr = capture(:STDERR) do
684
+ expect { @db.retry_busy_transaction(error, 11, sleeper: ->(_delay) {}) }
685
+ .to raise_error(error)
686
+ end
687
+
688
+ expect(actual_stderr).to include('The database appears to be locked by another process')
689
+ end
690
+ end
691
+
692
+ describe '.purge()' do
693
+ it 'uses drop_table for non-sqlite adapters' do
694
+ saved_db = @db.instance_variable_get(:@db)
695
+
696
+ begin
697
+ fake_db = instance_double('DB')
698
+ @db.instance_variable_set(:@db, fake_db)
699
+ allow(@db).to receive(:sqlite_adapter?).and_return(false)
700
+ expect(fake_db).to receive(:drop_table).with(
701
+ :hosts,
702
+ :hostvars,
703
+ :groups,
704
+ :groupvars,
705
+ :group_hosts,
706
+ if_exists: true,
707
+ cascade: true
708
+ )
709
+
710
+ @db.purge
711
+ ensure
712
+ @db.instance_variable_set(:@db, saved_db)
713
+ end
714
+ end
715
+ end
716
+
444
717
  describe '.reset()' do
445
718
  it 'should be responsive' do
446
719
  result = @db.respond_to?(:reset)
@@ -455,7 +728,6 @@ RSpec.describe 'Moose::Inventory::DB' do
455
728
  # Reset the DB
456
729
  @db.reset
457
730
 
458
- #
459
731
  hosts = @db.models[:host].all
460
732
  expect(hosts.count).to eq(0)
461
733
 
@@ -474,6 +746,57 @@ RSpec.describe 'Moose::Inventory::DB' do
474
746
  expect(result).to eq(true)
475
747
  end
476
748
 
749
+ it 'retries busy database transactions before succeeding' do
750
+ saved_db = @db.instance_variable_get(:@db)
751
+ fake_db = Class.new do
752
+ attr_reader :attempts
753
+
754
+ def initialize(error)
755
+ @error = error
756
+ @attempts = 0
757
+ end
758
+
759
+ def transaction(savepoint:)
760
+ raise ArgumentError, 'expected savepoint' unless savepoint
761
+
762
+ @attempts += 1
763
+ raise @error if @attempts == 1
764
+
765
+ yield
766
+ end
767
+ end.new(Sequel::DatabaseError.new('BusyException: database is locked'))
768
+
769
+ begin
770
+ @db.instance_variable_set(:@db, fake_db)
771
+ allow(@db).to receive(:retry_busy_transaction)
772
+
773
+ result = @db.transaction { :ok }
774
+
775
+ expect(result).to eq(:ok)
776
+ expect(fake_db.attempts).to eq(2)
777
+ expect(@db).to have_received(:retry_busy_transaction).once
778
+ ensure
779
+ @db.instance_variable_set(:@db, saved_db)
780
+ end
781
+ end
782
+
783
+ it 're-raises non-busy database errors without retrying' do
784
+ saved_db = @db.instance_variable_get(:@db)
785
+ error = Sequel::DatabaseError.new('constraint failed')
786
+ fake_db = instance_double('DB')
787
+
788
+ begin
789
+ @db.instance_variable_set(:@db, fake_db)
790
+ allow(fake_db).to receive(:transaction).and_raise(error)
791
+ allow(@db).to receive(:retry_busy_transaction)
792
+
793
+ expect { @db.transaction { :ignored } }.to raise_error(error)
794
+ expect(@db).not_to have_received(:retry_busy_transaction)
795
+ ensure
796
+ @db.instance_variable_set(:@db, saved_db)
797
+ end
798
+ end
799
+
477
800
  it 'should perform transactions' do
478
801
  hosts = @db.models[:host].all
479
802
  count = { initial: hosts.count, items: 0 }
@@ -505,7 +828,7 @@ RSpec.describe 'Moose::Inventory::DB' do
505
828
  count[:items] = count[:items] + 1
506
829
  @db.models[:host].create(name: "rollback-#{count[:items]}")
507
830
  end
508
- fail Sequel::Rollback, 'Test error' #
831
+ raise Sequel::Rollback, 'Test error'
509
832
  end
510
833
 
511
834
  hosts = @db.models[:host].all
@@ -524,7 +847,7 @@ RSpec.describe 'Moose::Inventory::DB' do
524
847
  begin
525
848
  actual = runner do
526
849
  @db.transaction do
527
- fail @db.exceptions[:moose], 'Trace regression target'
850
+ raise @db.exceptions[:moose], 'Trace regression target'
528
851
  end
529
852
  end
530
853
  ensure
@@ -546,7 +869,7 @@ RSpec.describe 'Moose::Inventory::DB' do
546
869
  begin
547
870
  actual = runner do
548
871
  @db.transaction do
549
- fail @db.exceptions[:moose], 'Trace regression target'
872
+ raise @db.exceptions[:moose], 'Trace regression target'
550
873
  end
551
874
  end
552
875
  ensure
@@ -564,5 +887,42 @@ RSpec.describe 'Moose::Inventory::DB' do
564
887
  expect(actual[:STDERR]).to include("ERROR: Trace regression target\n")
565
888
  expect(actual[:STDERR]).not_to include('NoMethodError')
566
889
  end
890
+
891
+ it 'warns and re-raises ordinary StandardError failures' do
892
+ actual = runner do
893
+ @db.transaction do
894
+ raise 'generic failure'
895
+ end
896
+ end
897
+
898
+ expect(actual[:aborted]).to eq(false)
899
+ expect(actual[:unexpected].class).to eq(RuntimeError)
900
+ expect(actual[:unexpected].message).to eq('generic failure')
901
+ expect(actual[:STDERR]).to eq(
902
+ "An error occurred during a transaction, any changes have been rolled back.\n"
903
+ )
904
+ end
905
+
906
+ it 'warns and re-raises SystemExit failures triggered inside the transaction' do
907
+ actual = runner do
908
+ @db.transaction do
909
+ raise SystemExit.new(7), 'stop now'
910
+ end
911
+ end
912
+
913
+ expect(actual[:unexpected]).to eq(false)
914
+ expect(actual[:aborted]).to eq(true)
915
+ expect(actual[:STDERR]).to eq(
916
+ "An error occurred during a transaction, any changes have been rolled back.\n"
917
+ )
918
+ end
919
+
920
+ it 'does not swallow non-rescued fatal exceptions such as interrupts' do
921
+ expect do
922
+ @db.transaction do
923
+ raise Interrupt, 'ctrl-c'
924
+ end
925
+ end.to raise_error(Interrupt, 'ctrl-c')
926
+ end
567
927
  end
568
928
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Moose::Inventory::DB::MooseDBException do
6
+ it 'uses the provided exception message through RuntimeError' do
7
+ error = described_class.new('boom')
8
+
9
+ expect(error.message).to eq('boom')
10
+ expect(error.full_message).to include('boom')
11
+ end
12
+
13
+ it 'falls back to a default exception message' do
14
+ error = described_class.new
15
+
16
+ expect(error.message).to eq('An undefined Moose exception occurred')
17
+ end
18
+ end
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'spec_helper'
2
4
 
5
+ # rubocop:disable Metrics/BlockLength
3
6
  RSpec.describe 'models' do
4
7
  #=============================
5
8
  # Initialization
@@ -8,9 +11,9 @@ RSpec.describe 'models' do
8
11
  before(:all) do
9
12
  # Set up the configuration object
10
13
  @mockarg_parts = {
11
- config: File.join(spec_root, 'config/config.yml'),
12
- format: 'yaml',
13
- env: 'test',
14
+ config: File.join(spec_root, 'config/config.yml'),
15
+ format: 'yaml',
16
+ env: 'test'
14
17
  }
15
18
 
16
19
  @mockargs = []
@@ -173,3 +176,4 @@ RSpec.describe 'models' do
173
176
  end
174
177
  end
175
178
  end
179
+ # rubocop:enable Metrics/BlockLength