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,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'yaml'
5
+
6
+ RSpec.describe 'Ansible inventory plugin examples' do
7
+ it 'ships a plugin, inventory source, and ansible.cfg example' do
8
+ expect(File).to exist('examples/ansible/inventory_plugins/moose_inventory.py')
9
+ expect(File).to exist('examples/ansible/inventory/moose_inventory.yml')
10
+ expect(File).to exist('examples/ansible/ansible.cfg')
11
+ end
12
+
13
+ it 'uses the moose_inventory plugin in the example inventory source' do
14
+ config = YAML.safe_load_file('examples/ansible/inventory/moose_inventory.yml')
15
+
16
+ expect(config).to include(
17
+ 'plugin' => 'moose_inventory',
18
+ 'executable' => 'moose-inventory',
19
+ 'config' => './example.conf',
20
+ 'env' => 'dev'
21
+ )
22
+ end
23
+
24
+ it 'keeps the plugin source syntax-valid' do
25
+ result = system('python3', '-m', 'py_compile', 'examples/ansible/inventory_plugins/moose_inventory.py')
26
+
27
+ expect(result).to eq(true)
28
+ end
29
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ require 'spec_helper'
5
+
6
+ RSpec.describe Moose::Inventory::Cli::Application do
7
+ before(:all) do
8
+ setup_cli_harness(command_class: described_class)
9
+ end
10
+
11
+ before(:each) do
12
+ reset_cli_harness
13
+ end
14
+
15
+ it 'prints a human success report when no issues are found' do
16
+ web = @db.models[:group].create(name: 'web')
17
+ host = @db.models[:host].create(name: 'web01')
18
+ host.add_group(web)
19
+
20
+ actual = runner { @app.start(%w[doctor]) }
21
+
22
+ expected(actual, aborted: false, STDOUT: "Inventory doctor found no issues.\n", STDERR: '')
23
+ end
24
+
25
+ it 'prints a human report and exits nonzero when issues are found' do
26
+ @db.models[:group].create(name: 'ungrouped')
27
+ host = @db.models[:host].create(name: 'lonely')
28
+ host.add_group(@db.models[:group].find(name: 'ungrouped'))
29
+
30
+ actual = runner { @app.start(%w[doctor]) }
31
+
32
+ expect(actual[:aborted]).to eq(true)
33
+ expect(actual[:STDOUT]).to include('Inventory doctor found')
34
+ expect(actual[:STDOUT]).to include('host_only_in_ungrouped')
35
+ end
36
+
37
+ it 'prints a machine-readable report when requested' do
38
+ @db.models[:group].create(name: 'ungrouped')
39
+ host = @db.models[:host].create(name: 'lonely')
40
+ host.add_group(@db.models[:group].find(name: 'ungrouped'))
41
+
42
+ actual = runner { @app.start(%w[doctor --format json]) }
43
+
44
+ expect(actual[:aborted]).to eq(true)
45
+ report = JSON.parse(actual[:STDOUT])
46
+ expect(report['ok']).to eq(false)
47
+ expect(report['issues'].map { |issue| issue['id'] }).to include('host_only_in_ungrouped')
48
+ end
49
+ end
50
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ require 'spec_helper'
5
+ require 'tmpdir'
6
+
7
+ RSpec.describe Moose::Inventory::Cli::Application do
8
+ before(:all) do
9
+ setup_cli_harness(command_class: described_class)
10
+ end
11
+
12
+ before(:each) do
13
+ reset_cli_harness
14
+ end
15
+
16
+ it 'exports the current inventory snapshot as yaml' do
17
+ runner { @app.start(%w[host add web01]) }
18
+ runner { @app.start(%w[group add web]) }
19
+ runner { @app.start(%w[host addgroup web01 web]) }
20
+
21
+ actual = runner { @app.start(%w[export]) }
22
+
23
+ expect(actual[:unexpected]).to eq(false)
24
+ expect(actual[:aborted]).to eq(false)
25
+ expect(actual[:STDERR]).to eq('')
26
+ snapshot = YAML.safe_load(actual[:STDOUT])
27
+ expect(snapshot['version']).to eq(1)
28
+ expect(snapshot.dig('hosts', 'web01', 'groups')).to include('web')
29
+ expect(snapshot['groups']).to have_key('web')
30
+ end
31
+
32
+ it 'imports a validated inventory snapshot file' do
33
+ Dir.mktmpdir do |dir|
34
+ path = File.join(dir, 'inventory.yml')
35
+ File.write(
36
+ path,
37
+ {
38
+ 'version' => 1,
39
+ 'hosts' => { 'web01' => { 'groups' => ['web'], 'tags' => [], 'vars' => { 'env' => 'prod' } } },
40
+ 'groups' => { 'web' => { 'children' => [], 'tags' => [], 'vars' => { 'role' => 'frontend' } } }
41
+ }.to_yaml
42
+ )
43
+
44
+ actual = runner { @app.start(['import', path]) }
45
+
46
+ expect(actual[:unexpected]).to eq(false)
47
+ expect(actual[:aborted]).to eq(false)
48
+ expect(actual[:STDERR]).to eq('')
49
+ expect(actual[:STDOUT]).to include("Imported inventory snapshot from #{path}.")
50
+ host = @db.models[:host].find(name: 'web01')
51
+ expect(host.groups_dataset[name: 'web']).not_to be_nil
52
+ expect(host.hostvars_dataset[name: 'env'][:value]).to eq('prod')
53
+ end
54
+ end
55
+
56
+ it 'previews an inventory snapshot import without writing' do
57
+ runner { @app.start(%w[group add web]) }
58
+
59
+ Dir.mktmpdir do |dir|
60
+ path = File.join(dir, 'inventory.yml')
61
+ File.write(
62
+ path,
63
+ {
64
+ 'version' => 1,
65
+ 'hosts' => { 'web01' => { 'groups' => ['web'], 'tags' => [], 'vars' => { 'env' => 'prod' } } },
66
+ 'groups' => { 'web' => { 'children' => [], 'tags' => [], 'vars' => {} } }
67
+ }.to_yaml
68
+ )
69
+
70
+ actual = runner { @app.start(['import', path, '--preview', '--preview-format', 'json']) }
71
+
72
+ expect(actual[:unexpected]).to eq(false)
73
+ expect(actual[:aborted]).to eq(false)
74
+ expect(actual[:STDERR]).to eq('')
75
+ preview = JSON.parse(actual[:STDOUT])
76
+ expect(preview['schema_version']).to eq('snapshot-import-preview-v1')
77
+ expect(preview['changes_applied']).to eq(false)
78
+ expect(preview.dig('summary', 'hosts_created')).to eq(1)
79
+ expect(preview.dig('creates', 'hosts')).to eq(['web01'])
80
+ expect(@db.models[:host].find(name: 'web01')).to be_nil
81
+ end
82
+ end
83
+
84
+ it 'aborts on invalid snapshot input without writing' do
85
+ Dir.mktmpdir do |dir|
86
+ path = File.join(dir, 'inventory.yml')
87
+ File.write(
88
+ path,
89
+ { 'version' => 1, 'hosts' => { 'web01' => { 'groups' => ['missing'], 'vars' => {} } }, 'groups' => {} }.to_yaml
90
+ )
91
+
92
+ actual = runner { @app.start(['import', path]) }
93
+
94
+ expect(actual[:aborted]).to eq(true)
95
+ expect(actual[:STDERR]).to include("Invalid inventory snapshot: host 'web01' references unknown group 'missing'.")
96
+ expect(@db.models[:host].count).to eq(0)
97
+ end
98
+ end
99
+ end
100
+ # rubocop:enable Metrics/BlockLength
@@ -1,15 +1,25 @@
1
- # require 'spec_helper'
2
- #
3
- # RSpec.describe Moose::Inventory::Cli::Group do
4
- # before do
5
- # # Set up the configuration object
6
- # mockargs = "--format yaml --env testing --config ./test.config"
7
- # Moose::Inventory::Config
8
- # end
9
- #
10
- # describe ".add" do
11
- # it '"add test" should add a group called test' do
12
- # expect(["group","add","test").to_eq(2)
13
- # end
14
- # end
15
- # end
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Moose::Inventory::Cli::Application do
6
+ describe 'version' do
7
+ it 'prints the current Moose Inventory version' do
8
+ output = capture(:STDOUT) do
9
+ described_class.start(%w[version])
10
+ end
11
+
12
+ expect(output).to eq("Version #{Moose::Inventory::VERSION}\n")
13
+ end
14
+ end
15
+
16
+ describe 'subcommands' do
17
+ it 'registers the group subcommand' do
18
+ expect(described_class.subcommands).to include('group')
19
+ end
20
+
21
+ it 'registers the host subcommand' do
22
+ expect(described_class.subcommands).to include('host')
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'spec_helper'
5
+
6
+ # rubocop:disable Metrics/BlockLength
7
+ RSpec.describe Moose::Inventory::Cli::Audit do
8
+ before(:all) do
9
+ setup_cli_harness(command_class: Moose::Inventory::Cli::Audit)
10
+ end
11
+
12
+ before(:each) do
13
+ reset_cli_harness
14
+ end
15
+
16
+ it 'starts with an empty audit log' do
17
+ actual = runner { @app.start(%w[audit list]) }
18
+
19
+ expected(actual, aborted: false, STDOUT: "No audit events recorded.\n", STDERR: '')
20
+ end
21
+
22
+ it 'records successful mutating host commands' do
23
+ add = runner { @app.start(%w[host add app01]) }
24
+ expect(add[:unexpected]).to eq(false)
25
+
26
+ event = @db.models[:audit_event].last
27
+ expect(event.command).to eq('host add')
28
+ expect(event.action).to eq('add')
29
+ expect(event.entity_type).to eq('host')
30
+ expect(event.entity_name).to eq('app01')
31
+ expect(JSON.parse(event.details)).to include('events')
32
+ end
33
+
34
+ it 'does not record dry-run commands' do
35
+ actual = runner { @app.start(%w[host add --dry-run app01]) }
36
+ expect(actual[:unexpected]).to eq(false)
37
+
38
+ expect(@db.models[:audit_event].count).to eq(0)
39
+ end
40
+
41
+ it 'lists audit events as machine-readable JSON' do
42
+ runner { @app.start(%w[group add web]) }
43
+
44
+ actual = runner { @app.start(%w[audit list --format json]) }
45
+ parsed = JSON.parse(actual[:STDOUT])
46
+
47
+ expect(actual[:unexpected]).to eq(false)
48
+ expect(parsed.first).to include(
49
+ 'command' => 'group add',
50
+ 'action' => 'add',
51
+ 'entity_type' => 'group',
52
+ 'entity_name' => 'web'
53
+ )
54
+ end
55
+ end
56
+ # rubocop:enable Metrics/BlockLength
@@ -1,25 +1,21 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'spec_helper'
2
4
 
