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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +2 -0
- data/.gitignore +2 -1
- data/.rubocop.yml +21 -0
- data/BACKLOG.md +630 -8
- data/Gemfile +2 -0
- data/Gemfile.lock +1 -1
- data/README.md +315 -39
- 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 +11 -3
- data/docs/release/release-environment-protection.md +70 -0
- data/docs/release/release-readiness.md +23 -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 +2 -2
- 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 +5 -2
- data/lib/moose_inventory/cli/group_add.rb +11 -9
- data/lib/moose_inventory/cli/group_addchild.rb +23 -65
- data/lib/moose_inventory/cli/group_addhost.rb +16 -67
- 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 +12 -10
- data/lib/moose_inventory/cli/group_rmchild.rb +26 -82
- data/lib/moose_inventory/cli/group_rmhost.rb +18 -53
- 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 +68 -1
- data/lib/moose_inventory/cli/host.rb +6 -3
- data/lib/moose_inventory/cli/host_add.rb +69 -29
- data/lib/moose_inventory/cli/host_addgroup.rb +22 -58
- 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 +25 -44
- 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 +170 -195
- 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 +68 -2
- data/lib/moose_inventory/operations/add_associations.rb +20 -16
- data/lib/moose_inventory/operations/add_groups.rb +21 -13
- data/lib/moose_inventory/operations/add_hosts.rb +30 -17
- 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 +23 -16
- data/lib/moose_inventory/operations/group_cleanup.rb +23 -8
- 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 +30 -18
- data/lib/moose_inventory/operations/remove_groups.rb +12 -12
- 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 +19 -35
- data/scripts/check.sh +1 -0
- data/scripts/ci/check_generated_artifacts.sh +41 -0
- data/scripts/ci/check_permissions.sh +2 -0
- data/scripts/ci/check_rubocop.sh +30 -25
- data/scripts/files.rb +5 -4
- 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 +136 -96
- data/spec/lib/moose_inventory/cli/group_rmchild_spec.rb +66 -41
- 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 +396 -36
- 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 +34 -0
- data/spec/lib/moose_inventory/operations/add_groups_spec.rb +15 -0
- data/spec/lib/moose_inventory/operations/add_hosts_spec.rb +13 -0
- data/spec/lib/moose_inventory/operations/add_variables_spec.rb +103 -0
- data/spec/lib/moose_inventory/operations/group_child_relations_spec.rb +46 -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 +35 -0
- data/spec/lib/moose_inventory/operations/remove_groups_spec.rb +21 -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 +80 -41
|
@@ -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
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moose
|
|
4
|
+
module Inventory
|
|
5
|
+
module Operations
|
|
6
|
+
class QueryInventory
|
|
7
|
+
# Host-focused read queries.
|
|
8
|
+
class HostQueries < BaseQuery
|
|
9
|
+
def get_hosts(names:)
|
|
10
|
+
names.each_with_object({}) do |name, results|
|
|
11
|
+
host = context.find_host(name)
|
|
12
|
+
next if host.nil?
|
|
13
|
+
|
|
14
|
+
results[host.name.to_sym] = host_data(host)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def list_hosts(filters: {})
|
|
19
|
+
dataset = filtered_hosts_dataset(filters)
|
|
20
|
+
return {} if dataset.nil?
|
|
21
|
+
|
|
22
|
+
dataset.order(:id).all.to_h do |host|
|
|
23
|
+
[host.name.to_sym, host_data(host)]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def list_host_vars(names:, ansible:)
|
|
28
|
+
return ansible_host_vars(names.first) if ansible
|
|
29
|
+
|
|
30
|
+
names.each_with_object({}) do |name, results|
|
|
31
|
+
host = context.find_host(name)
|
|
32
|
+
next if host.nil?
|
|
33
|
+
|
|
34
|
+
results[name.to_sym] = variables_hash(host.hostvars_dataset)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def host_data(host)
|
|
41
|
+
{}.tap do |data|
|
|
42
|
+
groups = host.groups_dataset.map(:name)
|
|
43
|
+
data[:groups] = groups unless groups.empty?
|
|
44
|
+
|
|
45
|
+
tags = host.tags_dataset.map(:name).sort
|
|
46
|
+
data[:tags] = tags unless tags.empty?
|
|
47
|
+
|
|
48
|
+
hostvars = variables_hash(host.hostvars_dataset)
|
|
49
|
+
data[:hostvars] = hostvars unless hostvars.empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def filtered_hosts_dataset(filters)
|
|
54
|
+
dataset = context.hosts_dataset
|
|
55
|
+
dataset = filter_hosts_by_groups(dataset, filters.fetch(:groups, []))
|
|
56
|
+
return nil if dataset.nil?
|
|
57
|
+
|
|
58
|
+
dataset = filter_hosts_by_tags(dataset, filters.fetch(:tags, []))
|
|
59
|
+
return nil if dataset.nil?
|
|
60
|
+
|
|
61
|
+
filter_hosts_by_variables(dataset, filters.fetch(:variables, {}))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def filter_hosts_by_groups(dataset, groups)
|
|
65
|
+
groups.reduce(dataset) do |current_dataset, group_name|
|
|
66
|
+
group = context.find_group(group_name)
|
|
67
|
+
return nil if group.nil?
|
|
68
|
+
|
|
69
|
+
current_dataset.where(id: context.db_dataset(:groups_hosts).where(group_id: group.id).select(:host_id))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def filter_hosts_by_tags(dataset, tags)
|
|
74
|
+
tags.reduce(dataset) do |current_dataset, tag_name|
|
|
75
|
+
tag = context.find_tag(tag_name)
|
|
76
|
+
return nil if tag.nil?
|
|
77
|
+
|
|
78
|
+
current_dataset.where(id: context.db_dataset(:hosts_tags).where(tag_id: tag.id).select(:host_id))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def filter_hosts_by_variables(dataset, variables)
|
|
83
|
+
variables.reduce(dataset) do |current_dataset, (name, value)|
|
|
84
|
+
current_dataset.where(
|
|
85
|
+
id: context.db_dataset(:hostvars).where(name: name, value: value).select(:host_id)
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ansible_host_vars(name)
|
|
91
|
+
results = {}
|
|
92
|
+
host = context.find_host(name)
|
|
93
|
+
results.merge!(variables_hash(host.hostvars_dataset)) unless host.nil?
|
|
94
|
+
|
|
95
|
+
results[:_meta] = {
|
|
96
|
+
hostvars: context.all_hosts.to_h do |entry|
|
|
97
|
+
[entry.name.to_sym, variables_hash(entry.hostvars_dataset)]
|
|
98
|
+
end
|
|
99
|
+
}
|
|
100
|
+
results
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'query_inventory/base_query'
|
|
4
|
+
require_relative 'query_inventory/host_queries'
|
|
5
|
+
require_relative 'query_inventory/group_queries'
|
|
6
|
+
|
|
7
|
+
module Moose
|
|
8
|
+
module Inventory
|
|
9
|
+
module Operations
|
|
10
|
+
# Read-only inventory query seam for host/group CLI commands.
|
|
11
|
+
class QueryInventory
|
|
12
|
+
def initialize(context:)
|
|
13
|
+
@host_queries = HostQueries.new(context: context)
|
|
14
|
+
@group_queries = GroupQueries.new(context: context)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def get_hosts(names:)
|
|
18
|
+
host_queries.get_hosts(names: names)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def list_hosts(filters: {})
|
|
22
|
+
host_queries.list_hosts(filters: filters)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def list_host_vars(names:, ansible:)
|
|
26
|
+
host_queries.list_host_vars(names: names, ansible: ansible)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get_groups(names:)
|
|
30
|
+
group_queries.get_groups(names: names)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def list_groups(ansible:)
|
|
34
|
+
group_queries.list_groups(ansible: ansible)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def list_group_vars(names:, ansible:)
|
|
38
|
+
group_queries.list_group_vars(names: names, ansible: ansible)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :group_queries, :host_queries
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|