moose-inventory 1.0.9 → 2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +15 -1
  3. data/.github/workflows/release.yml +60 -0
  4. data/.gitignore +2 -1
  5. data/.gitleaks.toml +9 -0
  6. data/.rubocop.yml +49 -0
  7. data/BACKLOG.md +752 -24
  8. data/Gemfile +2 -0
  9. data/Gemfile.lock +36 -1
  10. data/README.md +340 -44
  11. data/Rakefile +2 -0
  12. data/bin/moose-inventory +2 -1
  13. data/docs/architecture/architecture-and-trust-boundaries.md +444 -0
  14. data/docs/compatibility/cli-output-compatibility.md +76 -0
  15. data/docs/governance/approval-register.md +37 -0
  16. data/docs/maintenance/database-backup-restore-guidance.md +162 -0
  17. data/docs/maintenance/package-maintenance-and-agent-boundaries.md +260 -0
  18. data/docs/process/conformance-gap-analysis-2026-05-28.md +192 -0
  19. data/docs/product/product-brief.md +161 -0
  20. data/docs/product/requirements-baseline.md +477 -0
  21. data/docs/qa/qa-documentation-and-release-gates.md +283 -0
  22. data/docs/release/package-provenance-hardening.md +126 -0
  23. data/docs/release/publishing.md +54 -50
  24. data/docs/release/release-environment-protection.md +70 -0
  25. data/docs/release/release-readiness.md +37 -4
  26. data/docs/security/accepted-risk-register.md +84 -0
  27. data/docs/security/security-privacy-process.md +287 -0
  28. data/docs/security-audit-2026-05-26-rerun.md +75 -0
  29. data/docs/security-audit-2026-05-26.md +63 -0
  30. data/docs/ux/cli-workflow-notes.md +287 -0
  31. data/examples/ansible/ansible.cfg +3 -0
  32. data/examples/ansible/inventory/moose_inventory.yml +5 -0
  33. data/examples/ansible/inventory_plugins/moose_inventory.py +100 -0
  34. data/examples/ci/README.md +16 -0
  35. data/examples/ci/github-actions/inventory-review.yml +38 -0
  36. data/examples/ci/inventory/example-snapshot.yml +19 -0
  37. data/examples/ci/scripts/validate-inventory-snapshot.sh +30 -0
  38. data/lib/moose_inventory/cli/application.rb +133 -5
  39. data/lib/moose_inventory/cli/association_rendering.rb +74 -0
  40. data/lib/moose_inventory/cli/association_rendering_support.rb +89 -0
  41. data/lib/moose_inventory/cli/audit.rb +62 -0
  42. data/lib/moose_inventory/cli/audit_recording.rb +40 -0
  43. data/lib/moose_inventory/cli/child_relation_rendering.rb +110 -0
  44. data/lib/moose_inventory/cli/console.rb +135 -0
  45. data/lib/moose_inventory/cli/db.rb +64 -0
  46. data/lib/moose_inventory/cli/factory.rb +28 -0
  47. data/lib/moose_inventory/cli/formatter.rb +8 -12
  48. data/lib/moose_inventory/cli/group.rb +7 -1
  49. data/lib/moose_inventory/cli/group_add.rb +91 -73
  50. data/lib/moose_inventory/cli/group_addchild.rb +41 -66
  51. data/lib/moose_inventory/cli/group_addhost.rb +33 -71
  52. data/lib/moose_inventory/cli/group_addvar.rb +27 -47
  53. data/lib/moose_inventory/cli/group_get.rb +8 -42
  54. data/lib/moose_inventory/cli/group_list.rb +7 -40
  55. data/lib/moose_inventory/cli/group_listvars.rb +9 -55
  56. data/lib/moose_inventory/cli/group_rm.rb +105 -73
  57. data/lib/moose_inventory/cli/group_rmchild.rb +47 -57
  58. data/lib/moose_inventory/cli/group_rmhost.rb +34 -61
  59. data/lib/moose_inventory/cli/group_rmvar.rb +30 -41
  60. data/lib/moose_inventory/cli/group_tags.rb +33 -0
  61. data/lib/moose_inventory/cli/helpers.rb +143 -0
  62. data/lib/moose_inventory/cli/host.rb +8 -2
  63. data/lib/moose_inventory/cli/host_add.rb +91 -66
  64. data/lib/moose_inventory/cli/host_addgroup.rb +39 -66
  65. data/lib/moose_inventory/cli/host_addvar.rb +28 -52
  66. data/lib/moose_inventory/cli/host_get.rb +9 -37
  67. data/lib/moose_inventory/cli/host_list.rb +24 -21
  68. data/lib/moose_inventory/cli/host_listvars.rb +9 -62
  69. data/lib/moose_inventory/cli/host_rm.rb +60 -42
  70. data/lib/moose_inventory/cli/host_rmgroup.rb +39 -55
  71. data/lib/moose_inventory/cli/host_rmvar.rb +31 -45
  72. data/lib/moose_inventory/cli/host_tags.rb +33 -0
  73. data/lib/moose_inventory/cli/listvars_support.rb +55 -0
  74. data/lib/moose_inventory/cli/plan_rendering.rb +50 -0
  75. data/lib/moose_inventory/cli/relation_transaction_support.rb +51 -0
  76. data/lib/moose_inventory/cli/tag_support.rb +97 -0
  77. data/lib/moose_inventory/cli/variable_rendering.rb +67 -0
  78. data/lib/moose_inventory/config/config.rb +185 -108
  79. data/lib/moose_inventory/db/db.rb +188 -193
  80. data/lib/moose_inventory/db/exceptions.rb +6 -3
  81. data/lib/moose_inventory/db/models.rb +16 -0
  82. data/lib/moose_inventory/db/schema_migrations.rb +248 -0
  83. data/lib/moose_inventory/inventory_context.rb +116 -0
  84. data/lib/moose_inventory/operations/add_associations.rb +131 -0
  85. data/lib/moose_inventory/operations/add_groups.rb +123 -0
  86. data/lib/moose_inventory/operations/add_hosts.rb +123 -0
  87. data/lib/moose_inventory/operations/add_variables.rb +77 -0
  88. data/lib/moose_inventory/operations/entity_variable_operation_support.rb +46 -0
  89. data/lib/moose_inventory/operations/group_child_relations.rb +125 -0
  90. data/lib/moose_inventory/operations/group_cleanup.rb +70 -0
  91. data/lib/moose_inventory/operations/import_inventory_snapshot.rb +41 -0
  92. data/lib/moose_inventory/operations/inventory_doctor.rb +172 -0
  93. data/lib/moose_inventory/operations/inventory_snapshot.rb +60 -0
  94. data/lib/moose_inventory/operations/inventory_snapshot_applier.rb +112 -0
  95. data/lib/moose_inventory/operations/inventory_snapshot_preview.rb +174 -0
  96. data/lib/moose_inventory/operations/inventory_snapshot_validator.rb +134 -0
  97. data/lib/moose_inventory/operations/operation_event_support.rb +27 -0
  98. data/lib/moose_inventory/operations/query_inventory/base_query.rb +24 -0
  99. data/lib/moose_inventory/operations/query_inventory/group_queries.rb +86 -0
  100. data/lib/moose_inventory/operations/query_inventory/host_queries.rb +106 -0
  101. data/lib/moose_inventory/operations/query_inventory.rb +47 -0
  102. data/lib/moose_inventory/operations/remove_associations.rb +113 -0
  103. data/lib/moose_inventory/operations/remove_groups.rb +79 -0
  104. data/lib/moose_inventory/operations/remove_hosts.rb +68 -0
  105. data/lib/moose_inventory/operations/remove_variables.rb +67 -0
  106. data/lib/moose_inventory/runtime_options.rb +31 -0
  107. data/lib/moose_inventory/version.rb +3 -1
  108. data/lib/moose_inventory.rb +10 -7
  109. data/moose-inventory.gemspec +22 -35
  110. data/scripts/check.sh +3 -0
  111. data/scripts/ci/check_generated_artifacts.sh +41 -0
  112. data/scripts/ci/check_permissions.sh +5 -0
  113. data/scripts/ci/check_rubocop.sh +33 -0
  114. data/scripts/ci/check_secrets.sh +26 -0
  115. data/scripts/ci/check_security.sh +18 -0
  116. data/scripts/ci/install_security_tools.sh +47 -0
  117. data/scripts/files.rb +5 -4
  118. data/scripts/install_dependencies.sh +2 -0
  119. data/spec/examples/ci_examples_spec.rb +37 -0
  120. data/spec/lib/moose_inventory/ansible_plugin_examples_spec.rb +29 -0
  121. data/spec/lib/moose_inventory/cli/application_doctor_spec.rb +50 -0
  122. data/spec/lib/moose_inventory/cli/application_import_export_spec.rb +100 -0
  123. data/spec/lib/moose_inventory/cli/application_spec.rb +25 -15
  124. data/spec/lib/moose_inventory/cli/audit_spec.rb +56 -0
  125. data/spec/lib/moose_inventory/cli/cli_spec.rb +15 -19
  126. data/spec/lib/moose_inventory/cli/console_spec.rb +98 -0
  127. data/spec/lib/moose_inventory/cli/factory_spec.rb +27 -0
  128. data/spec/lib/moose_inventory/cli/formatter_spec.rb +95 -3
  129. data/spec/lib/moose_inventory/cli/group_add_spec.rb +140 -116
  130. data/spec/lib/moose_inventory/cli/group_addchild_spec.rb +89 -35
  131. data/spec/lib/moose_inventory/cli/group_addhost_spec.rb +81 -84
  132. data/spec/lib/moose_inventory/cli/group_addvar_spec.rb +65 -68
  133. data/spec/lib/moose_inventory/cli/group_get_spec.rb +17 -33
  134. data/spec/lib/moose_inventory/cli/group_list_spec.rb +16 -38
  135. data/spec/lib/moose_inventory/cli/group_listvar_spec.rb +33 -40
  136. data/spec/lib/moose_inventory/cli/group_rm_spec.rb +165 -85
  137. data/spec/lib/moose_inventory/cli/group_rmchild_spec.rb +100 -30
  138. data/spec/lib/moose_inventory/cli/group_rmhost_spec.rb +76 -78
  139. data/spec/lib/moose_inventory/cli/group_rmvar_spec.rb +57 -63
  140. data/spec/lib/moose_inventory/cli/group_spec.rb +2 -0
  141. data/spec/lib/moose_inventory/cli/helpers_spec.rb +146 -0
  142. data/spec/lib/moose_inventory/cli/host_add_spec.rb +170 -116
  143. data/spec/lib/moose_inventory/cli/host_addgroup_spec.rb +100 -83
  144. data/spec/lib/moose_inventory/cli/host_addvar_spec.rb +92 -74
  145. data/spec/lib/moose_inventory/cli/host_get_spec.rb +14 -33
  146. data/spec/lib/moose_inventory/cli/host_list_spec.rb +41 -33
  147. data/spec/lib/moose_inventory/cli/host_listvar_spec.rb +45 -53
  148. data/spec/lib/moose_inventory/cli/host_rm_spec.rb +66 -48
  149. data/spec/lib/moose_inventory/cli/host_rmgroup_spec.rb +73 -83
  150. data/spec/lib/moose_inventory/cli/host_rmvar_spec.rb +56 -63
  151. data/spec/lib/moose_inventory/cli/host_spec.rb +2 -0
  152. data/spec/lib/moose_inventory/cli/tags_spec.rb +81 -0
  153. data/spec/lib/moose_inventory/config/config_spec.rb +41 -3
  154. data/spec/lib/moose_inventory/db/db_spec.rb +551 -29
  155. data/spec/lib/moose_inventory/db/exceptions_spec.rb +18 -0
  156. data/spec/lib/moose_inventory/db/models_spec.rb +7 -3
  157. data/spec/lib/moose_inventory/db_lifecycle_spec.rb +73 -0
  158. data/spec/lib/moose_inventory/inventory_context_spec.rb +10 -0
  159. data/spec/lib/moose_inventory/operations/add_associations_spec.rb +111 -0
  160. data/spec/lib/moose_inventory/operations/add_groups_spec.rb +80 -0
  161. data/spec/lib/moose_inventory/operations/add_hosts_spec.rb +82 -0
  162. data/spec/lib/moose_inventory/operations/add_variables_spec.rb +103 -0
  163. data/spec/lib/moose_inventory/operations/group_child_relations_spec.rb +122 -0
  164. data/spec/lib/moose_inventory/operations/import_inventory_snapshot_spec.rb +226 -0
  165. data/spec/lib/moose_inventory/operations/inventory_doctor_spec.rb +77 -0
  166. data/spec/lib/moose_inventory/operations/inventory_snapshot_spec.rb +50 -0
  167. data/spec/lib/moose_inventory/operations/operation_event_support_spec.rb +78 -0
  168. data/spec/lib/moose_inventory/operations/query_inventory_spec.rb +146 -0
  169. data/spec/lib/moose_inventory/operations/remove_associations_spec.rb +113 -0
  170. data/spec/lib/moose_inventory/operations/remove_groups_spec.rb +78 -0
  171. data/spec/lib/moose_inventory/operations/remove_hosts_spec.rb +55 -0
  172. data/spec/lib/moose_inventory/operations/remove_variables_spec.rb +83 -0
  173. data/spec/shared/shared_config_setup.rb +4 -3
  174. data/spec/spec_helper.rb +50 -40
  175. data/spec/support/cli_harness.rb +33 -0
  176. metadata +163 -35
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'association_rendering_support'
4
+
5
+ module Moose
6
+ module Inventory
7
+ module Cli
8
+ # Shared rendering helpers for host/group association commands.
9
+ module AssociationRendering
10
+ include Moose::Inventory::Cli::AssociationRenderingSupport
11
+
12
+ private
13
+
14
+ def host_group_association_addition_emitter(perspective:)
15
+ lambda do |event|
16
+ render_host_group_association_addition_event(event, perspective:)
17
+ end
18
+ end
19
+
20
+ def host_group_association_removal_emitter(perspective:)
21
+ lambda do |event|
22
+ render_host_group_association_removal_event(event, perspective:)
23
+ end
24
+ end
25
+
26
+ def render_host_group_association_addition_event(event, perspective:)
27
+ payload = event.payload
28
+
29
+ return render_addition_warning(event.type, payload, perspective:) if addition_warning_event?(event.type)
30
+ return render_addition_existing(payload, perspective:) if event.type == :already_exists_skipping
31
+ return render_association_dry_run_summary if event.type == :dry_run_summary
32
+
33
+ render_addition_action_event(event.type, payload, perspective:)
34
+ end
35
+
36
+ def render_addition_action_event(type, payload, perspective:)
37
+ case type
38
+ when :adding_host_group_association, :adding_group_host_association
39
+ fmt.puts 2, "- #{verb_for(:add, perspective)} association #{association_label(payload, perspective:)}..."
40
+ when :group_creating_now, :host_creating_now
41
+ fmt.puts 4, "- #{missing_entity_label(perspective)} does not exist, creating now..."
42
+ when :removing_automatic_group
43
+ label = automatic_group_label(payload[:host], perspective:)
44
+ fmt.puts 2, "- #{verb_for(:remove, perspective)} automatic association #{label}..."
45
+ when :ok
46
+ fmt.puts payload[:indent], '- OK'
47
+ end
48
+ end
49
+
50
+ def render_association_dry_run_summary
51
+ puts 'Dry run complete. No changes applied.'
52
+ end
53
+
54
+ def render_host_group_association_removal_event(event, perspective:)
55
+ payload = event.payload
56
+
57
+ return render_removal_warning(payload, perspective:) if removal_warning_event?(event.type)
58
+ return render_removal_missing(payload, perspective:) if event.type == :missing_skipping
59
+ return render_association_dry_run_summary if event.type == :dry_run_summary
60
+
61
+ case event.type
62
+ when :removing_host_group_association, :removing_group_host_association
63
+ fmt.puts 2, "- #{verb_for(:remove, perspective)} association #{association_label(payload, perspective:)}..."
64
+ when :adding_automatic_group
65
+ label = automatic_group_label(payload[:host], perspective:)
66
+ fmt.puts 2, "- #{verb_for(:add, perspective)} automatic association #{label}..."
67
+ when :ok
68
+ fmt.puts payload[:indent], '- OK'
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moose
4
+ module Inventory
5
+ module Cli
6
+ # Shared string helpers for host/group association commands.
7
+ module AssociationRenderingSupport
8
+ private
9
+
10
+ def addition_warning_event?(type)
11
+ %i[
12
+ host_group_association_exists
13
+ group_host_association_exists
14
+ group_missing_created
15
+ host_missing_created
16
+ ].include?(type)
17
+ end
18
+
19
+ def removal_warning_event?(type)
20
+ %i[host_group_association_missing group_host_association_missing].include?(type)
21
+ end
22
+
23
+ def render_addition_warning(type, payload, perspective:)
24
+ if %i[host_group_association_exists group_host_association_exists].include?(type)
25
+ fmt.warn warning_text(
26
+ "Association #{association_label(payload, perspective:)} already exists, skipping.",
27
+ perspective:
28
+ )
29
+ else
30
+ fmt.warn warning_text(
31
+ "#{missing_entity_label(perspective).capitalize} '#{payload[:name]}' does not exist and will be created.",
32
+ perspective:
33
+ )
34
+ end
35
+ end
36
+
37
+ def render_addition_existing(payload, perspective:)
38
+ fmt.puts payload[:indent], "- #{existing_status_text(perspective)}"
39
+ end
40
+
41
+ def render_removal_warning(payload, perspective:)
42
+ fmt.warn "Association #{association_label(payload, perspective:)} doesn't exist, skipping.\n"
43
+ end
44
+
45
+ def render_removal_missing(payload, perspective:)
46
+ fmt.puts payload[:indent], "- #{missing_status_text(perspective)}"
47
+ end
48
+
49
+ def association_label(payload, perspective:)
50
+ if perspective == :host
51
+ "{host:#{payload[:host]} <-> group:#{payload[:group]}}"
52
+ else
53
+ "{group:#{payload[:group]} <-> host:#{payload[:host]}}"
54
+ end
55
+ end
56
+
57
+ def automatic_group_label(host, perspective:)
58
+ if perspective == :host
59
+ "{host:#{host} <-> group:ungrouped}"
60
+ else
61
+ "{group:ungrouped <-> host:#{host}}"
62
+ end
63
+ end
64
+
65
+ def verb_for(action, perspective)
66
+ return action.to_s.capitalize if perspective == :host
67
+
68
+ action.to_s
69
+ end
70
+
71
+ def missing_entity_label(perspective)
72
+ perspective == :host ? 'Group' : 'host'
73
+ end
74
+
75
+ def existing_status_text(perspective)
76
+ perspective == :host ? 'Already exists, skipping.' : 'already exists, skipping.'
77
+ end
78
+
79
+ def missing_status_text(perspective)
80
+ perspective == :host ? "Doesn't exist, skipping." : "doesn't exist, skipping."
81
+ end
82
+
83
+ def warning_text(text, perspective:)
84
+ perspective == :host ? text : "#{text}\n"
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'thor'
5
+
6
+ module Moose
7
+ module Inventory
8
+ module Cli
9
+ # Audit log inspection commands.
10
+ class Audit < Thor
11
+ include Moose::Inventory::Cli::Helpers
12
+
13
+ desc 'list', 'List recent append-only audit events'
14
+ option :limit, type: :numeric, default: 20
15
+ option :format, type: :string, desc: 'Emit audit events as yaml|json|pjson'
16
+ def list
17
+ events = inventory_context.audit_events(limit: options[:limit]).map { |event| serialize_event(event) }
18
+ if options[:format]
19
+ fmt.dump(events, options[:format].downcase)
20
+ else
21
+ render_human_events(events)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def serialize_event(event)
28
+ {
29
+ id: event.id,
30
+ created_at: event.created_at,
31
+ actor: event.actor,
32
+ command: event.command,
33
+ action: event.action,
34
+ entity_type: event.entity_type,
35
+ entity_name: event.entity_name,
36
+ details: parse_details(event.details)
37
+ }
38
+ end
39
+
40
+ def parse_details(details)
41
+ return nil if details.nil? || details.empty?
42
+
43
+ JSON.parse(details)
44
+ rescue JSON::ParserError
45
+ details
46
+ end
47
+
48
+ def render_human_events(events)
49
+ if events.empty?
50
+ puts 'No audit events recorded.'
51
+ return
52
+ end
53
+
54
+ events.each do |event|
55
+ puts "#{event[:id]} #{event[:created_at]} #{event[:command]} " \
56
+ "#{event[:entity_type]}=#{event[:entity_name]} action=#{event[:action]}"
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Moose
6
+ module Inventory
7
+ module Cli
8
+ # Shared append-only audit recording helpers for mutating CLI commands.
9
+ module AuditRecording
10
+ private
11
+
12
+ def record_audit(metadata, result:, dry_run: false)
13
+ return if dry_run
14
+
15
+ inventory_context.record_audit_event(
16
+ command: metadata.fetch(:command),
17
+ action: metadata.fetch(:action),
18
+ actor: ENV.fetch('USER', nil),
19
+ entity_type: metadata.fetch(:entity_type),
20
+ entity_name: Array(metadata.fetch(:entity_names)).join(','),
21
+ details: audit_details(result)
22
+ )
23
+ end
24
+
25
+ def audit_details(result)
26
+ JSON.generate(
27
+ warning_count: result.respond_to?(:warning_count) ? result.warning_count : 0,
28
+ events: audit_events_from_result(result)
29
+ )
30
+ end
31
+
32
+ def audit_events_from_result(result)
33
+ return [] unless result.respond_to?(:events)
34
+
35
+ result.events.map { |event| { type: event.type, payload: event.payload } }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moose
4
+ module Inventory
5
+ module Cli
6
+ # Shared rendering helpers for child-group relation commands.
7
+ module ChildRelationRendering
8
+ private
9
+
10
+ def addchild_emitter
11
+ lambda do |event|
12
+ render_addchild_event(event)
13
+ end
14
+ end
15
+
16
+ def rmchild_emitter
17
+ lambda do |event|
18
+ render_rmchild_event(event)
19
+ end
20
+ end
21
+
22
+ def render_addchild_event(event)
23
+ payload = event.payload
24
+
25
+ return render_addchild_warning(event.type, payload) if addchild_warning?(event.type)
26
+ return render_addchild_existing(payload) if event.type == :already_exists_skipping
27
+ return render_child_relation_dry_run_summary if event.type == :dry_run_summary
28
+
29
+ case event.type
30
+ when :adding_child_association
31
+ fmt.puts 2, "- add association {group:#{payload[:parent]} <-> group:#{payload[:child]}}..."
32
+ when :child_group_creating_now
33
+ fmt.puts 4, '- child group does not exist, creating now...'
34
+ when :ok
35
+ fmt.puts payload[:indent], '- OK'
36
+ end
37
+ end
38
+
39
+ def render_child_relation_dry_run_summary
40
+ puts 'Dry run complete. No changes applied.'
41
+ end
42
+
43
+ def addchild_warning?(type)
44
+ %i[child_association_exists child_group_missing].include?(type)
45
+ end
46
+
47
+ def render_addchild_warning(type, payload)
48
+ if type == :child_association_exists
49
+ fmt.warn "Association {group:#{payload[:parent]} <-> group:#{payload[:child]}} already exists, skipping.\n"
50
+ else
51
+ fmt.warn "Group '#{payload[:name]}' does not exist and will be created.\n"
52
+ end
53
+ end
54
+
55
+ def render_addchild_existing(payload)
56
+ fmt.puts payload[:indent], '- already exists, skipping.'
57
+ end
58
+
59
+ def render_rmchild_event(event)
60
+ payload = event.payload
61
+
62
+ return render_rmchild_warning(payload) if event.type == :child_association_missing
63
+ return render_rmchild_missing(payload) if event.type == :missing_skipping
64
+ return render_rmchild_progress(event.type, payload) if rmchild_progress_event?(event.type)
65
+ return render_child_relation_dry_run_summary if event.type == :dry_run_summary
66
+
67
+ render_rmchild_status(event.type, payload)
68
+ end
69
+
70
+ def rmchild_progress_event?(type)
71
+ %i[
72
+ removing_child_association
73
+ recursively_delete_orphaned_group
74
+ removing_recursive_child_association
75
+ ].include?(type)
76
+ end
77
+
78
+ def render_rmchild_progress(type, payload)
79
+ case type
80
+ when :removing_child_association
81
+ fmt.puts 2, "- remove association {group:#{payload[:parent]} <-> group:#{payload[:child]}}..."
82
+ when :recursively_delete_orphaned_group
83
+ fmt.puts 2, "- Recursively delete orphaned group '#{payload[:name]}'..."
84
+ when :removing_recursive_child_association
85
+ fmt.puts 4, "- Remove association {group:#{payload[:parent]} <-> group:#{payload[:child]}}..."
86
+ end
87
+ end
88
+
89
+ def render_rmchild_status(type, payload)
90
+ case type
91
+ when :adding_automatic_group_to_host
92
+ fmt.puts payload[:indent], "- Adding automatic association {group:ungrouped <-> host:#{payload[:host]}}..."
93
+ when :destroying_group
94
+ fmt.puts payload[:indent], "- Destroy group '#{payload[:name]}'..."
95
+ when :ok
96
+ fmt.puts payload[:indent], '- OK'
97
+ end
98
+ end
99
+
100
+ def render_rmchild_warning(payload)
101
+ fmt.warn "Association {group:#{payload[:parent]} <-> group:#{payload[:child]}} does not exist, skipping.\n"
102
+ end
103
+
104
+ def render_rmchild_missing(payload)
105
+ fmt.puts payload[:indent], "- doesn't exist, skipping."
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Moose
6
+ module Inventory
7
+ module Cli
8
+ # Small read-only interactive console for browsing inventory state.
9
+ class Console
10
+ COMMANDS = ['help', 'hosts', 'groups', 'host NAME', 'group NAME',
11
+ 'tags host NAME', 'tags group NAME', 'audit [LIMIT]', 'quit'].freeze
12
+
13
+ def initialize(context:, input: $stdin, output: $stdout)
14
+ @context = context
15
+ @input = input
16
+ @output = output
17
+ end
18
+
19
+ def run
20
+ output.puts 'Moose Inventory console (read-only). Type help or quit.'
21
+ input.each_line do |line|
22
+ parts = parse_command(line)
23
+ next if parts.nil? || parts.empty?
24
+
25
+ break if quit_command?(parts)
26
+
27
+ dispatch(parts)
28
+ end
29
+ output.puts 'Goodbye.'
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :context, :input, :output
35
+
36
+ def parse_command(line)
37
+ command = line.strip
38
+ return [] if command.empty?
39
+
40
+ Shellwords.split(command)
41
+ rescue ArgumentError => e
42
+ output.puts "Invalid command syntax: #{e.message}"
43
+ nil
44
+ end
45
+
46
+ def dispatch(parts)
47
+ handlers = {
48
+ 'help' => -> { render_exact(parts, 'help') { render_help } },
49
+ 'hosts' => -> { render_exact(parts, 'hosts') { render_hosts } },
50
+ 'groups' => -> { render_exact(parts, 'groups') { render_groups } },
51
+ 'host' => -> { render_entity(:host, parts) },
52
+ 'group' => -> { render_entity(:group, parts) },
53
+ 'tags' => -> { render_tags(parts) },
54
+ 'audit' => -> { render_audit(parts) }
55
+ }
56
+ handlers.fetch(parts.first, -> { output.puts "Unknown command: #{parts.join(' ')}" }).call
57
+ end
58
+
59
+ def quit_command?(parts)
60
+ parts.length == 1 && %w[quit exit].include?(parts.first)
61
+ end
62
+
63
+ def render_exact(parts, usage)
64
+ return output.puts("Usage: #{usage}") unless parts.length == 1
65
+
66
+ yield
67
+ end
68
+
69
+ def render_help
70
+ output.puts 'Commands:'
71
+ COMMANDS.each { |command| output.puts "- #{command}" }
72
+ end
73
+
74
+ def render_hosts
75
+ names = context.all_hosts.map(&:name).sort
76
+ output.puts(names.empty? ? 'No hosts.' : "Hosts: #{names.join(', ')}")
77
+ end
78
+
79
+ def render_groups
80
+ names = context.all_groups.map(&:name).sort
81
+ output.puts(names.empty? ? 'No groups.' : "Groups: #{names.join(', ')}")
82
+ end
83
+
84
+ def render_entity(type, parts)
85
+ return output.puts("Usage: #{type} NAME") unless parts.length == 2
86
+
87
+ name = parts[1]
88
+
89
+ entity = context.public_send("find_#{type}", name)
90
+ return output.puts("#{type.capitalize} '#{name}' not found.") if entity.nil?
91
+
92
+ output.puts "#{type.capitalize}: #{name}"
93
+ output.puts "Groups: #{entity.groups_dataset.map(:name).sort.join(', ')}" if type == :host
94
+ output.puts "Hosts: #{entity.hosts_dataset.map(:name).sort.join(', ')}" if type == :group
95
+ output.puts "Tags: #{entity.tags_dataset.map(:name).sort.join(', ')}"
96
+ end
97
+
98
+ def render_tags(parts)
99
+ type = parts[1]
100
+ name = parts[2]
101
+ return output.puts('Usage: tags host|group NAME') unless parts.length == 3 && %w[host group].include?(type)
102
+
103
+ entity = context.public_send("find_#{type}", name)
104
+ return output.puts("#{type.capitalize} '#{name}' not found.") if entity.nil?
105
+
106
+ tags = entity.tags_dataset.map(:name).sort
107
+ output.puts tags.empty? ? "#{type.capitalize} '#{name}' has no tags." : tags.join(', ')
108
+ end
109
+
110
+ def render_audit(parts)
111
+ limit = audit_limit(parts[1]) if parts.length <= 2
112
+ return output.puts('Usage: audit [LIMIT]') if parts.length > 2 || limit.nil?
113
+
114
+ events = context.audit_events(limit: limit)
115
+ return output.puts('No audit events recorded.') if events.empty?
116
+
117
+ events.each do |event|
118
+ output.puts "#{event.id} #{event.created_at} #{event.command} #{event.entity_type}=#{event.entity_name}"
119
+ end
120
+ end
121
+
122
+ def audit_limit(value)
123
+ return 10 if value.nil?
124
+
125
+ parsed = Integer(value)
126
+ return parsed if parsed.positive?
127
+
128
+ nil
129
+ rescue ArgumentError
130
+ nil
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'json'
5
+
6
+ require_relative 'formatter'
7
+
8
+ module Moose
9
+ module Inventory
10
+ module Cli
11
+ # Database lifecycle commands.
12
+ class Db < Thor
13
+ desc 'status', 'Show database lifecycle status'
14
+ def status
15
+ render_status(Moose::Inventory::DB.status)
16
+ end
17
+
18
+ desc 'doctor', 'Check database schema state'
19
+ def doctor
20
+ status = Moose::Inventory::DB.status
21
+ missing = status[:tables].reject { |_name, present| present }.keys
22
+ if missing.empty? && status[:schema_version] == status[:expected_schema_version]
23
+ puts 'Database doctor found no issues.'
24
+ return
25
+ end
26
+
27
+ puts 'Database doctor found issue(s):'
28
+ puts "- Missing tables: #{missing.join(', ')}" unless missing.empty?
29
+ if status[:schema_version] != status[:expected_schema_version]
30
+ puts "- Schema version is #{status[:schema_version].inspect}; expected #{status[:expected_schema_version]}."
31
+ end
32
+ exit(1)
33
+ end
34
+
35
+ desc 'migrate', 'Create missing schema tables and record current schema version'
36
+ def migrate
37
+ status = Moose::Inventory::DB.migrate!
38
+ puts "Database schema is at version #{status[:schema_version]}."
39
+ end
40
+
41
+ desc 'backup FILE', 'Back up the configured sqlite3 database file'
42
+ def backup(file)
43
+ destination = Moose::Inventory::DB.backup(file)
44
+ puts "Backed up database to #{destination}."
45
+ rescue Moose::Inventory::DB.exceptions[:moose] => e
46
+ abort("ERROR: #{e.message}")
47
+ end
48
+
49
+ private
50
+
51
+ def render_status(status)
52
+ puts "Adapter: #{status[:adapter]}"
53
+ puts "Schema version: #{status[:schema_version] || 'unknown'}"
54
+ puts "Expected schema version: #{status[:expected_schema_version]}"
55
+ puts "SQLite file: #{status[:sqlite_file]}" unless status[:sqlite_file].nil?
56
+ puts 'Tables:'
57
+ status[:tables].each do |name, present|
58
+ puts "- #{name}: #{present ? 'present' : 'missing'}"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../operations/query_inventory'
4
+
5
+ module Moose
6
+ module Inventory
7
+ module Cli
8
+ # Small factory for command-side operations and query wrappers.
9
+ class Factory
10
+ def initialize(context:)
11
+ @context = context
12
+ end
13
+
14
+ def operation(operation_class, **)
15
+ operation_class.new(context: context, **)
16
+ end
17
+
18
+ def query_inventory
19
+ @query_inventory ||= Moose::Inventory::Operations::QueryInventory.new(context: context)
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :context
25
+ end
26
+ end
27
+ end
28
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
2
4
  require 'yaml'
