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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moose
|
|
4
|
+
module Inventory
|
|
5
|
+
module Operations
|
|
6
|
+
# Builds a canonical, portable representation of the current inventory.
|
|
7
|
+
class InventorySnapshot
|
|
8
|
+
VERSION = 1
|
|
9
|
+
|
|
10
|
+
def initialize(context:)
|
|
11
|
+
@context = context
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def export
|
|
15
|
+
{
|
|
16
|
+
'version' => VERSION,
|
|
17
|
+
'hosts' => export_hosts,
|
|
18
|
+
'groups' => export_groups
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :context
|
|
25
|
+
|
|
26
|
+
def export_hosts
|
|
27
|
+
context.all_hosts.sort_by(&:name).to_h do |host|
|
|
28
|
+
[host.name, host_payload(host)]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def host_payload(host)
|
|
33
|
+
{
|
|
34
|
+
'groups' => host.groups_dataset.map(:name).sort,
|
|
35
|
+
'tags' => host.tags_dataset.map(:name).sort,
|
|
36
|
+
'vars' => variables_hash(host.hostvars_dataset)
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def export_groups
|
|
41
|
+
context.all_groups.sort_by(&:name).to_h do |group|
|
|
42
|
+
[group.name, group_payload(group)]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def group_payload(group)
|
|
47
|
+
{
|
|
48
|
+
'children' => group.children_dataset.map(:name).sort,
|
|
49
|
+
'tags' => group.tags_dataset.map(:name).sort,
|
|
50
|
+
'vars' => variables_hash(group.groupvars_dataset)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def variables_hash(dataset)
|
|
55
|
+
dataset.all.sort_by(&:name).to_h { |entry| [entry.name, entry.value] }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moose
|
|
4
|
+
module Inventory
|
|
5
|
+
module Operations
|
|
6
|
+
# Applies an already-validated inventory snapshot to the current inventory.
|
|
7
|
+
class InventorySnapshotApplier
|
|
8
|
+
Result = Struct.new(:created_hosts, :created_groups, :updated_variables, :associations, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(context:)
|
|
11
|
+
@context = context
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(snapshot:)
|
|
15
|
+
apply_snapshot(snapshot)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :context
|
|
21
|
+
|
|
22
|
+
def apply_snapshot(snapshot)
|
|
23
|
+
result = Result.new(created_hosts: 0, created_groups: 0, updated_variables: 0, associations: 0)
|
|
24
|
+
|
|
25
|
+
snapshot['groups'].each_key { |name| result.created_groups += 1 if ensure_group(name).nil? }
|
|
26
|
+
snapshot['hosts'].each_key { |name| result.created_hosts += 1 if ensure_host(name).nil? }
|
|
27
|
+
apply_group_payloads(snapshot['groups'], result)
|
|
28
|
+
apply_host_payloads(snapshot['hosts'], result)
|
|
29
|
+
result
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def apply_group_payloads(groups, result)
|
|
33
|
+
groups.each do |name, payload|
|
|
34
|
+
group = context.find_group(name)
|
|
35
|
+
result.updated_variables += apply_variables(group, :group, payload.fetch('vars', {}))
|
|
36
|
+
apply_tags(group, array_value(payload, 'tags'), result)
|
|
37
|
+
array_value(payload, 'children').each do |child_name|
|
|
38
|
+
child = context.find_group(child_name)
|
|
39
|
+
next unless group.children_dataset[name: child_name].nil?
|
|
40
|
+
|
|
41
|
+
group.add_child(child)
|
|
42
|
+
result.associations += 1
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def apply_host_payloads(hosts, result)
|
|
48
|
+
hosts.each do |name, payload|
|
|
49
|
+
host = context.find_host(name)
|
|
50
|
+
result.updated_variables += apply_variables(host, :host, payload.fetch('vars', {}))
|
|
51
|
+
apply_tags(host, array_value(payload, 'tags'), result)
|
|
52
|
+
array_value(payload, 'groups').each do |group_name|
|
|
53
|
+
group = context.find_group(group_name)
|
|
54
|
+
next unless host.groups_dataset[name: group_name].nil?
|
|
55
|
+
|
|
56
|
+
host.add_group(group)
|
|
57
|
+
result.associations += 1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def apply_tags(entity, tags, result)
|
|
63
|
+
context.normalize_tag_names(tags).each do |tag_name|
|
|
64
|
+
tag = context.find_or_create_tag(tag_name)
|
|
65
|
+
next unless entity.tags_dataset[name: tag_name].nil?
|
|
66
|
+
|
|
67
|
+
entity.add_tag(tag)
|
|
68
|
+
result.associations += 1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apply_variables(entity, type, variables)
|
|
73
|
+
variables.count do |name, value|
|
|
74
|
+
dataset = entity.public_send("#{type}vars_dataset")
|
|
75
|
+
existing = dataset[name: name]
|
|
76
|
+
if existing.nil?
|
|
77
|
+
record = context.create_variable(type, name: name, value: value.to_s)
|
|
78
|
+
entity.public_send("add_#{type}var", record)
|
|
79
|
+
true
|
|
80
|
+
elsif existing.value != value.to_s
|
|
81
|
+
existing.value = value.to_s
|
|
82
|
+
existing.save
|
|
83
|
+
true
|
|
84
|
+
else
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ensure_group(name)
|
|
91
|
+
existing = context.find_group(name)
|
|
92
|
+
return existing unless existing.nil?
|
|
93
|
+
|
|
94
|
+
context.create_group(name)
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def ensure_host(name)
|
|
99
|
+
existing = context.find_host(name)
|
|
100
|
+
return existing unless existing.nil?
|
|
101
|
+
|
|
102
|
+
context.create_host(name)
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def array_value(payload, key)
|
|
107
|
+
payload.fetch(key, []).map(&:to_s)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moose
|
|
4
|
+
module Inventory
|
|
5
|
+
module Operations
|
|
6
|
+
# Builds a non-mutating preview/diff for an already-validated inventory snapshot.
|
|
7
|
+
# rubocop:disable Metrics/ClassLength
|
|
8
|
+
class InventorySnapshotPreview
|
|
9
|
+
def initialize(context:)
|
|
10
|
+
@context = context
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(snapshot:)
|
|
14
|
+
preview = empty_preview
|
|
15
|
+
preview_existing_entities(snapshot, preview)
|
|
16
|
+
preview_group_payloads(snapshot.fetch('groups'), preview)
|
|
17
|
+
preview_host_payloads(snapshot.fetch('hosts'), preview)
|
|
18
|
+
preview_ignored_existing(snapshot, preview)
|
|
19
|
+
preview
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :context
|
|
25
|
+
|
|
26
|
+
def empty_preview
|
|
27
|
+
{
|
|
28
|
+
'schema_version' => 'snapshot-import-preview-v1',
|
|
29
|
+
'changes_applied' => false,
|
|
30
|
+
'summary' => {
|
|
31
|
+
'hosts_created' => 0,
|
|
32
|
+
'groups_created' => 0,
|
|
33
|
+
'variables_changed' => 0,
|
|
34
|
+
'associations_added' => 0,
|
|
35
|
+
'unchanged' => 0,
|
|
36
|
+
'ignored_existing_hosts' => 0,
|
|
37
|
+
'ignored_existing_groups' => 0,
|
|
38
|
+
'destructive_changes' => 0
|
|
39
|
+
},
|
|
40
|
+
'creates' => { 'hosts' => [], 'groups' => [] },
|
|
41
|
+
'updates' => { 'host_vars' => [], 'group_vars' => [] },
|
|
42
|
+
'associations' => { 'host_groups' => [], 'group_children' => [], 'tags' => [] },
|
|
43
|
+
'unchanged' => {
|
|
44
|
+
'hosts' => [], 'groups' => [], 'host_vars' => [], 'group_vars' => [], 'associations' => []
|
|
45
|
+
},
|
|
46
|
+
'ignored' => { 'existing_hosts_not_in_snapshot' => [], 'existing_groups_not_in_snapshot' => [] },
|
|
47
|
+
'unsupported_destructive_implications' => []
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def preview_existing_entities(snapshot, preview)
|
|
52
|
+
snapshot.fetch('groups').each_key do |name|
|
|
53
|
+
if context.find_group(name).nil?
|
|
54
|
+
add_create(preview, 'groups', name, 'groups_created')
|
|
55
|
+
else
|
|
56
|
+
add_unchanged(preview, 'groups', name)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
snapshot.fetch('hosts').each_key do |name|
|
|
61
|
+
if context.find_host(name).nil?
|
|
62
|
+
add_create(preview, 'hosts', name, 'hosts_created')
|
|
63
|
+
else
|
|
64
|
+
add_unchanged(preview, 'hosts', name)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def preview_group_payloads(groups, preview)
|
|
70
|
+
groups.each do |name, payload|
|
|
71
|
+
group = context.find_group(name)
|
|
72
|
+
preview_variables(preview, group, :group, name, payload.fetch('vars', {}))
|
|
73
|
+
preview_tags(preview, group, 'group', name, array_value(payload, 'tags'))
|
|
74
|
+
array_value(payload, 'children').each do |child_name|
|
|
75
|
+
preview_association(preview, group, 'group_children', name, child_name) do |entity|
|
|
76
|
+
entity.children_dataset[name: child_name]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def preview_host_payloads(hosts, preview)
|
|
83
|
+
hosts.each do |name, payload|
|
|
84
|
+
host = context.find_host(name)
|
|
85
|
+
preview_variables(preview, host, :host, name, payload.fetch('vars', {}))
|
|
86
|
+
preview_tags(preview, host, 'host', name, array_value(payload, 'tags'))
|
|
87
|
+
array_value(payload, 'groups').each do |group_name|
|
|
88
|
+
preview_association(preview, host, 'host_groups', name, group_name) do |entity|
|
|
89
|
+
entity.groups_dataset[name: group_name]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def preview_variables(preview, entity, type, entity_name, variables)
|
|
96
|
+
variables.each do |name, value|
|
|
97
|
+
existing = entity&.public_send("#{type}vars_dataset")&.[](name: name)
|
|
98
|
+
entry = { 'entity' => entity_name, 'name' => name, 'to' => value.to_s }
|
|
99
|
+
if existing.nil?
|
|
100
|
+
add_update(preview, "#{type}_vars", entry)
|
|
101
|
+
elsif existing.value != value.to_s
|
|
102
|
+
add_update(preview, "#{type}_vars", entry.merge('from' => existing.value))
|
|
103
|
+
else
|
|
104
|
+
add_unchanged(preview, "#{type}_vars", entry)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def preview_tags(preview, entity, entity_type, entity_name, tags)
|
|
110
|
+
context.normalize_tag_names(tags).each do |tag_name|
|
|
111
|
+
entry = { 'entity_type' => entity_type, 'entity' => entity_name, 'tag' => tag_name }
|
|
112
|
+
if entity.nil? || entity.tags_dataset[name: tag_name].nil?
|
|
113
|
+
add_association(preview, 'tags', entry)
|
|
114
|
+
else
|
|
115
|
+
add_unchanged(preview, 'associations', entry)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def preview_association(preview, entity, key, source, target)
|
|
121
|
+
entry = { 'source' => source, 'target' => target }
|
|
122
|
+
if entity.nil? || yield(entity).nil?
|
|
123
|
+
add_association(preview, key, entry)
|
|
124
|
+
else
|
|
125
|
+
add_unchanged(preview, 'associations', entry.merge('type' => key))
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def preview_ignored_existing(snapshot, preview)
|
|
130
|
+
snapshot_hosts = snapshot.fetch('hosts').keys
|
|
131
|
+
context.all_hosts.each do |host|
|
|
132
|
+
next if snapshot_hosts.include?(host.name)
|
|
133
|
+
|
|
134
|
+
preview['ignored']['existing_hosts_not_in_snapshot'] << host.name
|
|
135
|
+
preview['summary']['ignored_existing_hosts'] += 1
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
snapshot_groups = snapshot.fetch('groups').keys
|
|
139
|
+
context.all_groups.each do |group|
|
|
140
|
+
next if snapshot_groups.include?(group.name)
|
|
141
|
+
|
|
142
|
+
preview['ignored']['existing_groups_not_in_snapshot'] << group.name
|
|
143
|
+
preview['summary']['ignored_existing_groups'] += 1
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def add_create(preview, key, value, counter)
|
|
148
|
+
preview['creates'][key] << value
|
|
149
|
+
preview['summary'][counter] += 1
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def add_update(preview, key, entry)
|
|
153
|
+
preview['updates'][key] << entry
|
|
154
|
+
preview['summary']['variables_changed'] += 1
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def add_association(preview, key, entry)
|
|
158
|
+
preview['associations'][key] << entry
|
|
159
|
+
preview['summary']['associations_added'] += 1
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def add_unchanged(preview, key, entry)
|
|
163
|
+
preview['unchanged'][key] << entry
|
|
164
|
+
preview['summary']['unchanged'] += 1
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def array_value(payload, key)
|
|
168
|
+
payload.fetch(key, []).map(&:to_s)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
# rubocop:enable Metrics/ClassLength
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moose
|
|
4
|
+
module Inventory
|
|
5
|
+
module Operations
|
|
6
|
+
# Normalizes and validates portable inventory snapshot input before import.
|
|
7
|
+
class InventorySnapshotValidator
|
|
8
|
+
def initialize(context:)
|
|
9
|
+
@context = context
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(snapshot:)
|
|
13
|
+
normalized = deep_stringify_keys(snapshot)
|
|
14
|
+
validate_snapshot!(normalized)
|
|
15
|
+
normalized
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :context
|
|
21
|
+
|
|
22
|
+
def validate_snapshot!(snapshot)
|
|
23
|
+
raise_invalid('snapshot must be a mapping') unless snapshot.is_a?(Hash)
|
|
24
|
+
raise_invalid('version must be 1') unless snapshot['version'].to_i == InventorySnapshot::VERSION
|
|
25
|
+
raise_invalid('hosts must be a mapping') unless snapshot['hosts'].is_a?(Hash)
|
|
26
|
+
raise_invalid('groups must be a mapping') unless snapshot['groups'].is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
validate_hosts!(snapshot)
|
|
29
|
+
validate_groups!(snapshot)
|
|
30
|
+
validate_group_cycles!(snapshot['groups'])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_hosts!(snapshot)
|
|
34
|
+
snapshot['hosts'].each do |name, payload|
|
|
35
|
+
validate_entity_payload!(name, payload, 'host', allowed_keys: %w[groups tags vars])
|
|
36
|
+
groups = array_value(payload, 'groups', label: "host '#{name}' groups")
|
|
37
|
+
groups.each do |group_name|
|
|
38
|
+
next if snapshot['groups'].key?(group_name)
|
|
39
|
+
|
|
40
|
+
raise_invalid("host '#{name}' references unknown group '#{group_name}'")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_groups!(snapshot)
|
|
46
|
+
snapshot['groups'].each do |name, payload|
|
|
47
|
+
validate_entity_payload!(name, payload, 'group', allowed_keys: %w[children tags vars])
|
|
48
|
+
children = array_value(payload, 'children', label: "group '#{name}' children")
|
|
49
|
+
children.each do |child_name|
|
|
50
|
+
next if snapshot['groups'].key?(child_name)
|
|
51
|
+
|
|
52
|
+
raise_invalid("group '#{name}' references unknown child group '#{child_name}'")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_entity_payload!(name, payload, label, allowed_keys:)
|
|
58
|
+
raise_invalid("#{label} name cannot be empty") if blank_string?(name)
|
|
59
|
+
raise_invalid("#{label} '#{name}' must be a mapping") unless payload.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
unsupported = payload.keys - allowed_keys
|
|
62
|
+
unless unsupported.empty?
|
|
63
|
+
raise_invalid("#{label} '#{name}' has unsupported fields: #{unsupported.join(', ')}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
variables = payload.fetch('vars', {})
|
|
67
|
+
raise_invalid("#{label} '#{name}' vars must be a mapping") unless variables.is_a?(Hash)
|
|
68
|
+
variables.each_key do |variable_name|
|
|
69
|
+
raise_invalid("#{label} '#{name}' variable name cannot be empty") if blank_string?(variable_name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
payload['tags'] = context.normalize_tag_names(array_value(payload, 'tags', label: "#{label} '#{name}' tags"))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_group_cycles!(groups)
|
|
76
|
+
visiting = {}
|
|
77
|
+
visited = {}
|
|
78
|
+
|
|
79
|
+
groups.each_key do |name|
|
|
80
|
+
visit_group!(name, groups, visiting, visited)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def visit_group!(name, groups, visiting, visited)
|
|
85
|
+
return if visited[name]
|
|
86
|
+
|
|
87
|
+
raise_invalid("group hierarchy contains a cycle at '#{name}'") if visiting[name]
|
|
88
|
+
|
|
89
|
+
visiting[name] = true
|
|
90
|
+
array_value(groups[name], 'children', label: "group '#{name}' children").each do |child_name|
|
|
91
|
+
visit_group!(child_name, groups, visiting, visited)
|
|
92
|
+
end
|
|
93
|
+
visiting.delete(name)
|
|
94
|
+
visited[name] = true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def array_value(payload, key, label:)
|
|
98
|
+
value = payload.fetch(key, [])
|
|
99
|
+
raise_invalid("#{label} must be a list") unless value.is_a?(Array)
|
|
100
|
+
|
|
101
|
+
value.map(&:to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def deep_stringify_keys(value)
|
|
105
|
+
case value
|
|
106
|
+
when Hash
|
|
107
|
+
stringify_hash_keys(value)
|
|
108
|
+
when Array
|
|
109
|
+
value.map { |entry| deep_stringify_keys(entry) }
|
|
110
|
+
else
|
|
111
|
+
value
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def stringify_hash_keys(hash)
|
|
116
|
+
hash.each_with_object({}) do |(key, val), result|
|
|
117
|
+
normalized_key = key.to_s
|
|
118
|
+
raise_invalid("duplicate normalized key '#{normalized_key}'") if result.key?(normalized_key)
|
|
119
|
+
|
|
120
|
+
result[normalized_key] = deep_stringify_keys(val)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def blank_string?(value)
|
|
125
|
+
value.to_s.strip.empty?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def raise_invalid(message)
|
|
129
|
+
raise context.moose_exception_class, "Invalid inventory snapshot: #{message}."
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moose
|
|
4
|
+
module Inventory
|
|
5
|
+
module Operations
|
|
6
|
+
# Shared structured event/result plumbing for inventory operations.
|
|
7
|
+
module OperationEventSupport
|
|
8
|
+
Event = Struct.new(:type, :payload, keyword_init: true)
|
|
9
|
+
Result = Struct.new(:events, :warning_count, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def build_event(type, payload = {})
|
|
14
|
+
Event.new(type: type, payload: payload)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def emit(events, type, payload = {})
|
|
18
|
+
events << build_event(type, payload)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def operation_result(events:, warning_count: 0)
|
|
22
|
+
Result.new(events: events, warning_count: warning_count)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moose
|
|
4
|
+
module Inventory
|
|
5
|
+
module Operations
|
|
6
|
+
class QueryInventory
|
|
7
|
+
# Shared helpers for query subcomponents.
|
|
8
|
+
class BaseQuery
|
|
9
|
+
def initialize(context:)
|
|
10
|
+
@context = context
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
attr_reader :context
|
|
16
|
+
|
|
17
|
+
def variables_hash(dataset)
|
|
18
|
+
dataset.order(:id).to_h { |variable| [variable[:name].to_sym, variable[:value]] }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moose
|
|
4
|
+
module Inventory
|
|
5
|
+
module Operations
|
|
6
|
+
class QueryInventory
|
|
7
|
+
# Group-focused read queries.
|
|
8
|
+
class GroupQueries < BaseQuery
|
|
9
|
+
def get_groups(names:)
|
|
10
|
+
names.each_with_object({}) do |name, results|
|
|
11
|
+
group = context.find_group(name)
|
|
12
|
+
next if group.nil?
|
|
13
|
+
|
|
14
|
+
results[group.name.to_sym] = group_data(group)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def list_groups(ansible:)
|
|
19
|
+
context.all_groups.each_with_object({}) do |group, results|
|
|
20
|
+
hosts = group.hosts_dataset.map(:name)
|
|
21
|
+
next if hide_empty_automatic_group?(group, hosts)
|
|
22
|
+
|
|
23
|
+
results[group.name.to_sym] = list_group_data(group, hosts, ansible: ansible)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def list_group_vars(names:, ansible:)
|
|
28
|
+
return {} if names.empty?
|
|
29
|
+
return ansible_group_vars(names.first) if ansible
|
|
30
|
+
|
|
31
|
+
names.each_with_object({}) do |name, results|
|
|
32
|
+
group = context.find_group(name)
|
|
33
|
+
next if group.nil?
|
|
34
|
+
|
|
35
|
+
results[name.to_sym] = variables_hash(group.groupvars_dataset)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def group_data(group)
|
|
42
|
+
{}.tap do |data|
|
|
43
|
+
hosts = group.hosts_dataset.map(:name)
|
|
44
|
+
data[:hosts] = hosts unless hosts.empty?
|
|
45
|
+
|
|
46
|
+
children = group.children_dataset.map(:name)
|
|
47
|
+
data[:children] = children unless children.empty?
|
|
48
|
+
|
|
49
|
+
groupvars = variables_hash(group.groupvars_dataset)
|
|
50
|
+
data[:groupvars] = groupvars unless groupvars.empty?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def hide_empty_automatic_group?(group, hosts)
|
|
55
|
+
group.name == 'ungrouped' && hosts.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def list_group_data(group, hosts, ansible:)
|
|
59
|
+
{}.tap do |data|
|
|
60
|
+
data[:hosts] = hosts if ansible || !hosts.empty?
|
|
61
|
+
|
|
62
|
+
children = group.children_dataset.map(:name)
|
|
63
|
+
data[:children] = children unless children.empty?
|
|
64
|
+
|
|
65
|
+
append_group_vars(data, group, ansible: ansible)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def append_group_vars(data, group, ansible:)
|
|
70
|
+
groupvars = variables_hash(group.groupvars_dataset)
|
|
71
|
+
return if groupvars.empty?
|
|
72
|
+
|
|
73
|
+
data[ansible ? :vars : :groupvars] = groupvars
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ansible_group_vars(name)
|
|
77
|
+
group = context.find_group(name)
|
|
78
|
+
return {} if group.nil?
|
|
79
|
+
|
|
80
|
+
variables_hash(group.groupvars_dataset)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|