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,100 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright: (c) Russell Davies
3
+ # MIT License
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import subprocess
9
+
10
+ from ansible.errors import AnsibleParserError
11
+ from ansible.plugins.inventory import BaseInventoryPlugin
12
+
13
+ DOCUMENTATION = r'''
14
+ name: moose_inventory
15
+ plugin_type: inventory
16
+ short_description: Moose Inventory plugin
17
+ description:
18
+ - Loads inventory from the C(moose-inventory) command line tool.
19
+ - Keeps Moose Inventory configuration and environment selection in YAML instead of a shell shim.
20
+ options:
21
+ plugin:
22
+ description: Token that ensures this is a source file for this plugin.
23
+ required: true
24
+ choices: ['moose_inventory']
25
+ executable:
26
+ description: Moose Inventory executable path.
27
+ required: false
28
+ default: moose-inventory
29
+ config:
30
+ description: Moose Inventory config file passed to C(--config).
31
+ required: false
32
+ env:
33
+ description: Moose Inventory environment section passed to C(--env).
34
+ required: false
35
+ '''
36
+
37
+ EXAMPLES = r'''
38
+ plugin: moose_inventory
39
+ executable: moose-inventory
40
+ config: ./example.conf
41
+ env: dev
42
+ '''
43
+
44
+
45
+ class InventoryModule(BaseInventoryPlugin):
46
+ NAME = 'moose_inventory'
47
+
48
+ def verify_file(self, path):
49
+ return super().verify_file(path) and path.endswith(('moose_inventory.yml', 'moose_inventory.yaml'))
50
+
51
+ def parse(self, inventory, loader, path, cache=True):
52
+ super().parse(inventory, loader, path, cache=cache)
53
+ config = self._read_config_data(path)
54
+ executable = config.get('executable', 'moose-inventory')
55
+ moose_config = config.get('config')
56
+ env = config.get('env')
57
+
58
+ groups = self._run_moose(executable, moose_config, env, ['--ansible', 'group', 'list'])
59
+ hosts = self._run_moose(executable, moose_config, env, ['host', 'list'])
60
+
61
+ self._apply_groups(groups)
62
+ self._apply_hosts(hosts)
63
+
64
+ def _run_moose(self, executable, config, env, args):
65
+ command = [executable]
66
+ if config:
67
+ command.extend(['--config', config])
68
+ if env:
69
+ command.extend(['--env', env])
70
+ command.extend(args)
71
+
72
+ try:
73
+ completed = subprocess.run(command, check=True, capture_output=True, text=True)
74
+ except (OSError, subprocess.CalledProcessError) as error:
75
+ raise AnsibleParserError('moose-inventory command failed: %s' % error) from error
76
+
77
+ try:
78
+ return json.loads(completed.stdout or '{}')
79
+ except json.JSONDecodeError as error:
80
+ raise AnsibleParserError('moose-inventory returned invalid JSON: %s' % error) from error
81
+
82
+ def _apply_groups(self, groups):
83
+ for group_name, payload in groups.items():
84
+ self.inventory.add_group(group_name)
85
+ for host_name in payload.get('hosts', []):
86
+ self.inventory.add_host(host_name, group=group_name)
87
+ for child_name in payload.get('children', []):
88
+ self.inventory.add_group(child_name)
89
+ self.inventory.add_child(group_name, child_name)
90
+ for key, value in payload.get('vars', {}).items():
91
+ self.inventory.set_variable(group_name, key, value)
92
+
93
+ def _apply_hosts(self, hosts):
94
+ for host_name, payload in hosts.items():
95
+ self.inventory.add_host(host_name)
96
+ for group_name in payload.get('groups', []):
97
+ self.inventory.add_group(group_name)
98
+ self.inventory.add_host(host_name, group=group_name)
99
+ for key, value in payload.get('hostvars', {}).items():
100
+ self.inventory.set_variable(host_name, key, value)
@@ -0,0 +1,16 @@
1
+ # Moose Inventory CI/CD Examples
2
+
3
+ These examples show how to validate an inventory snapshot in CI without using production database credentials.
4
+
5
+ - `inventory/example-snapshot.yml` is a small review snapshot fixture.
6
+ - `scripts/validate-inventory-snapshot.sh` imports a snapshot into a temporary SQLite database, runs `doctor`, exports a canonical snapshot, lists hosts, and produces an Ansible-compatible inventory artifact.
7
+ - `github-actions/inventory-review.yml` is a copy/paste GitHub Actions workflow example. It is intentionally stored under `examples/` rather than `.github/workflows/` so projects can adapt it before enabling it.
8
+
9
+ The example writes artifacts to `tmp/inventory-ci-artifacts` by default:
10
+
11
+ - `doctor.txt`
12
+ - `inventory.yml`
13
+ - `hosts.json`
14
+ - `ansible-inventory.json`
15
+
16
+ Use this pattern for pull-request review gates before applying inventory changes to a shared or production Moose Inventory database.
@@ -0,0 +1,38 @@
1
+ name: Inventory review example
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ pull_request:
6
+ paths:
7
+ - 'inventory/**'
8
+ - 'examples/ci/inventory/**'
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ validate-inventory:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - name: Check out repository
18
+ uses: actions/checkout@v5
19
+
20
+ - name: Set up Ruby
21
+ uses: ruby/setup-ruby@v1
22
+ with:
23
+ ruby-version: '3.4'
24
+ bundler-cache: true
25
+
26
+ - name: Validate proposed inventory snapshot
27
+ env:
28
+ MOOSE_INVENTORY_CMD: bundle exec ruby -Ilib bin/moose-inventory
29
+ run: |
30
+ examples/ci/scripts/validate-inventory-snapshot.sh \
31
+ examples/ci/inventory/example-snapshot.yml \
32
+ tmp/inventory-ci-artifacts
33
+
34
+ - name: Upload inventory review artifacts
35
+ uses: actions/upload-artifact@v4
36
+ with:
37
+ name: inventory-review
38
+ path: tmp/inventory-ci-artifacts
@@ -0,0 +1,19 @@
1
+ ---
2
+ version: 1
3
+ hosts:
4
+ web01:
5
+ groups:
6
+ - web
7
+ tags:
8
+ - prod
9
+ - public-edge
10
+ vars:
11
+ os: fedora
12
+ owner: platform
13
+ groups:
14
+ web:
15
+ children: []
16
+ tags:
17
+ - frontend
18
+ vars:
19
+ role: frontend
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ snapshot="${1:-examples/ci/inventory/example-snapshot.yml}"
5
+ artifact_dir="${2:-tmp/inventory-ci-artifacts}"
6
+ moose_cmd="${MOOSE_INVENTORY_CMD:-moose-inventory}"
7
+ work_dir="$(mktemp -d)"
8
+ trap 'rm -rf "$work_dir"' EXIT
9
+
10
+ mkdir -p "$artifact_dir"
11
+ config_file="$work_dir/moose-inventory-ci.yml"
12
+ db_file="$work_dir/inventory.db"
13
+
14
+ cat > "$config_file" <<YAML
15
+ ---
16
+ general:
17
+ defaultenv: ci
18
+ ci:
19
+ db:
20
+ adapter: sqlite3
21
+ file: "$db_file"
22
+ YAML
23
+
24
+ $moose_cmd --config "$config_file" --env ci import "$snapshot"
25
+ $moose_cmd --config "$config_file" --env ci doctor > "$artifact_dir/doctor.txt"
26
+ $moose_cmd --config "$config_file" --env ci --format yaml export "$artifact_dir/inventory.yml"
27
+ $moose_cmd --config "$config_file" --env ci --format pjson host list > "$artifact_dir/hosts.json"
28
+ $moose_cmd --config "$config_file" --env ci --ansible group list > "$artifact_dir/ansible-inventory.json"
29
+
30
+ echo "Inventory CI artifacts written to $artifact_dir"
@@ -1,20 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
1
4
  require 'thor'
