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
@@ -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)
@@ -190,6 +399,59 @@ RSpec.describe 'Moose::Inventory::DB' do
190
399
  end
191
400
  end
192
401
 
402
+ it 'raises a Moose DB exception when password and password_env are missing' do
403
+ with_db_config(
404
+ adapter: 'mysql',
405
+ host: 'localhost',
406
+ database: 'moose_inventory_test',
407
+ user: 'moose'
408
+ ) do
409
+ expect { @db.init_mysql }.to raise_error(
410
+ Moose::Inventory::DB::MooseDBException,
411
+ /Expected key password or password_env missing in mysql configuration/
412
+ )
413
+ end
414
+ end
415
+
416
+ it 'uses a mysql password from the configured environment variable' do
417
+ saved_db = @db.instance_variable_get(:@db)
418
+ saved_settings = @config._settings.dup
419
+ saved_password = ENV.fetch('MOOSE_INVENTORY_MYSQL_PASSWORD', nil)
420
+ mysql_config = {
421
+ adapter: 'mysql',
422
+ host: 'localhost',
423
+ database: 'moose_inventory_test',
424
+ user: 'moose',
425
+ password_env: 'MOOSE_INVENTORY_MYSQL_PASSWORD'
426
+ }
427
+
428
+ begin
429
+ ENV['MOOSE_INVENTORY_MYSQL_PASSWORD'] = 'env-secret'
430
+ @db.instance_variable_set(:@db, nil)
431
+ @config._settings.clear
432
+ @config._settings[:config] = { db: mysql_config }
433
+
434
+ expect(Sequel).to receive(:mysql2).with(
435
+ user: 'moose',
436
+ password: 'env-secret',
437
+ host: 'localhost',
438
+ database: 'moose_inventory_test'
439
+ ).and_return(:mysql2_connection)
440
+
441
+ @db.init_mysql
442
+ expect(@db.db).to eq(:mysql2_connection)
443
+ ensure
444
+ if saved_password.nil?
445
+ ENV.delete('MOOSE_INVENTORY_MYSQL_PASSWORD')
446
+ else
447
+ ENV['MOOSE_INVENTORY_MYSQL_PASSWORD'] = saved_password
448
+ end
449
+ @db.instance_variable_set(:@db, saved_db)
450
+ @config._settings.clear
451
+ @config._settings.merge!(saved_settings)
452
+ end
453
+ end
454
+
193
455
  it 'uses the mysql2 Sequel adapter with configured connection settings' do
194
456
  saved_db = @db.instance_variable_get(:@db)
195
457
  saved_settings = @config._settings.dup
@@ -198,7 +460,7 @@ RSpec.describe 'Moose::Inventory::DB' do
198
460
  host: 'localhost',
199
461
  database: 'moose_inventory_test',
200
462
  user: 'moose',
201
- password: 'secret',
463
+ password: 'secret'
202
464
  }
203
465
 
204
466
  begin
@@ -238,6 +500,67 @@ RSpec.describe 'Moose::Inventory::DB' do
238
500
  end
239
501
  end
240
502
 
503
+ it 'raises a Moose DB exception when password_env points to an unset variable' do
504
+ saved_password = ENV.fetch('MOOSE_INVENTORY_POSTGRES_PASSWORD', nil)
505
+ ENV.delete('MOOSE_INVENTORY_POSTGRES_PASSWORD')
506
+
507
+ begin
508
+ with_db_config(
509
+ adapter: 'postgresql',
510
+ host: 'localhost',
511
+ database: 'moose_inventory_test',
512
+ user: 'moose',
513
+ password_env: 'MOOSE_INVENTORY_POSTGRES_PASSWORD'
514
+ ) do
515
+ expect { @db.init_postgresql }.to raise_error(
516
+ Moose::Inventory::DB::MooseDBException,
517
+ /Environment variable MOOSE_INVENTORY_POSTGRES_PASSWORD is not set for postgresql password/
518
+ )
519
+ end
520
+ ensure
521
+ ENV['MOOSE_INVENTORY_POSTGRES_PASSWORD'] = saved_password unless saved_password.nil?
522
+ end
523
+ end
524
+
525
+ it 'uses a postgresql password from the configured environment variable' do
526
+ saved_db = @db.instance_variable_get(:@db)
527
+ saved_settings = @config._settings.dup
528
+ saved_password = ENV.fetch('MOOSE_INVENTORY_POSTGRES_PASSWORD', nil)
529
+ postgresql_config = {
530
+ adapter: 'postgresql',
531
+ host: 'localhost',
532
+ database: 'moose_inventory_test',
533
+ user: 'moose',
534
+ password_env: 'MOOSE_INVENTORY_POSTGRES_PASSWORD'
535
+ }
536
+
537
+ begin
538
+ ENV['MOOSE_INVENTORY_POSTGRES_PASSWORD'] = 'env-secret'
539
+ @db.instance_variable_set(:@db, nil)
540
+ @config._settings.clear
541
+ @config._settings[:config] = { db: postgresql_config }
542
+
543
+ expect(Sequel).to receive(:postgres).with(
544
+ user: 'moose',
545
+ password: 'env-secret',
546
+ host: 'localhost',
547
+ database: 'moose_inventory_test'
548
+ ).and_return(:postgresql_connection)
549
+
550
+ @db.init_postgresql
551
+ expect(@db.db).to eq(:postgresql_connection)
552
+ ensure
553
+ if saved_password.nil?
554
+ ENV.delete('MOOSE_INVENTORY_POSTGRES_PASSWORD')
555
+ else
556
+ ENV['MOOSE_INVENTORY_POSTGRES_PASSWORD'] = saved_password
557
+ end
558
+ @db.instance_variable_set(:@db, saved_db)
559
+ @config._settings.clear
560
+ @config._settings.merge!(saved_settings)
561
+ end
562
+ end
563
+
241
564
  it 'uses the postgres Sequel adapter with configured connection settings' do