3
- RSpec.describe Moose::Inventory::Cli::Application do
4
- before do
5
- @app = Moose::Inventory::Cli::Application
6
- end
5
+ RSpec.describe Moose::Inventory::Cli do
6
+ describe '.start' do
7
+ it 'wires config, db, and application explicitly' do
8
+ args = ['--format', 'yaml']
9
+ config = instance_double('Config')
10
+ db = instance_double('DB')
11
+ application = class_double('Application')
7
12
 
8
- describe '.version' do
9
- # --------------------
10
- it 'method should be responsive' do
11
- result = @app.instance_methods(false).include?(:version)
12
- expect(result).to eq(true)
13
- end
13
+ expect(config).to receive(:init).with(args).ordered
14
+ expect(db).to receive(:init).ordered
15
+ expect(config).to receive(:application_args).ordered.and_return(['version'])
16
+ expect(application).to receive(:start).with(['version']).ordered
14
17
 
15
- # --------------------
16
- # it 'should output version information' do
17
- # actual = runner { @app.version }
18
- #
19
- # desired = {}
20
- # desired[:STDERR] = "Version #{Moose::Inventory::VERSION}"
21
- #
22
- # expected(actual, desired)
23
- # end
18
+ described_class.start(args, config: config, db: db, application: application)
19
+ end
24
20
  end
