moose-inventory 2.0 → 2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +2 -0
  3. data/.gitignore +2 -1
  4. data/.rubocop.yml +21 -0
  5. data/BACKLOG.md +630 -8
  6. data/Gemfile +2 -0
  7. data/Gemfile.lock +1 -1
  8. data/README.md +315 -39
  9. data/Rakefile +2 -0
  10. data/bin/moose-inventory +2 -1
  11. data/docs/architecture/architecture-and-trust-boundaries.md +444 -0
  12. data/docs/compatibility/cli-output-compatibility.md +76 -0
  13. data/docs/governance/approval-register.md +37 -0
  14. data/docs/maintenance/database-backup-restore-guidance.md +162 -0
  15. data/docs/maintenance/package-maintenance-and-agent-boundaries.md +260 -0
  16. data/docs/process/conformance-gap-analysis-2026-05-28.md +192 -0
  17. data/docs/product/product-brief.md +161 -0
  18. data/docs/product/requirements-baseline.md +477 -0
  19. data/docs/qa/qa-documentation-and-release-gates.md +283 -0
  20. data/docs/release/package-provenance-hardening.md +126 -0
  21. data/docs/release/publishing.md +11 -3
  22. data/docs/release/release-environment-protection.md +70 -0
  23. data/docs/release/release-readiness.md +23 -4
  24. data/docs/security/accepted-risk-register.md +84 -0
  25. data/docs/security/security-privacy-process.md +287 -0
  26. data/docs/security-audit-2026-05-26-rerun.md +2 -2
  27. data/docs/ux/cli-workflow-notes.md +287 -0
  28. data/examples/ansible/ansible.cfg +3 -0
  29. data/examples/ansible/inventory/moose_inventory.yml +5 -0
  30. data/examples/ansible/inventory_plugins/moose_inventory.py +100 -0
  31. data/examples/ci/README.md +16 -0
  32. data/examples/ci/github-actions/inventory-review.yml +38 -0
  33. data/examples/ci/inventory/example-snapshot.yml +19 -0
  34. data/examples/ci/scripts/validate-inventory-snapshot.sh +30 -0
  35. data/lib/moose_inventory/cli/application.rb +133 -5
  36. data/lib/moose_inventory/cli/association_rendering.rb +74 -0
  37. data/lib/moose_inventory/cli/association_rendering_support.rb +89 -0
  38. data/lib/moose_inventory/cli/audit.rb +62 -0
  39. data/lib/moose_inventory/cli/audit_recording.rb +40 -0
  40. data/lib/moose_inventory/cli/child_relation_rendering.rb +110 -0
  41. data/lib/moose_inventory/cli/console.rb +135 -0
  42. data/lib/moose_inventory/cli/db.rb +64 -0
  43. data/lib/moose_inventory/cli/factory.rb +28 -0
  44. data/lib/moose_inventory/cli/formatter.rb +8 -12
  45. data/lib/moose_inventory/cli/group.rb +5 -2
  46. data/lib/moose_inventory/cli/group_add.rb +11 -9
  47. data/lib/moose_inventory/cli/group_addchild.rb +23 -65
  48. data/lib/moose_inventory/cli/group_addhost.rb +16 -67
  49. data/lib/moose_inventory/cli/group_addvar.rb +27 -47
  50. data/lib/moose_inventory/cli/group_get.rb +8 -42
  51. data/lib/moose_inventory/cli/group_list.rb +7 -40
  52. data/lib/moose_inventory/cli/group_listvars.rb +9 -55
  53. data/lib/moose_inventory/cli/group_rm.rb +12 -10
  54. data/lib/moose_inventory/cli/group_rmchild.rb +26 -82
  55. data/lib/moose_inventory/cli/group_rmhost.rb +18 -53
  56. data/lib/moose_inventory/cli/group_rmvar.rb +30 -41
  57. data/lib/moose_inventory/cli/group_tags.rb +33 -0
  58. data/lib/moose_inventory/cli/helpers.rb +68 -1
  59. data/lib/moose_inventory/cli/host.rb +6 -3
  60. data/lib/moose_inventory/cli/host_add.rb +69 -29
  61. data/lib/moose_inventory/cli/host_addgroup.rb +22 -58
  62. data/lib/moose_inventory/cli/host_addvar.rb +28 -52
  63. data/lib/moose_inventory/cli/host_get.rb +9 -37
  64. data/lib/moose_inventory/cli/host_list.rb +24 -21
  65. data/lib/moose_inventory/cli/host_listvars.rb +9 -62
  66. data/lib/moose_inventory/cli/host_rm.rb +60 -42
  67. data/lib/moose_inventory/cli/host_rmgroup.rb +25 -44
  68. data/lib/moose_inventory/cli/host_rmvar.rb +31 -45
  69. data/lib/moose_inventory/cli/host_tags.rb +33 -0
  70. data/lib/moose_inventory/cli/listvars_support.rb +55 -0
  71. data/lib/moose_inventory/cli/plan_rendering.rb +50 -0
  72. data/lib/moose_inventory/cli/relation_transaction_support.rb +51 -0
  73. data/lib/moose_inventory/cli/tag_support.rb +97 -0
  74. data/lib/moose_inventory/cli/variable_rendering.rb +67 -0
  75. data/lib/moose_inventory/config/config.rb +185 -108
  76. data/lib/moose_inventory/db/db.rb +170 -195
  77. data/lib/moose_inventory/db/exceptions.rb +6 -3
  78. data/lib/moose_inventory/db/models.rb +16 -0
  79. data/lib/moose_inventory/db/schema_migrations.rb +248 -0
  80. data/lib/moose_inventory/inventory_context.rb +68 -2
  81. data/lib/moose_inventory/operations/add_associations.rb +20 -16
  82. data/lib/moose_inventory/operations/add_groups.rb +21 -13
  83. data/lib/moose_inventory/operations/add_hosts.rb +30 -17
  84. data/lib/moose_inventory/operations/add_variables.rb +77 -0
  85. data/lib/moose_inventory/operations/entity_variable_operation_support.rb +46 -0
  86. data/lib/moose_inventory/operations/group_child_relations.rb +23 -16
  87. data/lib/moose_inventory/operations/group_cleanup.rb +23 -8
  88. data/lib/moose_inventory/operations/import_inventory_snapshot.rb +41 -0
  89. data/lib/moose_inventory/operations/inventory_doctor.rb +172 -0
  90. data/lib/moose_inventory/operations/inventory_snapshot.rb +60 -0
  91. data/lib/moose_inventory/operations/inventory_snapshot_applier.rb +112 -0
  92. data/lib/moose_inventory/operations/inventory_snapshot_preview.rb +174 -0
  93. data/lib/moose_inventory/operations/inventory_snapshot_validator.rb +134 -0
  94. data/lib/moose_inventory/operations/operation_event_support.rb +27 -0
  95. data/lib/moose_inventory/operations/query_inventory/base_query.rb +24 -0
  96. data/lib/moose_inventory/operations/query_inventory/group_queries.rb +86 -0
  97. data/lib/moose_inventory/operations/query_inventory/host_queries.rb +106 -0
  98. data/lib/moose_inventory/operations/query_inventory.rb +47 -0
  99. data/lib/moose_inventory/operations/remove_associations.rb +30 -18
  100. data/lib/moose_inventory/operations/remove_groups.rb +12 -12
  101. data/lib/moose_inventory/operations/remove_hosts.rb +68 -0
  102. data/lib/moose_inventory/operations/remove_variables.rb +67 -0
  103. data/lib/moose_inventory/runtime_options.rb +31 -0
  104. data/lib/moose_inventory/version.rb +3 -1
  105. data/lib/moose_inventory.rb +10 -7
  106. data/moose-inventory.gemspec +19 -35
  107. data/scripts/check.sh +1 -0
  108. data/scripts/ci/check_generated_artifacts.sh +41 -0
  109. data/scripts/ci/check_permissions.sh +2 -0
  110. data/scripts/ci/check_rubocop.sh +30 -25
  111. data/scripts/files.rb +5 -4
  112. data/spec/examples/ci_examples_spec.rb +37 -0
  113. data/spec/lib/moose_inventory/ansible_plugin_examples_spec.rb +29 -0
  114. data/spec/lib/moose_inventory/cli/application_doctor_spec.rb +50 -0
  115. data/spec/lib/moose_inventory/cli/application_import_export_spec.rb +100 -0
  116. data/spec/lib/moose_inventory/cli/application_spec.rb +25 -15
  117. data/spec/lib/moose_inventory/cli/audit_spec.rb +56 -0
  118. data/spec/lib/moose_inventory/cli/cli_spec.rb +15 -19
  119. data/spec/lib/moose_inventory/cli/console_spec.rb +98 -0
  120. data/spec/lib/moose_inventory/cli/factory_spec.rb +27 -0
  121. data/spec/lib/moose_inventory/cli/formatter_spec.rb +95 -3
  122. data/spec/lib/moose_inventory/cli/group_add_spec.rb +140 -116
  123. data/spec/lib/moose_inventory/cli/group_addchild_spec.rb +89 -35
  124. data/spec/lib/moose_inventory/cli/group_addhost_spec.rb +81 -84
  125. data/spec/lib/moose_inventory/cli/group_addvar_spec.rb +65 -68
  126. data/spec/lib/moose_inventory/cli/group_get_spec.rb +17 -33
  127. data/spec/lib/moose_inventory/cli/group_list_spec.rb +16 -38
  128. data/spec/lib/moose_inventory/cli/group_listvar_spec.rb +33 -40
  129. data/spec/lib/moose_inventory/cli/group_rm_spec.rb +136 -96
  130. data/spec/lib/moose_inventory/cli/group_rmchild_spec.rb +66 -41
  131. data/spec/lib/moose_inventory/cli/group_rmhost_spec.rb +76 -78
  132. data/spec/lib/moose_inventory/cli/group_rmvar_spec.rb +57 -63
  133. data/spec/lib/moose_inventory/cli/group_spec.rb +2 -0
  134. data/spec/lib/moose_inventory/cli/helpers_spec.rb +146 -0
  135. data/spec/lib/moose_inventory/cli/host_add_spec.rb +170 -116
  136. data/spec/lib/moose_inventory/cli/host_addgroup_spec.rb +100 -83
  137. data/spec/lib/moose_inventory/cli/host_addvar_spec.rb +92 -74
  138. data/spec/lib/moose_inventory/cli/host_get_spec.rb +14 -33
  139. data/spec/lib/moose_inventory/cli/host_list_spec.rb +41 -33
  140. data/spec/lib/moose_inventory/cli/host_listvar_spec.rb +45 -53
  141. data/spec/lib/moose_inventory/cli/host_rm_spec.rb +66 -48
  142. data/spec/lib/moose_inventory/cli/host_rmgroup_spec.rb +73 -83
  143. data/spec/lib/moose_inventory/cli/host_rmvar_spec.rb +56 -63
  144. data/spec/lib/moose_inventory/cli/host_spec.rb +2 -0
  145. data/spec/lib/moose_inventory/cli/tags_spec.rb +81 -0
  146. data/spec/lib/moose_inventory/config/config_spec.rb +41 -3
  147. data/spec/lib/moose_inventory/db/db_spec.rb +396 -36
  148. data/spec/lib/moose_inventory/db/exceptions_spec.rb +18 -0
  149. data/spec/lib/moose_inventory/db/models_spec.rb +7 -3
  150. data/spec/lib/moose_inventory/db_lifecycle_spec.rb +73 -0
  151. data/spec/lib/moose_inventory/inventory_context_spec.rb +10 -0
  152. data/spec/lib/moose_inventory/operations/add_associations_spec.rb +34 -0
  153. data/spec/lib/moose_inventory/operations/add_groups_spec.rb +15 -0
  154. data/spec/lib/moose_inventory/operations/add_hosts_spec.rb +13 -0
  155. data/spec/lib/moose_inventory/operations/add_variables_spec.rb +103 -0
  156. data/spec/lib/moose_inventory/operations/group_child_relations_spec.rb +46 -0
  157. data/spec/lib/moose_inventory/operations/import_inventory_snapshot_spec.rb +226 -0
  158. data/spec/lib/moose_inventory/operations/inventory_doctor_spec.rb +77 -0
  159. data/spec/lib/moose_inventory/operations/inventory_snapshot_spec.rb +50 -0
  160. data/spec/lib/moose_inventory/operations/operation_event_support_spec.rb +78 -0
  161. data/spec/lib/moose_inventory/operations/query_inventory_spec.rb +146 -0
  162. data/spec/lib/moose_inventory/operations/remove_associations_spec.rb +35 -0
  163. data/spec/lib/moose_inventory/operations/remove_groups_spec.rb +21 -0
  164. data/spec/lib/moose_inventory/operations/remove_hosts_spec.rb +55 -0
  165. data/spec/lib/moose_inventory/operations/remove_variables_spec.rb +83 -0
  166. data/spec/shared/shared_config_setup.rb +4 -3
  167. data/spec/spec_helper.rb +50 -40
  168. data/spec/support/cli_harness.rb +33 -0
  169. metadata +80 -41
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ require 'spec_helper'
5
+ require 'tmpdir'
6
+
7
+ RSpec.describe 'database lifecycle commands' do
8
+ before(:all) do
9
+ setup_cli_harness(command_class: Moose::Inventory::Cli::Application)
10
+ end
11
+
12
+ before(:each) do
13
+ reset_cli_harness
14
+ end
15
+
16
+ it 'records the current schema version during database initialization' do
17
+ expect(Moose::Inventory::DB.schema_version).to eq(Moose::Inventory::DB::SCHEMA_VERSION)
18
+ expect(Moose::Inventory::DB.status[:tables][:schema_info]).to eq(true)
19
+ end
20
+
21
+ it 'prints database status' do
22
+ actual = runner { @app.start(%w[db status]) }
23
+
24
+ expect(actual[:unexpected]).to eq(false)
25
+ expect(actual[:aborted]).to eq(false)
26
+ expect(actual[:STDOUT]).to include('Adapter: sqlite3')
27
+ expect(actual[:STDOUT]).to include("Expected schema version: #{Moose::Inventory::DB::SCHEMA_VERSION}")
28
+ expect(actual[:STDOUT]).to include('- schema_info: present')
29
+ end
30
+
31
+ it 'runs db doctor successfully when schema state is current' do
32
+ actual = runner { @app.start(%w[db doctor]) }
33
+
34
+ expected(actual, aborted: false, STDOUT: "Database doctor found no issues.\n", STDERR: '')
35
+ end
36
+
37
+ it 'reports dirty partial schema state through db doctor' do
38
+ Moose::Inventory::DB.db.drop_table(:audit_events)
39
+
40
+ actual = runner { @app.start(%w[db doctor]) }
41
+
42
+ expect(actual[:unexpected]).to eq(false)
43
+ expect(actual[:aborted]).to eq(true)
44
+ expect(actual[:STDOUT]).to include('Database doctor found issue(s):')
45
+ expect(actual[:STDOUT]).to include('- Missing tables: audit_events')
46
+ end
47
+
48
+ it 'migrates missing lifecycle metadata' do
49
+ Moose::Inventory::DB.db.drop_table(:schema_info)
50
+
51
+ actual = runner { @app.start(%w[db migrate]) }
52
+
53
+ expect(actual[:unexpected]).to eq(false)
54
+ expect(actual[:aborted]).to eq(false)
55
+ expect(actual[:STDOUT]).to include("Database schema is at version #{Moose::Inventory::DB::SCHEMA_VERSION}.")
56
+ expect(Moose::Inventory::DB.schema_version).to eq(Moose::Inventory::DB::SCHEMA_VERSION)
57
+ end
58
+
59
+ it 'backs up the sqlite database file' do
60
+ Dir.mktmpdir do |dir|
61
+ destination = File.join(dir, 'backup.sqlite3')
62
+
63
+ actual = runner { @app.start(['db', 'backup', destination]) }
64
+
65
+ expect(actual[:unexpected]).to eq(false)
66
+ expect(actual[:aborted]).to eq(false)
67
+ expect(actual[:STDOUT]).to include("Backed up database to #{destination}.")
68
+ expect(File).to exist(destination)
69
+ expect(File.size(destination)).to be_positive
70
+ end
71
+ end
72
+ end
73
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'inventory_context'
5
+
6
+ RSpec.describe Moose::Inventory::InventoryContext do
7
+ it 'requires an explicit db dependency' do
8
+ expect { described_class.new }.to raise_error(ArgumentError)
9
+ end
10
+ end
@@ -50,6 +50,40 @@ RSpec.describe Moose::Inventory::Operations::AddAssociations do
50
50
  expect(host.groups_dataset[name: 'ungrouped']).to be_nil