2
- require_relative '../version.rb'
3
- require_relative '../config/config.rb'
4
- require_relative './group.rb'
5
- require_relative './host.rb'
5
+ require_relative '../version'
6
+ require 'yaml'
7
+
8
+ require_relative '../config/config'
9
+ require_relative '../operations/import_inventory_snapshot'
10
+ require_relative '../operations/inventory_doctor'
11
+ require_relative '../operations/inventory_snapshot'
12
+ require_relative 'formatter'
13
+ require_relative 'helpers'
14
+ require_relative 'audit'
15
+ require_relative 'console'
16
+ require_relative 'db'
17
+ require_relative 'group'
18
+ require_relative 'host'
6
19
 
7
20
  module Moose
8
21
  module Inventory
9
22
  module Cli
10
23
  ##
11
- # TODO: Documentation
24
+ # Top-level Thor application for moose-inventory.
25
+ # rubocop:disable Metrics/ClassLength
12
26
  class Application < Thor
27
+ include Moose::Inventory::Cli::Helpers
28
+
13
29
  desc 'version', 'Get the code version'
14
30
  def version
15
31
  puts "Version #{Moose::Inventory::VERSION}"
16
32
  end
17
33
 
34
+ desc 'doctor', 'Run inventory health checks'
35
+ option :format, type: :string, desc: 'Emit doctor report as yaml|json|pjson'
36
+ def doctor
37
+ report = build_operation(Moose::Inventory::Operations::InventoryDoctor).call
38
+ render_doctor_report(report)
39
+ exit(1) unless report[:ok]
40
+ end
41
+
42
+ desc 'export [FILE]', 'Export a canonical inventory snapshot'
43
+ def export(file = nil)
44
+ snapshot = build_operation(Moose::Inventory::Operations::InventorySnapshot).export
45
+ output = serialize_snapshot(snapshot)
46
+
47
+ if file.nil?
48
+ puts output
49
+ else
50
+ File.write(file, output)
51
+ puts "Exported inventory snapshot to #{file}."
52
+ end
53
+ end
54
+
55
+ desc 'import FILE', 'Import and validate an inventory snapshot'
56
+ option :preview, type: :boolean, desc: 'Preview snapshot import changes without writing'
57
+ option :preview_format, type: :string, desc: 'Emit preview as yaml|json|pjson'
58
+ def import(file)
59
+ snapshot = YAML.safe_load_file(file, aliases: false)
60
+ operation = build_operation(Moose::Inventory::Operations::ImportInventorySnapshot)
61
+ return render_import_preview(operation.preview(snapshot: snapshot)) if options[:preview]
62
+
63
+ abort('ERROR: --preview-format requires --preview.') if options[:preview_format]
64
+
65
+ result = operation.call(snapshot: snapshot)
66
+ record_audit({ command: 'import', action: 'import', entity_type: 'inventory',
67
+ entity_names: file }, result: result)
68
+ puts "Imported inventory snapshot from #{file}."
69
+ puts "Created hosts: #{result.created_hosts}"
70
+ puts "Created groups: #{result.created_groups}"
71
+ puts "Variables changed: #{result.updated_variables}"
72
+ puts "Associations added: #{result.associations}"
73
+ rescue Psych::SyntaxError => e
74
+ abort("ERROR: Could not parse inventory snapshot '#{file}': #{e.message}")
75
+ rescue db.exceptions[:moose] => e
76
+ abort("ERROR: #{e.message}")
77
+ end
78
+
79
+ map 'db' => :database
80
+ desc 'audit ACTION', 'Inspect append-only inventory change history'
81
+ subcommand 'audit', Moose::Inventory::Cli::Audit
82
+
83
+ desc 'database ACTION', 'Inspect and manage database lifecycle state'
84
+ subcommand 'database', Moose::Inventory::Cli::Db
85
+
86
+ desc 'console', 'Open a small read-only inventory browsing console'
87
+ def console
88
+ Moose::Inventory::Cli::Console.new(context: inventory_context).run
89
+ end
90
+
18
91
  desc 'group ACTION',
