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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +15 -1
- data/.github/workflows/release.yml +60 -0
- data/.gitignore +2 -1
- data/.gitleaks.toml +9 -0
- data/.rubocop.yml +49 -0
- data/BACKLOG.md +752 -24
- data/Gemfile +2 -0
- data/Gemfile.lock +36 -1
- data/README.md +340 -44
- data/Rakefile +2 -0
- data/bin/moose-inventory +2 -1
- data/docs/architecture/architecture-and-trust-boundaries.md +444 -0
- data/docs/compatibility/cli-output-compatibility.md +76 -0
- data/docs/governance/approval-register.md +37 -0
- data/docs/maintenance/database-backup-restore-guidance.md +162 -0
- data/docs/maintenance/package-maintenance-and-agent-boundaries.md +260 -0
- data/docs/process/conformance-gap-analysis-2026-05-28.md +192 -0
- data/docs/product/product-brief.md +161 -0
- data/docs/product/requirements-baseline.md +477 -0
- data/docs/qa/qa-documentation-and-release-gates.md +283 -0
- data/docs/release/package-provenance-hardening.md +126 -0
- data/docs/release/publishing.md +54 -50
- data/docs/release/release-environment-protection.md +70 -0
- data/docs/release/release-readiness.md +37 -4
- data/docs/security/accepted-risk-register.md +84 -0
- data/docs/security/security-privacy-process.md +287 -0
- data/docs/security-audit-2026-05-26-rerun.md +75 -0
- data/docs/security-audit-2026-05-26.md +63 -0
- data/docs/ux/cli-workflow-notes.md +287 -0
- data/examples/ansible/ansible.cfg +3 -0
- data/examples/ansible/inventory/moose_inventory.yml +5 -0
- data/examples/ansible/inventory_plugins/moose_inventory.py +100 -0
- data/examples/ci/README.md +16 -0
- data/examples/ci/github-actions/inventory-review.yml +38 -0
- data/examples/ci/inventory/example-snapshot.yml +19 -0
- data/examples/ci/scripts/validate-inventory-snapshot.sh +30 -0
- data/lib/moose_inventory/cli/application.rb +133 -5
- data/lib/moose_inventory/cli/association_rendering.rb +74 -0
- data/lib/moose_inventory/cli/association_rendering_support.rb +89 -0
- data/lib/moose_inventory/cli/audit.rb +62 -0
- data/lib/moose_inventory/cli/audit_recording.rb +40 -0
- data/lib/moose_inventory/cli/child_relation_rendering.rb +110 -0
- data/lib/moose_inventory/cli/console.rb +135 -0
- data/lib/moose_inventory/cli/db.rb +64 -0
- data/lib/moose_inventory/cli/factory.rb +28 -0
- data/lib/moose_inventory/cli/formatter.rb +8 -12
- data/lib/moose_inventory/cli/group.rb +7 -1
- data/lib/moose_inventory/cli/group_add.rb +91 -73
- data/lib/moose_inventory/cli/group_addchild.rb +41 -66
- data/lib/moose_inventory/cli/group_addhost.rb +33 -71
- data/lib/moose_inventory/cli/group_addvar.rb +27 -47
- data/lib/moose_inventory/cli/group_get.rb +8 -42
- data/lib/moose_inventory/cli/group_list.rb +7 -40
- data/lib/moose_inventory/cli/group_listvars.rb +9 -55
- data/lib/moose_inventory/cli/group_rm.rb +105 -73
- data/lib/moose_inventory/cli/group_rmchild.rb +47 -57
- data/lib/moose_inventory/cli/group_rmhost.rb +34 -61
- data/lib/moose_inventory/cli/group_rmvar.rb +30 -41
- data/lib/moose_inventory/cli/group_tags.rb +33 -0
- data/lib/moose_inventory/cli/helpers.rb +143 -0
- data/lib/moose_inventory/cli/host.rb +8 -2
- data/lib/moose_inventory/cli/host_add.rb +91 -66
- data/lib/moose_inventory/cli/host_addgroup.rb +39 -66
- data/lib/moose_inventory/cli/host_addvar.rb +28 -52
- data/lib/moose_inventory/cli/host_get.rb +9 -37
- data/lib/moose_inventory/cli/host_list.rb +24 -21
- data/lib/moose_inventory/cli/host_listvars.rb +9 -62
- data/lib/moose_inventory/cli/host_rm.rb +60 -42
- data/lib/moose_inventory/cli/host_rmgroup.rb +39 -55
- data/lib/moose_inventory/cli/host_rmvar.rb +31 -45
- data/lib/moose_inventory/cli/host_tags.rb +33 -0
- data/lib/moose_inventory/cli/listvars_support.rb +55 -0
- data/lib/moose_inventory/cli/plan_rendering.rb +50 -0
- data/lib/moose_inventory/cli/relation_transaction_support.rb +51 -0
- data/lib/moose_inventory/cli/tag_support.rb +97 -0
- data/lib/moose_inventory/cli/variable_rendering.rb +67 -0
- data/lib/moose_inventory/config/config.rb +185 -108
- data/lib/moose_inventory/db/db.rb +188 -193
- data/lib/moose_inventory/db/exceptions.rb +6 -3
- data/lib/moose_inventory/db/models.rb +16 -0
- data/lib/moose_inventory/db/schema_migrations.rb +248 -0
- data/lib/moose_inventory/inventory_context.rb +116 -0
- data/lib/moose_inventory/operations/add_associations.rb +131 -0
- data/lib/moose_inventory/operations/add_groups.rb +123 -0
- data/lib/moose_inventory/operations/add_hosts.rb +123 -0
- data/lib/moose_inventory/operations/add_variables.rb +77 -0
- data/lib/moose_inventory/operations/entity_variable_operation_support.rb +46 -0
- data/lib/moose_inventory/operations/group_child_relations.rb +125 -0
- data/lib/moose_inventory/operations/group_cleanup.rb +70 -0
- data/lib/moose_inventory/operations/import_inventory_snapshot.rb +41 -0
- data/lib/moose_inventory/operations/inventory_doctor.rb +172 -0
- data/lib/moose_inventory/operations/inventory_snapshot.rb +60 -0
- data/lib/moose_inventory/operations/inventory_snapshot_applier.rb +112 -0
- data/lib/moose_inventory/operations/inventory_snapshot_preview.rb +174 -0
- data/lib/moose_inventory/operations/inventory_snapshot_validator.rb +134 -0
- data/lib/moose_inventory/operations/operation_event_support.rb +27 -0
- data/lib/moose_inventory/operations/query_inventory/base_query.rb +24 -0
- data/lib/moose_inventory/operations/query_inventory/group_queries.rb +86 -0
- data/lib/moose_inventory/operations/query_inventory/host_queries.rb +106 -0
- data/lib/moose_inventory/operations/query_inventory.rb +47 -0
- data/lib/moose_inventory/operations/remove_associations.rb +113 -0
- data/lib/moose_inventory/operations/remove_groups.rb +79 -0
- data/lib/moose_inventory/operations/remove_hosts.rb +68 -0
- data/lib/moose_inventory/operations/remove_variables.rb +67 -0
- data/lib/moose_inventory/runtime_options.rb +31 -0
- data/lib/moose_inventory/version.rb +3 -1
- data/lib/moose_inventory.rb +10 -7
- data/moose-inventory.gemspec +22 -35
- data/scripts/check.sh +3 -0
- data/scripts/ci/check_generated_artifacts.sh +41 -0
- data/scripts/ci/check_permissions.sh +5 -0
- data/scripts/ci/check_rubocop.sh +33 -0
- data/scripts/ci/check_secrets.sh +26 -0
- data/scripts/ci/check_security.sh +18 -0
- data/scripts/ci/install_security_tools.sh +47 -0
- data/scripts/files.rb +5 -4
- data/scripts/install_dependencies.sh +2 -0
- data/spec/examples/ci_examples_spec.rb +37 -0
- data/spec/lib/moose_inventory/ansible_plugin_examples_spec.rb +29 -0
- data/spec/lib/moose_inventory/cli/application_doctor_spec.rb +50 -0
- data/spec/lib/moose_inventory/cli/application_import_export_spec.rb +100 -0
- data/spec/lib/moose_inventory/cli/application_spec.rb +25 -15
- data/spec/lib/moose_inventory/cli/audit_spec.rb +56 -0
- data/spec/lib/moose_inventory/cli/cli_spec.rb +15 -19
- data/spec/lib/moose_inventory/cli/console_spec.rb +98 -0
- data/spec/lib/moose_inventory/cli/factory_spec.rb +27 -0
- data/spec/lib/moose_inventory/cli/formatter_spec.rb +95 -3
- data/spec/lib/moose_inventory/cli/group_add_spec.rb +140 -116
- data/spec/lib/moose_inventory/cli/group_addchild_spec.rb +89 -35
- data/spec/lib/moose_inventory/cli/group_addhost_spec.rb +81 -84
- data/spec/lib/moose_inventory/cli/group_addvar_spec.rb +65 -68
- data/spec/lib/moose_inventory/cli/group_get_spec.rb +17 -33
- data/spec/lib/moose_inventory/cli/group_list_spec.rb +16 -38
- data/spec/lib/moose_inventory/cli/group_listvar_spec.rb +33 -40
- data/spec/lib/moose_inventory/cli/group_rm_spec.rb +165 -85
- data/spec/lib/moose_inventory/cli/group_rmchild_spec.rb +100 -30
- data/spec/lib/moose_inventory/cli/group_rmhost_spec.rb +76 -78
- data/spec/lib/moose_inventory/cli/group_rmvar_spec.rb +57 -63
- data/spec/lib/moose_inventory/cli/group_spec.rb +2 -0
- data/spec/lib/moose_inventory/cli/helpers_spec.rb +146 -0
- data/spec/lib/moose_inventory/cli/host_add_spec.rb +170 -116
- data/spec/lib/moose_inventory/cli/host_addgroup_spec.rb +100 -83
- data/spec/lib/moose_inventory/cli/host_addvar_spec.rb +92 -74
- data/spec/lib/moose_inventory/cli/host_get_spec.rb +14 -33
- data/spec/lib/moose_inventory/cli/host_list_spec.rb +41 -33
- data/spec/lib/moose_inventory/cli/host_listvar_spec.rb +45 -53
- data/spec/lib/moose_inventory/cli/host_rm_spec.rb +66 -48
- data/spec/lib/moose_inventory/cli/host_rmgroup_spec.rb +73 -83
- data/spec/lib/moose_inventory/cli/host_rmvar_spec.rb +56 -63
- data/spec/lib/moose_inventory/cli/host_spec.rb +2 -0
- data/spec/lib/moose_inventory/cli/tags_spec.rb +81 -0
- data/spec/lib/moose_inventory/config/config_spec.rb +41 -3
- data/spec/lib/moose_inventory/db/db_spec.rb +551 -29
- data/spec/lib/moose_inventory/db/exceptions_spec.rb +18 -0
- data/spec/lib/moose_inventory/db/models_spec.rb +7 -3
- data/spec/lib/moose_inventory/db_lifecycle_spec.rb +73 -0
- data/spec/lib/moose_inventory/inventory_context_spec.rb +10 -0
- data/spec/lib/moose_inventory/operations/add_associations_spec.rb +111 -0
- data/spec/lib/moose_inventory/operations/add_groups_spec.rb +80 -0
- data/spec/lib/moose_inventory/operations/add_hosts_spec.rb +82 -0
- data/spec/lib/moose_inventory/operations/add_variables_spec.rb +103 -0
- data/spec/lib/moose_inventory/operations/group_child_relations_spec.rb +122 -0
- data/spec/lib/moose_inventory/operations/import_inventory_snapshot_spec.rb +226 -0
- data/spec/lib/moose_inventory/operations/inventory_doctor_spec.rb +77 -0
- data/spec/lib/moose_inventory/operations/inventory_snapshot_spec.rb +50 -0
- data/spec/lib/moose_inventory/operations/operation_event_support_spec.rb +78 -0
- data/spec/lib/moose_inventory/operations/query_inventory_spec.rb +146 -0
- data/spec/lib/moose_inventory/operations/remove_associations_spec.rb +113 -0
- data/spec/lib/moose_inventory/operations/remove_groups_spec.rb +78 -0
- data/spec/lib/moose_inventory/operations/remove_hosts_spec.rb +55 -0
- data/spec/lib/moose_inventory/operations/remove_variables_spec.rb +83 -0
- data/spec/shared/shared_config_setup.rb +4 -3
- data/spec/spec_helper.rb +50 -40
- data/spec/support/cli_harness.rb +33 -0
- metadata +163 -35
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'operation_event_support'
|
|
4
|
+
|
|
5
|
+
module Moose
|
|
6
|
+
module Inventory
|
|
7
|
+
module Operations
|
|
8
|
+
##
|
|
9
|
+
# Adds hosts and their optional group associations.
|
|
10
|
+
#
|
|
11
|
+
# The operation mutates inventory state and returns structured events for
|
|
12
|
+
# the CLI adapter to render. Keeping output out of this class makes the
|
|
13
|
+
# inventory behavior easier to exercise without binding every domain test
|
|
14
|
+
# to progress text.
|
|
15
|
+
class AddHosts
|
|
16
|
+
AUTOMATIC_GROUP = 'ungrouped'
|
|
17
|
+
include OperationEventSupport
|
|
18
|
+
|
|
19
|
+
def initialize(context:)
|
|
20
|
+
@context = context
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(names:, groups:, dry_run: false)
|
|
24
|
+
events = []
|
|
25
|
+
@dry_run = dry_run
|
|
26
|
+
|
|
27
|
+
if dry_run
|
|
28
|
+
names.each do |name|
|
|
29
|
+
add_host(name, groups, events)
|
|
30
|
+
end
|
|
31
|
+
emit(events, :dry_run_summary)
|
|
32
|
+
return operation_result(events: events)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
context.transaction do
|
|
36
|
+
names.each do |name|
|
|
37
|
+
add_host(name, groups, events)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
operation_result(events: events)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
attr_reader :context, :dry_run
|
|
46
|
+
|
|
47
|
+
def add_host(name, groups, events)
|
|
48
|
+
emit(events, :host_started, name: name)
|
|
49
|
+
host, groups_dataset = create_or_find_host(name, events)
|
|
50
|
+
|
|
51
|
+
groups.each do |group_name|
|
|
52
|
+
add_group_association(host, name, group_name, groups_dataset, events)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
add_automatic_group_if_needed(host, name, groups, groups_dataset, events)
|
|
56
|
+
emit(events, :host_complete)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def create_or_find_host(name, events)
|
|
60
|
+
emit(events, :creating_host, name: name)
|
|
61
|
+
host = context.find_host(name)
|
|
62
|
+
groups_dataset = nil
|
|
63
|
+
|
|
64
|
+
if host.nil?
|
|
65
|
+
host = context.create_host(name) unless dry_run
|
|
66
|
+
else
|
|
67
|
+
emit(events, :host_exists, name: name)
|
|
68
|
+
groups_dataset = host.groups_dataset
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
emit(events, :ok, indent: 4)
|
|
72
|
+
[host, groups_dataset]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def add_group_association(host, host_name, group_name, groups_dataset, events)
|
|
76
|
+
return if group_name.nil? || group_name.empty?
|
|
77
|
+
|
|
78
|
+
emit(events, :adding_association, host: host_name, group: group_name)
|
|
79
|
+
group = find_or_create_group(group_name, events)
|
|
80
|
+
|
|
81
|
+
if association_exists?(groups_dataset, group_name)
|
|
82
|
+
emit(events, :association_exists, host: host_name, group: group_name)
|
|
83
|
+
elsif !dry_run
|
|
84
|
+
host.add_group(group)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
emit(events, :ok, indent: 4)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def find_or_create_group(name, events)
|
|
91
|
+
group = context.find_group(name)
|
|
92
|
+
return group unless group.nil?
|
|
93
|
+
|
|
94
|
+
emit(events, :group_missing_created, name: name)
|
|
95
|
+
context.create_group(name) unless dry_run
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def add_automatic_group_if_needed(host, host_name, requested_groups, groups_dataset, events)
|
|
99
|
+
return unless automatic_group_needed?(host, requested_groups, groups_dataset)
|
|
100
|
+
|
|
101
|
+
emit(events, :adding_automatic_group, host: host_name, group: AUTOMATIC_GROUP)
|
|
102
|
+
host.add_group(automatic_group) unless dry_run
|
|
103
|
+
emit(events, :ok, indent: 4)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def automatic_group_needed?(host, requested_groups, groups_dataset)
|
|
107
|
+
return requested_groups.empty? && (host.nil? || groups_dataset.nil? || groups_dataset.none?) if dry_run
|
|
108
|
+
|
|
109
|
+
groups_dataset = host.groups_dataset
|
|
110
|
+
!groups_dataset.nil? && groups_dataset.none?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def automatic_group
|
|
114
|
+
context.automatic_group
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def association_exists?(dataset, name)
|
|
118
|
+
!dataset.nil? && !dataset[name: name].nil?
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'entity_variable_operation_support'
|
|
4
|
+
|
|
5
|
+
module Moose
|
|
6
|
+
module Inventory
|
|
7
|
+
module Operations
|
|
8
|
+
# Adds host/group variables and updates existing values when needed.
|
|
9
|
+
class AddVariables
|
|
10
|
+
include EntityVariableOperationSupport
|
|
11
|
+
|
|
12
|
+
def call(name:, vars:, dry_run: false)
|
|
13
|
+
@events = []
|
|
14
|
+
@dry_run = dry_run
|
|
15
|
+
|
|
16
|
+
emit(:entity_started, name: name)
|
|
17
|
+
emit(:retrieving_entity, name: name)
|
|
18
|
+
entity = find_entity(name)
|
|
19
|
+
raise_missing_entity(name) if entity.nil?
|
|
20
|
+
|
|
21
|
+
emit(:ok, indent: 4)
|
|
22
|
+
|
|
23
|
+
dataset = entity.public_send("#{entity_type}vars_dataset")
|
|
24
|
+
vars.each do |variable|
|
|
25
|
+
add_variable(entity, dataset, variable)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
emit(:entity_complete)
|
|
29
|
+
emit(:dry_run_summary) if dry_run
|
|
30
|
+
operation_result(events: events)
|
|
31
|
+
ensure
|
|
32
|
+
@events = nil
|
|
33
|
+
@dry_run = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :dry_run
|
|
39
|
+
|
|
40
|
+
def add_variable(entity, dataset, variable)
|
|
41
|
+
emit(:adding_variable, variable: variable)
|
|
42
|
+
key, value = parse_variable(variable)
|
|
43
|
+
|
|
44
|
+
existing = dataset[name: key]
|
|
45
|
+
if existing.nil?
|
|
46
|
+
unless dry_run
|
|
47
|
+
record = context.create_variable(entity_type, name: key, value: value)
|
|
48
|
+
entity.public_send("add_#{entity_type}var", record)
|
|
49
|
+
end
|
|
50
|
+
elsif existing[:value] != value
|
|
51
|
+
emit(:updating_existing_variable)
|
|
52
|
+
unless dry_run
|
|
53
|
+
update = context.find_variable(entity_type, existing[:id])
|
|
54
|
+
update[:value] = value
|
|
55
|
+
update.save
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
emit(:ok, indent: 4)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_variable(variable)
|
|
63
|
+
parts = variable.split('=')
|
|
64
|
+
invalid = variable.start_with?('=') || variable.end_with?('=') || parts.length != 2
|
|
65
|
+
raise_invalid_variable(variable) if invalid
|
|
66
|
+
|
|
67
|
+
parts
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def raise_invalid_variable(variable)
|
|
71
|
+
raise context.moose_exception_class,
|
|
72
|
+
"Incorrect format in '{#{variable}}'. Expected 'key=value'."
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'operation_event_support'
|
|
4
|
+
|
|
5
|
+
module Moose
|
|
6
|
+
module Inventory
|
|
7
|
+
module Operations
|
|
8
|
+
module EntityVariableOperationSupport
|
|
9
|
+
include OperationEventSupport
|
|
10
|
+
|
|
11
|
+
def initialize(context:, entity_type:, emitter: nil)
|
|
12
|
+
@context = context
|
|
13
|
+
@entity_type = entity_type
|
|
14
|
+
@emitter = emitter
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :context, :emitter, :entity_type, :events
|
|
20
|
+
|
|
21
|
+
def find_entity(name)
|
|
22
|
+
case entity_type
|
|
23
|
+
when :host
|
|
24
|
+
context.find_host(name)
|
|
25
|
+
when :group
|
|
26
|
+
context.find_group(name)
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError, "Unsupported entity type: #{entity_type.inspect}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def raise_missing_entity(name)
|
|
33
|
+
label = entity_type == :host ? 'host' : 'group'
|
|
34
|
+
raise context.moose_exception_class, "The #{label} '#{name}' does not exist."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def emit(type, payload = {})
|
|
38
|
+
event = build_event(type, payload)
|
|
39
|
+
events << event
|
|
40
|
+
emitter&.call(event)
|
|
41
|
+
event
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'operation_event_support'
|
|
4
|
+
|
|
5
|
+
require_relative 'group_cleanup'
|
|
6
|
+
|
|
7
|
+
module Moose
|
|
8
|
+
module Inventory
|
|
9
|
+
module Operations
|
|
10
|
+
class GroupChildRelations
|
|
11
|
+
include OperationEventSupport
|
|
12
|
+
|
|
13
|
+
def initialize(context:)
|
|
14
|
+
@context = context
|
|
15
|
+
@cleanup = Moose::Inventory::Operations::GroupCleanup.new(
|
|
16
|
+
context: context,
|
|
17
|
+
emitter: method(:emit)
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add_children(parent_group:, parent_name:, child_names:, dry_run: false)
|
|
22
|
+
events = []
|
|
23
|
+
warning_count = 0
|
|
24
|
+
children_dataset = parent_group.children_dataset
|
|
25
|
+
@dry_run = dry_run
|
|
26
|
+
|
|
27
|
+
child_names.each do |child_name|
|
|
28
|
+
next if child_name.nil? || child_name.empty?
|
|
29
|
+
|
|
30
|
+
warning_count += add_child(parent_group, parent_name, child_name, children_dataset, events)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
emit(events, :dry_run_summary) if dry_run
|
|
34
|
+
|
|
35
|
+
operation_result(events: events, warning_count: warning_count)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def remove_children(parent_group:, parent_name:, child_names:, delete_orphans: false, dry_run: false)
|
|
39
|
+
events = []
|
|
40
|
+
warning_count = 0
|
|
41
|
+
children_dataset = parent_group.children_dataset
|
|
42
|
+
@dry_run = dry_run
|
|
43
|
+
cleanup.dry_run = dry_run
|
|
44
|
+
|
|
45
|
+
child_names.each do |child_name|
|
|
46
|
+
next if child_name.nil? || child_name.empty?
|
|
47
|
+
|
|
48
|
+
warning_count += remove_child(
|
|
49
|
+
{
|
|
50
|
+
parent_group: parent_group,
|
|
51
|
+
parent_name: parent_name,
|
|
52
|
+
child_name: child_name,
|
|
53
|
+
children_dataset: children_dataset,
|
|
54
|
+
events: events,
|
|
55
|
+
delete_orphans: delete_orphans,
|
|
56
|
+
dry_run: dry_run
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
emit(events, :dry_run_summary) if dry_run
|
|
62
|
+
|
|
63
|
+
operation_result(events: events, warning_count: warning_count)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
attr_reader :cleanup, :context, :dry_run
|
|
69
|
+
|
|
70
|
+
def add_child(parent_group, parent_name, child_name, children_dataset, events)
|
|
71
|
+
emit(events, :adding_child_association, parent: parent_name, child: child_name)
|
|
72
|
+
|
|
73
|
+
if association_exists?(children_dataset, child_name)
|
|
74
|
+
emit(events, :child_association_exists, parent: parent_name, child: child_name)
|
|
75
|
+
emit(events, :already_exists_skipping, indent: 4)
|
|
76
|
+
emit(events, :ok, indent: 4)
|
|
77
|
+
return 1
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
child_group = context.find_group(child_name)
|
|
81
|
+
warning_count = 0
|
|
82
|
+
if child_group.nil?
|
|
83
|
+
emit(events, :child_group_missing, name: child_name)
|
|
84
|
+
emit(events, :child_group_creating_now, name: child_name)
|
|
85
|
+
child_group = context.create_group(child_name) unless dry_run
|
|
86
|
+
emit(events, :ok, indent: 6)
|
|
87
|
+
warning_count = 1
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
parent_group.add_child(child_group) unless dry_run
|
|
91
|
+
emit(events, :ok, indent: 4)
|
|
92
|
+
warning_count
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def remove_child(input)
|
|
96
|
+
emit(
|
|
97
|
+
input[:events],
|
|
98
|
+
:removing_child_association,
|
|
99
|
+
parent: input[:parent_name],
|
|
100
|
+
child: input[:child_name]
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
unless association_exists?(input[:children_dataset], input[:child_name])
|
|
104
|
+
emit(input[:events], :child_association_missing, parent: input[:parent_name], child: input[:child_name])
|
|
105
|
+
emit(input[:events], :missing_skipping, indent: 4)
|
|
106
|
+
emit(input[:events], :ok, indent: 4)
|
|
107
|
+
return 1
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
child_group = context.find_group(input[:child_name])
|
|
111
|
+
input[:parent_group].remove_child(child_group) unless input[:dry_run]
|
|
112
|
+
emit(input[:events], :ok, indent: 4)
|
|
113
|
+
if input[:delete_orphans]
|
|
114
|
+
cleanup.delete_orphaned_group(child_group, input[:events], ignored_parent: input[:parent_group])
|
|
115
|
+
end
|
|
116
|
+
0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def association_exists?(dataset, name)
|
|
120
|
+
!dataset.nil? && !dataset[name: name].nil?
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'operation_event_support'
|
|
4
|
+
|
|
5
|
+
module Moose
|
|
6
|
+
module Inventory
|
|
7
|
+
module Operations
|
|
8
|
+
# Recursively cleans up orphaned groups and their dependent relations.
|
|
9
|
+
class GroupCleanup
|
|
10
|
+
include OperationEventSupport
|
|
11
|
+
|
|
12
|
+
AUTOMATIC_GROUP = 'ungrouped'
|
|
13
|
+
|
|
14
|
+
attr_accessor :dry_run
|
|
15
|
+
|
|
16
|
+
def initialize(context:, emitter:)
|
|
17
|
+
@context = context
|
|
18
|
+
@emitter = emitter
|
|
19
|
+
@dry_run = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def delete_orphaned_group(group, events, ignored_parent: nil)
|
|
23
|
+
return if group.name == AUTOMATIC_GROUP
|
|
24
|
+
return unless orphaned_after_planned_removal?(group, ignored_parent)
|
|
25
|
+
|
|
26
|
+
emit(events, :recursively_delete_orphaned_group, name: group.name)
|
|
27
|
+
group.children_dataset.each do |child|
|
|
28
|
+
emit(events, :removing_recursive_child_association, parent: group.name, child: child.name)
|
|
29
|
+
group.remove_child(child) unless dry_run
|
|
30
|
+
emit(events, :ok, indent: 6)
|
|
31
|
+
delete_orphaned_group(child, events, ignored_parent: group)
|
|
32
|
+
end
|
|
33
|
+
destroy_group(group, events, indent: 4)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def destroy_group(group, events, indent:)
|
|
37
|
+
group.hosts_dataset.each do |host|
|
|
38
|
+
next unless host.groups_dataset.one?
|
|
39
|
+
|
|
40
|
+
emit(events, :adding_automatic_group_to_host, host: host[:name], indent: indent)
|
|
41
|
+
host.add_group(context.automatic_group) unless dry_run
|
|
42
|
+
emit(events, :ok, indent: indent + 2)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
emit(events, :destroying_group, name: group.name, indent: indent)
|
|
46
|
+
unless dry_run
|
|
47
|
+
group.remove_all_groupvars
|
|
48
|
+
group.remove_all_hosts
|
|
49
|
+
group.destroy
|
|
50
|
+
end
|
|
51
|
+
emit(events, :ok, indent: indent + 2)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :context, :emitter
|
|
57
|
+
|
|
58
|
+
def orphaned_after_planned_removal?(group, ignored_parent)
|
|
59
|
+
group.parents_dataset.none? do |parent|
|
|
60
|
+
!ignored_parent || parent.name != ignored_parent.name
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def emit(events, type, payload = {})
|
|
65
|
+
emitter.call(events, type, payload)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'inventory_snapshot_applier'
|
|
4
|
+
require_relative 'inventory_snapshot_preview'
|
|
5
|
+
require_relative 'inventory_snapshot_validator'
|
|
6
|
+
|
|
7
|
+
module Moose
|
|
8
|
+
module Inventory
|
|
9
|
+
module Operations
|
|
10
|
+
# Validates and imports a portable inventory snapshot.
|
|
11
|
+
class ImportInventorySnapshot
|
|
12
|
+
Result = InventorySnapshotApplier::Result
|
|
13
|
+
|
|
14
|
+
def initialize(context:)
|
|
15
|
+
@context = context
|
|
16
|
+
@validator = InventorySnapshotValidator.new(context: context)
|
|
17
|
+
@applier = InventorySnapshotApplier.new(context: context)
|
|
18
|
+
@previewer = InventorySnapshotPreview.new(context: context)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(snapshot:)
|
|
22
|
+
normalized = validator.call(snapshot: snapshot)
|
|
23
|
+
|
|
24
|
+
context.transaction do
|
|
25
|
+
applier.call(snapshot: normalized)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def preview(snapshot:)
|
|
30
|
+
normalized = validator.call(snapshot: snapshot)
|
|
31
|
+
|
|
32
|
+
previewer.call(snapshot: normalized)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :context, :validator, :applier, :previewer
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ClassLength
|
|
4
|
+
module Moose
|
|
5
|
+
module Inventory
|
|
6
|
+
module Operations
|
|
7
|
+
# Runs read-only inventory health checks for humans and CI.
|
|
8
|
+
class InventoryDoctor
|
|
9
|
+
AUTOMATIC_GROUP = 'ungrouped'
|
|
10
|
+
|
|
11
|
+
def initialize(context:, config: Moose::Inventory::Config)
|
|
12
|
+
@context = context
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
issues = []
|
|
18
|
+
issues.concat(check_database_config)
|
|
19
|
+
issues.concat(check_plaintext_password_config)
|
|
20
|
+
issues.concat(check_hosts_only_in_automatic_group)
|
|
21
|
+
issues.concat(check_orphaned_groups)
|
|
22
|
+
issues.concat(check_empty_groups)
|
|
23
|
+
issues.concat(check_duplicateish_names)
|
|
24
|
+
issues.concat(check_invalid_variables)
|
|
25
|
+
issues.concat(check_group_cycles)
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
ok: issues.empty?,
|
|
29
|
+
issue_count: issues.length,
|
|
30
|
+
issues: issues
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :context, :config
|
|
37
|
+
|
|
38
|
+
def check_database_config
|
|
39
|
+
settings = config.db_settings
|
|
40
|
+
return [issue('missing_db_config', 'error', 'Database configuration is missing.')] unless settings.is_a?(Hash)
|
|
41
|
+
return [] if settings[:adapter].to_s.strip != ''
|
|
42
|
+
|
|
43
|
+
[issue('missing_db_adapter', 'error', 'Database adapter is missing from configuration.')]
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
[issue('missing_db_config', 'error', "Database configuration could not be read: #{e.message}")]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def check_plaintext_password_config
|
|
49
|
+
settings = config.db_settings
|
|
50
|
+
return [] unless settings.is_a?(Hash) && settings.key?(:password)
|
|
51
|
+
|
|
52
|
+
[
|
|
53
|
+
issue('plaintext_password_config', 'warning',
|
|
54
|
+
'Database configuration uses plaintext password; prefer password_env.')
|
|
55
|
+
]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_hosts_only_in_automatic_group
|
|
59
|
+
context.all_hosts.filter_map do |host|
|
|
60
|
+
groups = host.groups_dataset.map(:name)
|
|
61
|
+
next unless groups == [AUTOMATIC_GROUP]
|
|
62
|
+
|
|
63
|
+
issue('host_only_in_ungrouped', 'warning', "Host '#{host.name}' is only in automatic group 'ungrouped'.",
|
|
64
|
+
subject: host.name)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_orphaned_groups
|
|
69
|
+
context.all_groups.filter_map do |group|
|
|
70
|
+
next if group.name == AUTOMATIC_GROUP
|
|
71
|
+
next unless group.parents_dataset.empty? && group.hosts_dataset.empty?
|
|
72
|
+
|
|
73
|
+
issue('orphaned_group', 'warning', "Group '#{group.name}' has no parents and no hosts.",
|
|
74
|
+
subject: group.name)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def check_empty_groups
|
|
79
|
+
context.all_groups.filter_map do |group|
|
|
80
|
+
next if group.name == AUTOMATIC_GROUP
|
|
81
|
+
next unless group.hosts_dataset.empty? && group.children_dataset.empty? && group.groupvars_dataset.empty?
|
|
82
|
+
|
|
83
|
+
issue('empty_group', 'warning', "Group '#{group.name}' is empty.", subject: group.name)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_duplicateish_names
|
|
88
|
+
host_issues = duplicateish_issues(context.all_hosts.map(&:name), 'host')
|
|
89
|
+
group_issues = duplicateish_issues(context.all_groups.map(&:name), 'group')
|
|
90
|
+
host_issues + group_issues
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def duplicateish_issues(names, label)
|
|
94
|
+
names.group_by { |name| normalize_name(name) }.filter_map do |normalized, originals|
|
|
95
|
+
unique = originals.uniq
|
|
96
|
+
next if normalized.empty? || unique.length < 2
|
|
97
|
+
|
|
98
|
+
issue("duplicateish_#{label}_names", 'warning',
|
|
99
|
+
"#{label.capitalize} names look duplicate-ish: #{unique.sort.join(', ')}.", subject: unique.sort)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def normalize_name(name)
|
|
104
|
+
name.to_s.downcase.gsub(/[^a-z0-9]/, '')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def check_invalid_variables
|
|
108
|
+
host_var_issues = context.all_hosts.flat_map do |host|
|
|
109
|
+
invalid_variable_issues(host.hostvars_dataset, "host '#{host.name}'")
|
|
110
|
+
end
|
|
111
|
+
group_var_issues = context.all_groups.flat_map do |group|
|
|
112
|
+
invalid_variable_issues(group.groupvars_dataset, "group '#{group.name}'")
|
|
113
|
+
end
|
|
114
|
+
host_var_issues + group_var_issues
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def invalid_variable_issues(dataset, owner)
|
|
118
|
+
dataset.filter_map do |variable|
|
|
119
|
+
next unless variable.name.to_s.strip.empty? || variable.value.nil?
|
|
120
|
+
|
|
121
|
+
issue('invalid_variable_shape', 'error', "Variable on #{owner} has an empty name or nil value.",
|
|
122
|
+
subject: owner)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def check_group_cycles
|
|
127
|
+
groups = context.all_groups.to_h { |group| [group.name, group.children_dataset.map(:name)] }
|
|
128
|
+
visiting = {}
|
|
129
|
+
visited = {}
|
|
130
|
+
cycles = []
|
|
131
|
+
|
|
132
|
+
state = { groups: groups, visiting: visiting, visited: visited, cycles: cycles }
|
|
133
|
+
groups.each_key do |name|
|
|
134
|
+
visit_group(name, state, [])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
cycles.uniq.map do |cycle|
|
|
138
|
+
issue('circular_group_relationship', 'error', "Group hierarchy contains a cycle: #{cycle.join(' -> ')}.",
|
|
139
|
+
subject: cycle)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def visit_group(name, state, path)
|
|
144
|
+
return if state[:visited][name]
|
|
145
|
+
|
|
146
|
+
if state[:visiting][name]
|
|
147
|
+
cycle_start = path.index(name) || 0
|
|
148
|
+
state[:cycles] << (path[cycle_start..] + [name])
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
state[:visiting][name] = true
|
|
153
|
+
state[:groups].fetch(name, []).each do |child|
|
|
154
|
+
visit_group(child, state, path + [name])
|
|
155
|
+
end
|
|
156
|
+
state[:visiting].delete(name)
|
|
157
|
+
state[:visited][name] = true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def issue(id, severity, message, subject: nil)
|
|
161
|
+
{
|
|
162
|
+
id: id,
|
|
163
|
+
severity: severity,
|
|
164
|
+
message: message,
|
|
165
|
+
subject: subject
|
|
166
|
+
}.compact
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
# rubocop:enable Metrics/ClassLength
|