51
51
  end
52
52
 
53
+ it 'dry-runs adding groups to a host without creating groups or changing automatic membership' do
54
+ host = @db.models[:host].create(name: 'host1')
55
+ ungrouped = @db.models[:group].find_or_create(name: 'ungrouped')
56
+ host.add_group(ungrouped)
57
+
58
+ result = operation.host_to_groups(
59
+ host: host,
60
+ host_name: 'host1',
61
+ group_names: ['created'],
62
+ dry_run: true
63
+ )
64
+
65
+ expect(result.warning_count).to eq(1)
66
+ expect(result.events.map(&:type)).to include(:group_missing_created, :removing_automatic_group, :dry_run_summary)
67
+ expect(@db.models[:group].find(name: 'created')).to be_nil
68
+ expect(host.groups_dataset[name: 'created']).to be_nil
69
+ expect(host.groups_dataset[name: 'ungrouped']).not_to be_nil
70
+ end
71
+
72
+ it 'dry-runs adding hosts to a group without creating hosts or changing automatic membership' do
73
+ group = @db.models[:group].create(name: 'group1')
74
+
75
+ result = operation.group_to_hosts(
76
+ group: group,
77
+ group_name: 'group1',
78
+ host_names: ['host1'],
79
+ dry_run: true
80
+ )
81
+
82
+ expect(result.warning_count).to eq(1)
83
+ expect(result.events.map(&:type)).to include(:host_missing_created, :dry_run_summary)
84
+ expect(@db.models[:host].find(name: 'host1')).to be_nil
85
+ expect(group.hosts_dataset[name: 'host1']).to be_nil
86
+ end
53
87
  it 'adds hosts to an existing group and reports creation/duplicate events' do