3
5
  require 'indentation'
@@ -5,24 +7,18 @@ require 'indentation'
5
7
  module Moose
6
8
  module Inventory
7
9
  module Cli
8
- ##
9
- # TODO: Documentation
10
10
  module Formatter
11
11
  # rubocop:disable Style/ModuleFunction
12
12
  extend self
13
13
  # rubocop:enable Style/ModuleFunction
14
14
 
15
- def self.dump(arg, format = nil)
15
+ def self.dump(arg, format = 'json')
16
16
  out(arg, format)
17
17
  end
18
18
 
19
- def self.out(arg, format = nil)
19
+ def self.out(arg, format = 'json')
20
20
  return if arg.nil?
21
21
 
22
- if format.nil?
23
- format = Moose::Inventory::Config._confopts[:format].downcase
24
- end
25
-
26
22
  case format
27
23
  when 'yaml', 'y'
28
24
  $stdout.puts arg.to_yaml
@@ -50,7 +46,7 @@ module Moose
50
46
  when 'STDOUT'
51
47
  $stdout.puts msg.indent(indent)
52
48
  when 'STDERR'
53
- $stderr.puts msg.indent(indent)
49
+ $stderr.print("#{msg.indent(indent)}\n")
54
50
  else
55
51
  abort("Output stream '#{stream}' is not known.")