242
565
  saved_db = @db.instance_variable_get(:@db)
243
566
  saved_settings = @config._settings.dup
@@ -246,7 +569,7 @@ RSpec.describe 'Moose::Inventory::DB' do
246
569
  host: 'localhost',
247
570
  database: 'moose_inventory_test',
248
571
  user: 'moose',
249
- password: 'secret',
572
+ password: 'secret'
250
573
  }
251
574
 
252
575
  begin
@@ -327,6 +650,70 @@ RSpec.describe 'Moose::Inventory::DB' do
327
650
  end
328
651
  end
329
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
+
330
717
  describe '.reset()' do
331
718
  it 'should be responsive' do
332
719
  result = @db.respond_to?(:reset)
@@ -341,7 +728,6 @@ RSpec.describe 'Moose::Inventory::DB' do
341
728
  # Reset the DB
342
729
  @db.reset
343
730
 
344
- #
345
731
  hosts = @db.models[:host].all
346
732
  expect(hosts.count).to eq(0)
347
733
 
@@ -360,6 +746,57 @@ RSpec.describe 'Moose::Inventory::DB' do
360
746
  expect(result).to eq(true)
361
747
  end
362
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
+
363
800
  it 'should perform transactions' do
364
801
  hosts = @db.models[:host].all
365
802
  count = { initial: hosts.count, items: 0 }
@@ -391,7 +828,7 @@ RSpec.describe 'Moose::Inventory::DB' do
391
828
  count[:items] = count[:items] + 1
392
829
  @db.models[:host].create(name: "rollback-#{count[:items]}")
393
830
  end
394
- fail Sequel::Rollback, 'Test error' #
831
+ raise Sequel::Rollback, 'Test error'
395
832
  end
396
833
 
397
834
  hosts = @db.models[:host].all
@@ -402,5 +839,90 @@ RSpec.describe 'Moose::Inventory::DB' do
402
839
 
403
840
  expect(count[:final]).to eq(count[:initial])
404
841
  end
842
+
843
+ it 'prints concise Moose DB transaction errors by default' do
844
+ saved_trace = @config._confopts[:trace]
845
+ @config._confopts[:trace] = false
846
+
847
+ begin
848
+ actual = runner do
849
+ @db.transaction do
850
+ raise @db.exceptions[:moose], 'Trace regression target'
851
+ end
852
+ end
853
+ ensure
854
+ @config._confopts[:trace] = saved_trace
855
+ end
856
+
857
+ expect(actual[:unexpected]).to eq(false)
858
+ expect(actual[:aborted]).to eq(true)
859
+ expect(actual[:STDERR]).to eq(
860
+ "An error occurred during a transaction, any changes have been rolled back.\n" \
861
+ "ERROR: Trace regression target\n"
862
+ )
863
+ end
864
+
865
+ it 'prints the Moose DB exception backtrace when trace is enabled' do
866
+ saved_trace = @config._confopts[:trace]
867
+ @config._confopts[:trace] = true
868
+
869
+ begin
870
+ actual = runner do
871
+ @db.transaction do
872
+ raise @db.exceptions[:moose], 'Trace regression target'
873
+ end
874
+ end
875
+ ensure
876
+ @config._confopts[:trace] = saved_trace
877
+ end
878
+
879
+ expect(actual[:unexpected]).to eq(false)
880
+ expect(actual[:aborted]).to eq(true)
881
+ expect(actual[:STDERR]).to include(
882
+ "An error occurred during a transaction, any changes have been rolled back.\n"
883
+ )
884
+ expect(actual[:STDERR]).to include('Moose::Inventory::DB::MooseDBException')
885
+ expect(actual[:STDERR]).to include('Trace regression target')
886
+ expect(actual[:STDERR]).to include('spec/lib/moose_inventory/db/db_spec.rb')
887
+ expect(actual[:STDERR]).to include("ERROR: Trace regression target\n")
888
+ expect(actual[:STDERR]).not_to include('NoMethodError')
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
405
927
  end
406
928
  end