54
88
  group = @db.models[:group].create(name: 'group1')
55
89
  duplicate_host = @db.models[:host].create(name: 'host1')
@@ -40,6 +40,21 @@ RSpec.describe Moose::Inventory::Operations::AddGroups do
40
40
  expect(group).not_to be_nil
41
41
  end
42
42
 
43
+ it 'returns dry-run events without creating groups, hosts, or associations' do
44
+ result = operation.call(
45
+ names: ['testgroup'],
46
+ hosts: ['missinghost'],
47
+ dry_run: true
48
+ )
49
+
50
+ expect(result.warning_count).to eq(1)
51
+ expect(result.events.map(&:type)).to eq(
52
+ %i[group_started creating_group ok adding_association host_missing_created host_creating_now ok ok group_complete
53
+ dry_run_summary]
54
+ )
55
+ expect(@db.models[:group].find(name: 'testgroup')).to be_nil
56
+ expect(@db.models[:host].find(name: 'missinghost')).to be_nil
57
+ end
43
58
  it 'reports existing groups, created hosts, duplicate associations, and ungrouped removal as events' do
44
59
  host = @db.models[:host].create(name: 'testhost')
45
60
  ungrouped = @db.models[:group].find_or_create(name: 'ungrouped')
@@ -43,6 +43,19 @@ RSpec.describe Moose::Inventory::Operations::AddHosts do
43
43
  expect(host.groups_dataset[name: 'ungrouped']).not_to be_nil