25
21
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'stringio'
5
+
6
+ # rubocop:disable Metrics/BlockLength
7
+ RSpec.describe Moose::Inventory::Cli::Console 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
+ def run_console(input)
17
+ original_stdin = $stdin
18
+ $stdin = StringIO.new(input)
19
+ runner { @app.start(%w[console]) }
20
+ ensure
21
+ $stdin = original_stdin
22
+ end
23
+
24
+ it 'prints help and exits' do
25
+ actual = run_console("help\nquit\n")
26
+
27
+ expect(actual[:unexpected]).to eq(false)
28
+ expect(actual[:STDOUT]).to include('Moose Inventory console (read-only). Type help or quit.')
29
+ expect(actual[:STDOUT]).to include('- hosts')
30
+ expect(actual[:STDOUT]).to include('- group NAME')
31
+ expect(actual[:STDOUT]).to include('Goodbye.')
32
+ end
33
+
34
+ it 'browses hosts, groups, tags, and entity detail without mutating' do
35
+ runner { @app.start(%w[group add web]) }
36
+ runner { @app.start(%w[host add web01 --groups web]) }
37
+ runner { @app.start(%w[host addtag web01 prod]) }
38
+ before_audit_count = @db.models[:audit_event].count
39
+
40
+ actual = run_console("hosts\ngroups\nhost web01\ntags host web01\nquit\n")
41
+
42
+ expect(actual[:unexpected]).to eq(false)
43
+ expect(actual[:STDOUT]).to include('Hosts: web01')
44
+ expect(actual[:STDOUT]).to include('Groups: web')
45
+ expect(actual[:STDOUT]).to include('Host: web01')
46
+ expect(actual[:STDOUT]).to include('Tags: prod')
47
+ expect(@db.models[:audit_event].count).to eq(before_audit_count)
48
+ end
49
+
50
+ it 'reports unknown and missing entities safely' do
51
+ actual = run_console("nonsense\nhost missing\nquit\n")
52
+
53
+ expect(actual[:unexpected]).to eq(false)
54
+ expect(actual[:STDOUT]).to include('Unknown command: nonsense')
55
+ expect(actual[:STDOUT]).to include("Host 'missing' not found.")
56
+ end
57
+
58
+ it 'supports quoted read-only entity names' do
59
+ group = @db.models[:group].create(name: 'prod group')
60
+ host = @db.models[:host].create(name: 'web 01')
61
+ tag = @db.models[:tag].create(name: 'critical host')
62
+ host.add_group(group)
63
+ host.add_tag(tag)
64
+ before_audit_count = @db.models[:audit_event].count
65
+
66
+ actual = run_console("host \"web 01\"\ntags host \"web 01\"\ngroup 'prod group'\nquit\n")
67
+
68
+ expect(actual[:unexpected]).to eq(false)
69
+ expect(actual[:STDOUT]).to include('Host: web 01')
70
+ expect(actual[:STDOUT]).to include('Groups: prod group')
71
+ expect(actual[:STDOUT]).to include('critical host')
72
+ expect(actual[:STDOUT]).to include('Group: prod group')
73
+ expect(actual[:STDOUT]).to include('Hosts: web 01')
74
+ expect(@db.models[:audit_event].count).to eq(before_audit_count)
75
+ end
76
+
77
+ it 'reports command-specific usage for extra or invalid arguments' do
78
+ actual = run_console("host one two\ntags host\naudit nope\naudit 0\nhelp extra\nquit\n")
79
+
80
+ expect(actual[:unexpected]).to eq(false)
81
+ expect(actual[:STDOUT]).to include('Usage: host NAME')
82
+ expect(actual[:STDOUT]).to include('Usage: tags host|group NAME')
83
+ expect(actual[:STDOUT].scan('Usage: audit [LIMIT]').length).to eq(2)
84
+ expect(actual[:STDOUT]).to include('Usage: help')
85
+ end
86
+
87
+ it 'reports unclosed quoted input without leaving read-only mode' do
88
+ before_audit_count = @db.models[:audit_event].count
89
+
90
+ actual = run_console("host \"unterminated\nquit\n")
91
+
92
+ expect(actual[:unexpected]).to eq(false)
93
+ expect(actual[:STDOUT]).to include('Invalid command syntax:')
94
+ expect(actual[:STDOUT]).to include('Goodbye.')
95
+ expect(@db.models[:audit_event].count).to eq(before_audit_count)
96
+ end
97
+ end
98
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'cli/factory'
5
+ require 'operations/query_inventory'
6
+
7
+ RSpec.describe Moose::Inventory::Cli::Factory do
8
+ let(:context) { instance_double('InventoryContext') }
9
+ subject(:factory) { described_class.new(context: context) }
10
+
11
+ it 'builds operations with the shared context' do
12
+ operation_class = class_double('OperationClass')
13
+ operation = instance_double('Operation')
14
+
15
+ expect(operation_class).to receive(:new).with(context: context, emitter: :emit).and_return(operation)
16
+
17
+ expect(factory.operation(operation_class, emitter: :emit)).to eq(operation)
18
+ end
19
+
20
+ it 'memoizes the query inventory wrapper for the shared context' do
21
+ first = factory.query_inventory
22
+ second = factory.query_inventory
23
+
24
+ expect(first).to be_a(Moose::Inventory::Operations::QueryInventory)
25
+ expect(second).to equal(first)
26
+ end
27
+ end
@@ -1,6 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'spec_helper'
2
4
 