56
52
  end
@@ -67,12 +63,12 @@ module Moose
67
63
  end
68
64
  end
69
65
 
70
- def info(_indent, _msg, stream = 'STDOUT')
66
+ def info(indent, msg, stream = 'STDOUT')
71
67
  case stream
72
68
  when 'STDOUT'
73
- $stdout.print 'INFO: {msg}'
69
+ $stdout.print "INFO: #{msg}".indent(indent)
74
70
  when 'STDERR'
75
- $stderr.print 'INFO: {msg}'
71
+ $stderr.print "INFO: #{msg}".indent(indent)
76
72
  else
77
73
  abort("Output stream '#{stream}' is not known.")
78
74
  end
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'thor'
2
- require_relative './formatter.rb'
4
+ require_relative 'formatter'
5
+ require_relative 'helpers'
3
6
 
4
7
  module Moose
5
8
  module Inventory
@@ -7,6 +10,8 @@ module Moose
7
10
  ##
8
11
  # Class implementing the "group" methods of the CLI
9
12
  class Group < Thor
13
+ include Moose::Inventory::Cli::Helpers
14
+
10
15
  require_relative 'group_add'
11
16
  require_relative 'group_get'
12
17
  require_relative 'group_list'
@@ -18,6 +23,7 @@ module Moose
18
23
  require_relative 'group_addvar'
19
24
  require_relative 'group_listvars'
20
25
  require_relative 'group_rmvar'
26
+ require_relative 'group_tags'
21
27
  end
22
28
  end
23
29
  end