44
44
  end
45
45
 
46
+ it 'returns dry-run events without creating hosts, groups, or associations' do
47
+ @result = operation.call(
48
+ names: ['testhost'],
49
+ groups: ['missinggroup'],
50
+ dry_run: true
51
+ )
52
+
53
+ expect(@result.events.map(&:type)).to eq(
54
+ %i[host_started creating_host ok adding_association group_missing_created ok host_complete dry_run_summary]
55
+ )
56
+ expect(@db.models[:host].find(name: 'testhost')).to be_nil
57
+ expect(@db.models[:group].find(name: 'missinggroup')).to be_nil
58
+ end
46
59
  it 'reports existing hosts, missing groups, and duplicate associations as events' do
47
60
  host = @db.models[:host].create(name: 'testhost')
48
61
  group = @db.models[:group].create(name: 'existinggroup')
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'inventory_context'
5
+ require 'operations/add_variables'
6
+
7
+ RSpec.describe Moose::Inventory::Operations::AddVariables 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 build_operation(entity_type, emitter: nil)
25
+ described_class.new(
26
+ context: Moose::Inventory::InventoryContext.new(db: @db),
27
+ entity_type: entity_type,
28
+ emitter: emitter
29
+ )
30
+ end
31
+
32
+ it 'adds host variables and returns structured events without rendering output' do
33
+ @db.models[:host].create(name: 'test1')
34
+
35
+ actual = runner do
36
+ @result = build_operation(:host).call(name: 'test1', vars: ['var1=val1'])
37
+ end
38
+
39
+ expected(actual, STDOUT: '', STDERR: '')
40
+ expect(@result.events.map(&:type)).to eq(
41
+ %i[entity_started retrieving_entity ok adding_variable ok entity_complete]
42
+ )
43
+
44
+ host = @db.models[:host].find(name: 'test1')
45
+ expect(host.hostvars_dataset[name: 'var1'][:value]).to eq('val1')
46
+ end
47
+
48
+ it 'updates an existing group variable and emits an update event' do
49
+ group = @db.models[:group].create(name: 'testgroup')
50
+ var = @db.models[:groupvar].create(name: 'var1', value: 'old')
51
+ group.add_groupvar(var)
52
+
53
+ result = build_operation(:group).call(name: 'testgroup', vars: ['var1=new'])
54
+
55
+ expect(result.events.map(&:type)).to include(:updating_existing_variable)
56
+ expect(group.groupvars_dataset[name: 'var1'][:value]).to eq('new')
57
+ expect(@db.models[:groupvar].count).to eq(1)
58
+ end
59
+
60
+ it 'dry-runs adding and updating variables without writing records' do
61
+ host = @db.models[:host].create(name: 'test1')
62
+ existing = @db.models[:hostvar].create(name: 'var1', value: 'old')
63
+ host.add_hostvar(existing)
64
+
65
+ result = build_operation(:host).call(
66
+ name: 'test1',
67
+ vars: %w[var1=new var2=val2],
68
+ dry_run: true
69
+ )
70
+
71
+ expect(result.events.map(&:type)).to include(:updating_existing_variable, :dry_run_summary)
72
+ expect(host.hostvars_dataset[name: 'var1'][:value]).to eq('old')
73
+ expect(host.hostvars_dataset[name: 'var2']).to be_nil
74
+ end
75
+
76
+ it 'uses the shared variable operation support for missing entity errors' do
77
+ operation = build_operation(:host)
78
+
79
+ expect do
80
+ operation.call(name: 'ghost', vars: ['var1=val1'])
81
+ end.to raise_error(Moose::Inventory::DB.exceptions[:moose], "The host 'ghost' does not exist.")
82
+ end
83
+
84
+ it 'rejects unsupported entity types via the shared variable operation support' do
85
+ operation = build_operation(:thing)
86
+
87
+ expect do
88
+ operation.call(name: 'whatever', vars: ['var1=val1'])
89
+ end.to raise_error(ArgumentError, /Unsupported entity type/)
90
+ end
91
+
92
+ it 'emits partial progress before raising on malformed host variable input' do
93
+ @db.models[:host].create(name: 'test1')
94
+ emitted = []
95
+ operation = build_operation(:host, emitter: emitted.method(:<<))
96
+
97
+ expect do
98
+ operation.call(name: 'test1', vars: ['broken'])
99
+ end.to raise_error(Moose::Inventory::DB.exceptions[:moose], /Expected 'key=value'/)
100
+
101
+ expect(emitted.map(&:type)).to eq(%i[entity_started retrieving_entity ok adding_variable])
102
+ end
103
+ end
@@ -46,6 +46,52 @@ RSpec.describe Moose::Inventory::Operations::GroupChildRelations do
46
46
  expect(parent.children_dataset[name: 'created']).not_to be_nil