3
- # TODO: the usual respond_to? method doesn't seem to work on Thor objects.
4
5
  # Why not? For now, we'll check against instance_methods.
5
6
 
6
7
  RSpec.describe Moose::Inventory::Cli::Formatter do
@@ -33,7 +34,7 @@ RSpec.describe Moose::Inventory::Cli::Formatter do
33
34
  actual = runner { @formatter.out(test, 'json') }
34
35
 
35
36
  desired = { aborted: false, STDOUT: '', STDERR: '' }
36
- desired[:STDOUT] = test.to_json + "\n"
37
+ desired[:STDOUT] = "#{test.to_json}\n"
37
38
 
38
39
  expected(actual, desired)
39
40
  end
@@ -44,7 +45,7 @@ RSpec.describe Moose::Inventory::Cli::Formatter do
44
45
  actual = runner { @formatter.out(test, 'prettyjson') }
45
46
 
46
47
  desired = { aborted: false, STDOUT: '', STDERR: '' }
47
- desired[:STDOUT] = JSON.pretty_generate(test) + "\n"
48
+ desired[:STDOUT] = "#{JSON.pretty_generate(test)}\n"
48
49
 
49
50
  expected(actual, desired)
50
51
  end
@@ -59,5 +60,96 @@ RSpec.describe Moose::Inventory::Cli::Formatter do
59
60
 