19
92
  'Manipulate groups in the inventory. ' \
20
93
  'ACTION can be add, rm, get, list, addhost, rmhost, addchild, rmchild, addvar, rmvar'
@@ -24,7 +97,62 @@ module Moose
24
97
  'Manipulate hosts in the inventory. ' \
25
98
  'ACTION can be add, rm, get, list, addgroup, rmgroup, addvar, rmvar'
26
99
  subcommand 'host', Moose::Inventory::Cli::Host
100
+
101
+ private
102
+
103
+ def render_doctor_report(report)
104
+ if options[:format]
105
+ puts serialize_data(report, options[:format].downcase)
106
+ else
107
+ render_human_doctor_report(report)
108
+ end
109
+ end
110
+
111
+ def render_human_doctor_report(report)
112
+ if report[:ok]
113
+ puts 'Inventory doctor found no issues.'
114
+ return
115
+ end
116
+
117
+ puts "Inventory doctor found #{report[:issue_count]} issue(s):"
118
+ report[:issues].each do |entry|
119
+ puts "- [#{entry[:severity]}] #{entry[:id]}: #{entry[:message]}"
120
+ end
121
+ end
122
+
123
+ def render_import_preview(preview)
124
+ if options[:preview_format]
125
+ puts serialize_data(preview, options[:preview_format].downcase)
126
+ else
127
+ render_human_import_preview(preview)
128
+ end
129
+ end
130
+
131
+ def render_human_import_preview(preview)
132
+ puts 'Snapshot import preview. No changes applied.'
133
+ preview.fetch('summary').each do |key, value|
134
+ puts "#{key.tr('_', ' ').capitalize}: #{value}"
135
+ end
136
+ end
137
+
138
+ def serialize_snapshot(snapshot)
139
+ serialize_data(snapshot, output_format)
140
+ end
141
+
142
+ def serialize_data(data, format)
143
+ case format
144
+ when 'yaml', 'y'
145
+ data.to_yaml
146
+ when 'prettyjson', 'pjson', 'p'
147
+ JSON.pretty_generate(data)
148
+ when 'json', 'j'
149
+ data.to_json
150
+ else
151
+ abort("Output format '#{format}' is not yet supported.")
152
+ end
153
+ end
27
154
  end
155
+ # rubocop:enable Metrics/ClassLength
28
156
  end
29
157
  end
30
158
  end
@@ -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