47
47
  end
48
48
 
49
+ it 'dry-runs adding child groups without creating children or associations' do
50
+ parent = @db.models[:group].create(name: 'parent')
51
+
52
+ result = operation.add_children(
53
+ parent_group: parent,
54
+ parent_name: 'parent',
55
+ child_names: ['created'],
56
+ dry_run: true
57
+ )
58
+
59
+ expect(result.warning_count).to eq(1)
60
+ expect(result.events.map(&:type)).to include(:child_group_missing, :dry_run_summary)
61
+ expect(@db.models[:group].find(name: 'created')).to be_nil
62
+ expect(parent.children_dataset[name: 'created']).to be_nil
63
+ end
64
+
65
+ it 'dry-runs removing child groups without deleting orphaned descendants' do
66
+ parent = @db.models[:group].create(name: 'parent')
67
+ child = @db.models[:group].create(name: 'child')
68
+ grandchild = @db.models[:group].create(name: 'grandchild')
69
+ host = @db.models[:host].create(name: 'child-host')
70
+ child.add_host(host)
71
+ parent.add_child(child)
72
+ child.add_child(grandchild)
73
+
74
+ result = operation.remove_children(
75
+ parent_group: parent,
76
+ parent_name: 'parent',
77
+ child_names: ['child'],
78
+ delete_orphans: true,
79
+ dry_run: true
80
+ )
81
+
82
+ expect(result.warning_count).to eq(0)
83
+ expect(result.events.map(&:type)).to include(
84
+ :recursively_delete_orphaned_group,
85
+ :destroying_group,
86
+ :adding_automatic_group_to_host,
87
+ :dry_run_summary
88
+ )
89
+ expect(parent.children_dataset[name: 'child']).not_to be_nil
90
+ expect(@db.models[:group].find(name: 'child')).not_to be_nil
91
+ expect(@db.models[:group].find(name: 'grandchild')).not_to be_nil
92
+ expect(host.groups_dataset[name: 'ungrouped']).to be_nil
93
+ end
94
+
49
95
  it 'removes child groups and recursively deletes orphan groups when requested' do
50
96
  parent = @db.models[:group].create(name: 'parent')
51
97
  child = @db.models[:group].create(name: 'child')
@@ -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