60
61
  expected(actual, desired)
61
62
  end
63
+
64
+ it 'info() prints the provided message instead of a literal placeholder' do
65
+ actual = runner { @formatter.info(2, 'hello world') }
66
+
67
+ desired = { aborted: false, STDOUT: '', STDERR: '' }
68
+ desired[:STDOUT] = ' INFO: hello world'
69
+
70
+ expected(actual, desired)
71
+ end
72
+ end
73
+
74
+ describe 'stream helpers' do
75
+ it 'puts() writes indented text to stdout by default' do
76
+ actual = runner { @formatter.puts(2, 'hello world') }
77
+
78
+ desired = { aborted: false, STDOUT: " hello world\n", STDERR: '' }
79
+
80
+ expected(actual, desired)
81
+ end
82
+
83
+ it 'puts() writes indented text to stderr when requested' do
84
+ actual = runner { @formatter.puts(2, 'hello world', 'STDERR') }
85
+
86
+ desired = { aborted: false, STDOUT: '', STDERR: " hello world\n" }
87
+
88
+ expected(actual, desired)
89
+ end
90
+
91
+ it 'puts() aborts on an unknown output stream' do
92
+ actual = runner { @formatter.puts(2, 'hello world', 'BOGUS') }
93
+
94
+ desired = { aborted: true, STDOUT: '', STDERR: "Output stream 'BOGUS' is not known.\n" }
95
+
96
+ expected(actual, desired)
97
+ end
98
+
99
+ it 'print() writes indented text to stdout by default' do
100
+ actual = runner { @formatter.print(2, 'hello world') }
101
+
102
+ desired = { aborted: false, STDOUT: ' hello world', STDERR: '' }
103
+
104
+ expected(actual, desired)
105
+ end
106
+
107
+ it 'print() writes indented text to stderr when requested' do
108
+ actual = runner { @formatter.print(2, 'hello world', 'STDERR') }
109
+
110
+ desired = { aborted: false, STDOUT: '', STDERR: ' hello world' }
111
+
112
+ expected(actual, desired)
113
+ end
114
+
115
+ it 'print() aborts on an unknown output stream' do
116
+ actual = runner { @formatter.print(2, 'hello world', 'BOGUS') }
117
+
118
+ desired = { aborted: true, STDOUT: '', STDERR: "Output stream 'BOGUS' is not known.\n" }
119
+
120
+ expected(actual, desired)
121
+ end
122
+
123
+ it 'info() writes to stderr when requested' do
124
+ actual = runner { @formatter.info(2, 'hello world', 'STDERR') }
125
+
126
+ desired = { aborted: false, STDOUT: '', STDERR: ' INFO: hello world' }
127
+
128
+ expected(actual, desired)
129
+ end
130
+
131
+ it 'info() aborts on an unknown output stream' do
132
+ actual = runner { @formatter.info(2, 'hello world', 'BOGUS') }
133
+
134
+ desired = { aborted: true, STDOUT: '', STDERR: "Output stream 'BOGUS' is not known.\n" }
135
+
136
+ expected(actual, desired)
137
+ end
138
+
139
+ it 'warn() writes to stderr with the warning prefix' do
140
+ actual = runner { @formatter.warn('hello world') }
141
+
142
+ desired = { aborted: false, STDOUT: '', STDERR: 'WARNING: hello world' }
143
+
144
+ expected(actual, desired)
145
+ end
146
+
147
+ it 'error() writes to stderr with the error prefix' do
148
+ actual = runner { @formatter.error('hello world') }
149
+
150
+ desired = { aborted: false, STDOUT: '', STDERR: 'ERROR: hello world' }
151
+
152
+ expected(actual, desired)
153
+ end
62
154
  end
63
155
  end