empeira 0.1.0.pre.alpha.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 +7 -0
- data/LICENSE +661 -0
- data/README.md +150 -0
- data/bin/empeira +18 -0
- data/config/defaults.yaml +229 -0
- data/docs/addons.md +331 -0
- data/docs/architecture.md +277 -0
- data/docs/configuration.md +1204 -0
- data/docs/development.md +472 -0
- data/docs/hiera-and-eyaml.md +361 -0
- data/docs/installation.md +280 -0
- data/docs/networking.md +362 -0
- data/docs/nodes.md +642 -0
- data/docs/proxy.md +136 -0
- data/docs/troubleshooting.md +361 -0
- data/lib/empeira/addons/environment.rb +82 -0
- data/lib/empeira/addons/error.rb +14 -0
- data/lib/empeira/addons/installation.rb +119 -0
- data/lib/empeira/addons/manifest.rb +112 -0
- data/lib/empeira/addons/service.rb +50 -0
- data/lib/empeira/addons/storage.rb +94 -0
- data/lib/empeira/agent/acquisition.rb +120 -0
- data/lib/empeira/agent/artifact.rb +41 -0
- data/lib/empeira/agent/authentication.rb +122 -0
- data/lib/empeira/agent/cache.rb +122 -0
- data/lib/empeira/agent/cache_storage.rb +82 -0
- data/lib/empeira/agent/download.rb +84 -0
- data/lib/empeira/agent/native_commands.rb +84 -0
- data/lib/empeira/agent/release_sources.rb +64 -0
- data/lib/empeira/agent/target.rb +66 -0
- data/lib/empeira/agent/versions.rb +29 -0
- data/lib/empeira/application.rb +86 -0
- data/lib/empeira/build.json +5 -0
- data/lib/empeira/build_info.rb +46 -0
- data/lib/empeira/cli/addons.rb +45 -0
- data/lib/empeira/cli/base.rb +73 -0
- data/lib/empeira/cli/completion.rb +42 -0
- data/lib/empeira/cli/config.rb +22 -0
- data/lib/empeira/cli/main.rb +114 -0
- data/lib/empeira/cli/node.rb +87 -0
- data/lib/empeira/cli/progress.rb +169 -0
- data/lib/empeira/cli/proxy.rb +36 -0
- data/lib/empeira/cli/update.rb +23 -0
- data/lib/empeira/completion/addons.rb +93 -0
- data/lib/empeira/completion/bash.rb +62 -0
- data/lib/empeira/completion/catalog.rb +170 -0
- data/lib/empeira/configuration/additional_services.rb +74 -0
- data/lib/empeira/configuration/addons.rb +85 -0
- data/lib/empeira/configuration/agent_schema.rb +163 -0
- data/lib/empeira/configuration/agent_values.rb +54 -0
- data/lib/empeira/configuration/bootstrap_guests.rb +42 -0
- data/lib/empeira/configuration/command_mocks.rb +65 -0
- data/lib/empeira/configuration/display.rb +20 -0
- data/lib/empeira/configuration/dns_rewrites.rb +58 -0
- data/lib/empeira/configuration/document.rb +67 -0
- data/lib/empeira/configuration/eyaml.rb +46 -0
- data/lib/empeira/configuration/hiera_modulepath.rb +40 -0
- data/lib/empeira/configuration/hiera_mount_schema.rb +62 -0
- data/lib/empeira/configuration/hiera_mounts.rb +83 -0
- data/lib/empeira/configuration/image_schema.rb +68 -0
- data/lib/empeira/configuration/loader.rb +70 -0
- data/lib/empeira/configuration/merge.rb +48 -0
- data/lib/empeira/configuration/network_redirects.rb +73 -0
- data/lib/empeira/configuration/node_images.rb +64 -0
- data/lib/empeira/configuration/schema.rb +215 -0
- data/lib/empeira/configuration/server_mounts.rb +106 -0
- data/lib/empeira/configuration/service_configuration.rb +40 -0
- data/lib/empeira/configuration/ssh_preferences.rb +83 -0
- data/lib/empeira/configuration/user.rb +46 -0
- data/lib/empeira/configuration/vm_interfaces.rb +147 -0
- data/lib/empeira/control_plane/additional_configurations.rb +106 -0
- data/lib/empeira/control_plane/browser.rb +73 -0
- data/lib/empeira/control_plane/controller.rb +763 -0
- data/lib/empeira/control_plane/discovery.rb +69 -0
- data/lib/empeira/control_plane/environment.rb +101 -0
- data/lib/empeira/control_plane/files.rb +263 -0
- data/lib/empeira/control_plane/health.rb +104 -0
- data/lib/empeira/control_plane/plan.rb +356 -0
- data/lib/empeira/control_plane/storage.rb +48 -0
- data/lib/empeira/errors.rb +9 -0
- data/lib/empeira/execution/console.rb +45 -0
- data/lib/empeira/execution/diagnostics.rb +210 -0
- data/lib/empeira/execution/launch_error.rb +14 -0
- data/lib/empeira/execution/result.rb +11 -0
- data/lib/empeira/execution/runner.rb +235 -0
- data/lib/empeira/execution/terminal.rb +35 -0
- data/lib/empeira/hostname_pattern.rb +14 -0
- data/lib/empeira/images/configuration.rb +44 -0
- data/lib/empeira/images/identity.rb +30 -0
- data/lib/empeira/images/recipe.rb +22 -0
- data/lib/empeira/images/reference.rb +43 -0
- data/lib/empeira/images/source.rb +19 -0
- data/lib/empeira/immutable.rb +19 -0
- data/lib/empeira/infrastructure/cleanup.rb +328 -0
- data/lib/empeira/infrastructure/definition.rb +59 -0
- data/lib/empeira/infrastructure/owned_files.rb +63 -0
- data/lib/empeira/infrastructure/recovery_inventory.rb +53 -0
- data/lib/empeira/infrastructure/service.rb +237 -0
- data/lib/empeira/infrastructure/status.rb +73 -0
- data/lib/empeira/infrastructure/store.rb +274 -0
- data/lib/empeira/modules/events.rb +67 -0
- data/lib/empeira/modules/git_safety.rb +60 -0
- data/lib/empeira/modules/host_git.rb +91 -0
- data/lib/empeira/modules/image.rb +27 -0
- data/lib/empeira/modules/installer.rb +67 -0
- data/lib/empeira/modules/request.rb +68 -0
- data/lib/empeira/modules/state.rb +87 -0
- data/lib/empeira/modules/storage.rb +35 -0
- data/lib/empeira/network/bootstrap_proxy.rb +157 -0
- data/lib/empeira/network/definition.rb +61 -0
- data/lib/empeira/network/direct_egress.rb +133 -0
- data/lib/empeira/network/egress.rb +27 -0
- data/lib/empeira/network/gateway.rb +79 -0
- data/lib/empeira/network/host_policy.rb +53 -0
- data/lib/empeira/network/naming.rb +28 -0
- data/lib/empeira/network/peer/adapter_image.rb +32 -0
- data/lib/empeira/network/peer/allocation.rb +45 -0
- data/lib/empeira/network/peer/attachment.rb +27 -0
- data/lib/empeira/network/peer/backend.rb +108 -0
- data/lib/empeira/network/peer/channel.rb +133 -0
- data/lib/empeira/network/peer/channel_server.rb +71 -0
- data/lib/empeira/network/peer/docker_adapter.rb +165 -0
- data/lib/empeira/network/peer/helper_security.rb +40 -0
- data/lib/empeira/network/peer/layout.rb +55 -0
- data/lib/empeira/network/peer/linux_podman.rb +114 -0
- data/lib/empeira/network/peer/podman_machine.rb +112 -0
- data/lib/empeira/network/policy.rb +39 -0
- data/lib/empeira/network/proxy_bindings.rb +52 -0
- data/lib/empeira/network/proxy_policy.rb +64 -0
- data/lib/empeira/network/redirects.rb +89 -0
- data/lib/empeira/network/resource.rb +50 -0
- data/lib/empeira/node/agent_installer.rb +31 -0
- data/lib/empeira/node/agent_package.rb +125 -0
- data/lib/empeira/node/agent_repository.rb +160 -0
- data/lib/empeira/node/apt_configuration.rb +85 -0
- data/lib/empeira/node/bootstrap.rb +17 -0
- data/lib/empeira/node/certificates.rb +98 -0
- data/lib/empeira/node/command_mock.rb +38 -0
- data/lib/empeira/node/command_mocks.rb +114 -0
- data/lib/empeira/node/container.rb +134 -0
- data/lib/empeira/node/container_agent_bootstrap.rb +43 -0
- data/lib/empeira/node/container_bootstrap.rb +52 -0
- data/lib/empeira/node/container_guest.rb +42 -0
- data/lib/empeira/node/container_lifecycle.rb +145 -0
- data/lib/empeira/node/container_preparation.rb +144 -0
- data/lib/empeira/node/container_state.rb +129 -0
- data/lib/empeira/node/definition.rb +20 -0
- data/lib/empeira/node/diagnostic_command.rb +19 -0
- data/lib/empeira/node/dnf_agent_repository.rb +202 -0
- data/lib/empeira/node/external_fact.rb +21 -0
- data/lib/empeira/node/image.rb +38 -0
- data/lib/empeira/node/interactive_tools.rb +33 -0
- data/lib/empeira/node/interface.rb +18 -0
- data/lib/empeira/node/inventory.rb +143 -0
- data/lib/empeira/node/package_bootstrap.rb +129 -0
- data/lib/empeira/node/package_proxy.rb +102 -0
- data/lib/empeira/node/package_sources.rb +19 -0
- data/lib/empeira/node/puppet_command.rb +12 -0
- data/lib/empeira/node/rpm_configuration.rb +62 -0
- data/lib/empeira/node/run_request.rb +52 -0
- data/lib/empeira/node/runtime_proxy.rb +86 -0
- data/lib/empeira/node/service.rb +72 -0
- data/lib/empeira/node/ssh_client.rb +25 -0
- data/lib/empeira/node/ssh_credentials.rb +78 -0
- data/lib/empeira/node/ssh_endpoint.rb +58 -0
- data/lib/empeira/node/ssh_identity.rb +37 -0
- data/lib/empeira/node/user_ssh.rb +60 -0
- data/lib/empeira/node/vm.rb +89 -0
- data/lib/empeira/node/vm_bootstrap.rb +53 -0
- data/lib/empeira/node/vm_lifecycle.rb +212 -0
- data/lib/empeira/node/vm_preflight.rb +37 -0
- data/lib/empeira/node/vm_preparation.rb +161 -0
- data/lib/empeira/node/vm_state.rb +161 -0
- data/lib/empeira/platform/facts.rb +66 -0
- data/lib/empeira/platform/kvm.rb +91 -0
- data/lib/empeira/platform/locations.rb +51 -0
- data/lib/empeira/platform/resolvers.rb +85 -0
- data/lib/empeira/platform/routes.rb +50 -0
- data/lib/empeira/progress.rb +70 -0
- data/lib/empeira/project_root.rb +47 -0
- data/lib/empeira/providers/lifecycle.rb +96 -0
- data/lib/empeira/providers/registry.rb +21 -0
- data/lib/empeira/providers/resource.rb +32 -0
- data/lib/empeira/providers.rb +55 -0
- data/lib/empeira/runtime/agent_helper.rb +34 -0
- data/lib/empeira/runtime/container.rb +184 -0
- data/lib/empeira/runtime/diagnostic_execution.rb +52 -0
- data/lib/empeira/runtime/docker.rb +87 -0
- data/lib/empeira/runtime/image_metadata.rb +83 -0
- data/lib/empeira/runtime/interface.rb +8 -0
- data/lib/empeira/runtime/mount_probe.rb +57 -0
- data/lib/empeira/runtime/podman.rb +122 -0
- data/lib/empeira/runtime/recipe_build.rb +103 -0
- data/lib/empeira/runtime/registry_diagnostics.rb +23 -0
- data/lib/empeira/runtime/service_arguments.rb +55 -0
- data/lib/empeira/runtime/service_images.rb +118 -0
- data/lib/empeira/runtime/service_inspection.rb +159 -0
- data/lib/empeira/runtime/services.rb +120 -0
- data/lib/empeira/runtime/update_helper.rb +97 -0
- data/lib/empeira/runtime/workspace_network.rb +69 -0
- data/lib/empeira/server/environment_configuration.rb +53 -0
- data/lib/empeira/server/http.rb +38 -0
- data/lib/empeira/server/puppetdb_runtime.rb +74 -0
- data/lib/empeira/server/relay_certificate.rb +76 -0
- data/lib/empeira/server/runtime.rb +90 -0
- data/lib/empeira/services/definition.rb +42 -0
- data/lib/empeira/updates/images.rb +104 -0
- data/lib/empeira/updates/modules.rb +45 -0
- data/lib/empeira/updates/service.rb +45 -0
- data/lib/empeira/version_requirement.rb +23 -0
- data/lib/empeira/vm/agent.rb +66 -0
- data/lib/empeira/vm/bootstrap_requirements.rb +69 -0
- data/lib/empeira/vm/certificate_runtime.rb +30 -0
- data/lib/empeira/vm/cloud_init.rb +171 -0
- data/lib/empeira/vm/disk.rb +136 -0
- data/lib/empeira/vm/guest.rb +123 -0
- data/lib/empeira/vm/guest_channel.rb +110 -0
- data/lib/empeira/vm/guest_network.rb +94 -0
- data/lib/empeira/vm/guest_output.rb +52 -0
- data/lib/empeira/vm/guest_shell.rb +79 -0
- data/lib/empeira/vm/guest_upload.rb +43 -0
- data/lib/empeira/vm/image_cache.rb +77 -0
- data/lib/empeira/vm/image_source.rb +91 -0
- data/lib/empeira/vm/interface.rb +8 -0
- data/lib/empeira/vm/interface_ownership.rb +124 -0
- data/lib/empeira/vm/interface_routes.rb +101 -0
- data/lib/empeira/vm/interfaces.rb +240 -0
- data/lib/empeira/vm/management.rb +73 -0
- data/lib/empeira/vm/prerequisites.rb +42 -0
- data/lib/empeira/vm/qemu.rb +83 -0
- data/lib/empeira/vm/qemu_runtime.rb +404 -0
- data/lib/empeira/vm/removal.rb +30 -0
- data/lib/empeira/vm/root_disk.rb +92 -0
- data/lib/empeira/vm/session_lock.rb +56 -0
- data/lib/empeira/vm/system_ssh.rb +29 -0
- data/lib/empeira/workspace.rb +42 -0
- data/lib/empeira.rb +209 -0
- data/resources/browser/relay.rb +22 -0
- data/resources/gateway/Containerfile +8 -0
- data/resources/gateway/bridge.rb +66 -0
- data/resources/gateway/configure.rb +323 -0
- data/resources/modules/Containerfile +15 -0
- data/resources/modules/Gemfile +5 -0
- data/resources/modules/Gemfile.lock +105 -0
- data/resources/modules/worker.rb +161 -0
- data/resources/network/adapter/Containerfile +13 -0
- data/resources/network/adapter/bridge_linux.go +51 -0
- data/resources/network/adapter/framing.go +45 -0
- data/resources/network/adapter/framing_test.go +53 -0
- data/resources/network/adapter/go.mod +3 -0
- data/resources/network/adapter/main_linux.go +273 -0
- data/resources/network/channel.rb +18 -0
- data/resources/network/ssh_connect.rb +32 -0
- data/resources/nodes/apt_sources.rb +66 -0
- data/resources/nodes/debian/Containerfile +10 -0
- data/resources/nodes/diagnostic_exec.rb +109 -0
- data/resources/nodes/interactive_tools.rb +139 -0
- data/resources/nodes/managed_file.rb +91 -0
- data/resources/nodes/management/guest_agent.py +415 -0
- data/resources/nodes/management/setup.sh +20 -0
- data/resources/nodes/management/shell_rc.sh +12 -0
- data/resources/nodes/management/system_account.sh +24 -0
- data/resources/nodes/management/system_home.sh +15 -0
- data/resources/nodes/rpm/Containerfile +10 -0
- data/resources/nodes/runtime_proxy.rb +244 -0
- data/resources/nodes/ssh/proxy.rb +16 -0
- data/resources/nodes/ssh/sshd_config +15 -0
- data/resources/nodes/ssh/start +6 -0
- data/resources/proxy/Containerfile +10 -0
- data/resources/relay/Containerfile +5 -0
- data/resources/relay/puppetdb.rb +81 -0
- data/resources/relay/server_environment.rb +10 -0
- data/resources/relay/server_start.sh +41 -0
- metadata +326 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Empeira
|
|
4
|
+
module Configuration
|
|
5
|
+
module Merge
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(lower, higher, path: [])
|
|
9
|
+
lower = agent_override_base(lower, higher, path)
|
|
10
|
+
lower = ImageSchema.override_base(lower, higher) if path.first == 'images' && path.size == 2
|
|
11
|
+
copy(lower).merge(copy(higher)) do |key, old, replacement|
|
|
12
|
+
old.is_a?(Hash) && replacement.is_a?(Hash) ? call(old, replacement, path: path + [key]) : replacement
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def agent_override_base(lower, higher, path)
|
|
17
|
+
return lower unless path.first == 'agent'
|
|
18
|
+
|
|
19
|
+
base = copy(lower)
|
|
20
|
+
if path == %w[agent install]
|
|
21
|
+
override_agent_defaults!(base, higher)
|
|
22
|
+
elsif higher.key?('url') && higher['url'] != base['url'] && !higher.key?('sha256')
|
|
23
|
+
base.delete('sha256')
|
|
24
|
+
end
|
|
25
|
+
base
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def override_agent_defaults!(base, higher)
|
|
29
|
+
%w[apt dnf].each do |manager|
|
|
30
|
+
next unless higher.dig(manager, 'default', 'url')
|
|
31
|
+
|
|
32
|
+
prefix = manager == 'apt' ? /\A(?:ubuntu|debian)/ : /\Ael/
|
|
33
|
+
base.fetch('repositories', {}).delete_if { |key, _| key.match?(prefix) }
|
|
34
|
+
base.fetch(manager, {}).clear
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def copy(value)
|
|
39
|
+
case value
|
|
40
|
+
when Hash then value.to_h { |key, child| [copy(key), copy(child)] }
|
|
41
|
+
when Array then value.map { |child| copy(child) }
|
|
42
|
+
when String then value.dup
|
|
43
|
+
else value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Empeira
|
|
4
|
+
module Configuration
|
|
5
|
+
class NetworkRedirects
|
|
6
|
+
UNROUTABLE = %w[0.0.0.0/8 127.0.0.0/8 169.254.0.0/16 224.0.0.0/3].map { |value| IPAddr.new(value) }.freeze
|
|
7
|
+
|
|
8
|
+
def self.validate!(entries, path)
|
|
9
|
+
raise ConfigurationError, "#{path} must be an array" unless entries.is_a?(Array)
|
|
10
|
+
|
|
11
|
+
sources = entries.each_with_index.map do |entry, index|
|
|
12
|
+
validate_entry!(entry, "#{path}[#{index}]")
|
|
13
|
+
end
|
|
14
|
+
return if sources.uniq == sources
|
|
15
|
+
|
|
16
|
+
raise ConfigurationError,
|
|
17
|
+
"#{path} contains duplicate source IP/port/protocol tuples"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.validate_entry!(entry, path)
|
|
21
|
+
mapping!(entry, %w[from to], path)
|
|
22
|
+
source!(entry.fetch('from'), "#{path}.from")
|
|
23
|
+
target!(entry.fetch('to'), "#{path}.to")
|
|
24
|
+
source = entry.fetch('from')
|
|
25
|
+
udp_target!(entry, path) if source.fetch('protocol', 'tcp') == 'udp'
|
|
26
|
+
source.values_at('ip', 'port') + [source.fetch('protocol', 'tcp')]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.mapping!(value, keys, path)
|
|
30
|
+
return if value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
31
|
+
|
|
32
|
+
raise ConfigurationError, "#{path} requires only #{keys.join(' and ')}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.source!(value, path)
|
|
36
|
+
mapping!(value.is_a?(Hash) ? value.except('protocol') : value, %w[ip port], path)
|
|
37
|
+
unless %w[tcp udp].include?(value.fetch('protocol', 'tcp'))
|
|
38
|
+
raise ConfigurationError, "#{path}.protocol must be tcp or udp (default: tcp)"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Network::DirectEgress.validate_ip!(value.fetch('ip'), path, allow_benchmark: true)
|
|
42
|
+
if UNROUTABLE.any? { |range| range.include?(value.fetch('ip')) }
|
|
43
|
+
raise ConfigurationError, "#{path}.ip must be a routable unicast IPv4 address"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
port!(value.fetch('port'), "#{path}.port")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.udp_target!(entry, path)
|
|
50
|
+
return if entry.dig('from', 'port') == 53 && entry.dig('to', 'port') == 53 &&
|
|
51
|
+
%w[dns coredns].include?(entry.dig('to', 'service'))
|
|
52
|
+
|
|
53
|
+
raise ConfigurationError, "#{path}: UDP redirects require port 53 and the built-in coredns service"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.target!(value, path)
|
|
57
|
+
mapping!(value, %w[service port], path)
|
|
58
|
+
service = value.fetch('service')
|
|
59
|
+
unless service.is_a?(String) && service.match?(/\A#{Network::DirectEgress::DNS_LABEL}\z/)
|
|
60
|
+
raise ConfigurationError, "#{path}.service must be an internal service name"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
port!(value.fetch('port'), "#{path}.port")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.port!(value, path)
|
|
67
|
+
return if value.is_a?(Integer) && value.between?(1, 65_535)
|
|
68
|
+
|
|
69
|
+
raise ConfigurationError, "#{path} must be an integer port from 1 through 65535"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Empeira
|
|
4
|
+
module Configuration
|
|
5
|
+
# Node image metadata and architecture validation.
|
|
6
|
+
module NodeImages
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def mapping!(value, path)
|
|
10
|
+
raise ConfigurationError, "#{path} must be a mapping" unless value.is_a?(Hash)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def catalog!(value, path)
|
|
14
|
+
mapping!(value, path)
|
|
15
|
+
value.each do |key, item|
|
|
16
|
+
unless key.is_a?(String) && key.match?(/\A[a-z0-9][a-z0-9_.-]*\z/)
|
|
17
|
+
raise ConfigurationError, "#{path} has an invalid catalog key"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
yield item, "#{path}.#{key}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validate!(value, path, &)
|
|
25
|
+
catalog!(value, path, &)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def nodes(data, path)
|
|
29
|
+
catalog!(data, path) do |versions, os_path|
|
|
30
|
+
catalog!(versions, os_path) do |image, image_path|
|
|
31
|
+
node_image!(image, image_path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def node_image!(image, path)
|
|
37
|
+
mapping!(image, path)
|
|
38
|
+
ImageSchema.validate!(image.except('architectures', 'build'), path)
|
|
39
|
+
ImageSchema.validate!({ 'build' => image['build'] }, path) if image.key?('build')
|
|
40
|
+
return unless image.key?('architectures')
|
|
41
|
+
|
|
42
|
+
architectures = image['architectures']
|
|
43
|
+
return if architectures.is_a?(Array) && !architectures.empty? &&
|
|
44
|
+
(architectures - %w[amd64 arm64]).empty?
|
|
45
|
+
|
|
46
|
+
raise ConfigurationError, "#{path}.architectures must contain amd64 and/or arm64"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def effective!(images)
|
|
50
|
+
images.fetch('nodes').each do |os, versions|
|
|
51
|
+
versions.each do |version, image|
|
|
52
|
+
path = "images.nodes.#{os}.#{version}"
|
|
53
|
+
required!(image, ['architectures'], path)
|
|
54
|
+
ImageSchema.validate!(image.except('architectures', 'build'), path, effective: true)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def required!(data, keys, path)
|
|
60
|
+
keys.each { |key| raise ConfigurationError, "#{path}.#{key} is required" unless data.key?(key) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
|
|
5
|
+
module Empeira
|
|
6
|
+
module Configuration
|
|
7
|
+
# Keep the public schema and its fragment/effective validation in one boundary.
|
|
8
|
+
# rubocop:disable-next Metrics/ClassLength
|
|
9
|
+
class Schema
|
|
10
|
+
CONTAINER_ENGINES = Runtime.registry.names
|
|
11
|
+
DNS_LABEL = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
|
|
12
|
+
DNS_NAME = /\A#{DNS_LABEL}(?:\.#{DNS_LABEL})*\.?\z/
|
|
13
|
+
RULES = {
|
|
14
|
+
'version' => :version,
|
|
15
|
+
'requirements' => { 'empeira' => :requirement },
|
|
16
|
+
'runtime' => { 'container_engine' => :engine },
|
|
17
|
+
'modules' => { 'path' => :module_path },
|
|
18
|
+
'addons' => :addons,
|
|
19
|
+
'server' => { 'runtime' => :server_runtime, 'mounts' => :server_mounts,
|
|
20
|
+
'environment' => :environment,
|
|
21
|
+
'memory' => :positive_integer, 'cpus' => :positive_integer, 'timeout' => :positive_integer },
|
|
22
|
+
'puppetdb' => { 'enabled' => :boolean, 'memory' => :positive_integer, 'runtime' => :puppetdb_runtime },
|
|
23
|
+
'network' => { 'egress' => :network_egress, 'redirects' => :network_redirects },
|
|
24
|
+
'proxy' => { 'enabled' => :boolean, 'global' => :allowlist, 'rules' => :proxy_rules },
|
|
25
|
+
'vm' => { 'disk' => :vm_disk, 'interfaces' => :vm_interfaces,
|
|
26
|
+
'console' => { 'root_password' => :optional_string } },
|
|
27
|
+
'dns' => { 'upstream' => { 'mode' => :dns_mode, 'servers' => :dns_servers },
|
|
28
|
+
'additional_resolver' => :additional_resolver, 'rewrites' => :dns_rewrites },
|
|
29
|
+
'node_defaults' => { 'init' => :node_init, 'os' => :optional_string, 'version' => :optional_string,
|
|
30
|
+
'memory' => :positive_integer, 'cpus' => :positive_integer },
|
|
31
|
+
'mocks' => { 'commands' => :command_mocks },
|
|
32
|
+
'hiera' => { 'mounts' => :hiera_mounts },
|
|
33
|
+
'eyaml' => { 'enabled' => :boolean, 'private_key' => :optional_string, 'public_key' => :optional_string },
|
|
34
|
+
'images' => { 'registry' => :registry, 'network_adapter_builder' => :image,
|
|
35
|
+
'server' => :image, 'puppetdb' => :image, 'postgres' => :image,
|
|
36
|
+
'dns' => :image, 'proxy' => :image, 'direct_egress' => :image,
|
|
37
|
+
'relay' => :image, 'r10k' => :image,
|
|
38
|
+
'nodes' => :node_images },
|
|
39
|
+
'agent' => :agent,
|
|
40
|
+
'bootstrap' => { 'enabled' => :boolean, 'scripts' => :bootstrap_scripts,
|
|
41
|
+
'packages' => :bootstrap_packages, 'guests' => :bootstrap_guests },
|
|
42
|
+
'browser' => { 'start_url' => :browser_start_url,
|
|
43
|
+
'image' => { 'repository' => :image_repository, 'tag' => :image_tag } },
|
|
44
|
+
'containers' => { 'additional' => :additional_services }
|
|
45
|
+
}.tap { |rules| Immutable.deep_freeze(rules) }
|
|
46
|
+
|
|
47
|
+
# Schema fields containing secrets or opaque user-supplied content. Never export
|
|
48
|
+
# these to addons, even when their nested keys do not resemble secret names.
|
|
49
|
+
ADDON_PRIVATE_PATHS = [
|
|
50
|
+
%w[vm console root_password], %w[eyaml private_key], %w[browser start_url],
|
|
51
|
+
%w[server runtime startup arguments], %w[puppetdb runtime java_arguments],
|
|
52
|
+
%w[puppetdb runtime main_arguments], %w[containers additional * environment],
|
|
53
|
+
%w[containers additional * command], %w[containers additional * configuration content],
|
|
54
|
+
%w[addons * config]
|
|
55
|
+
].tap { |paths| Immutable.deep_freeze(paths) }
|
|
56
|
+
|
|
57
|
+
CHECKS = {
|
|
58
|
+
registry: [Images::Reference.method(:registry?),
|
|
59
|
+
'must be null or a registry host with a dot, port or localhost (no scheme or path)'],
|
|
60
|
+
browser_start_url: [->(v) { v.is_a?(String) && !v.strip.empty? && !v.match?(/\p{Cc}/) },
|
|
61
|
+
'must be a nonempty string without control characters'],
|
|
62
|
+
module_path: [->(v) { v.is_a?(String) && !v.strip.empty? && !v.match?(/[\x00-\x1f\x7f,]/) },
|
|
63
|
+
'must be a nonempty directory path without commas or control characters'],
|
|
64
|
+
image_repository: [->(v) { v.is_a?(String) && v.match?(Images::Configuration::REPOSITORY) },
|
|
65
|
+
'must be an image repository'],
|
|
66
|
+
image_tag: [->(v) { v.is_a?(String) && v.match?(Images::Configuration::TAG) }, 'must be an image tag'],
|
|
67
|
+
node_init: [->(v) { %w[process systemd].include?(v) }, 'must be process or systemd'],
|
|
68
|
+
dns_mode: [->(v) { %w[host explicit].include?(v) }, 'must be host or explicit'],
|
|
69
|
+
dns_servers: [lambda do |v|
|
|
70
|
+
v.is_a?(Array) && v.all? do |address|
|
|
71
|
+
address.is_a?(String) && IPAddr.new(address).ipv4? && IPAddr.new(address).to_s == address
|
|
72
|
+
end
|
|
73
|
+
rescue IPAddr::InvalidAddressError
|
|
74
|
+
false
|
|
75
|
+
end, 'must contain canonical IPv4 DNS resolver addresses; IPv6 is not supported'],
|
|
76
|
+
additional_resolver: [lambda do |v|
|
|
77
|
+
v.nil? || (v.is_a?(String) && v.size <= 253 &&
|
|
78
|
+
((v.match?(DNS_NAME) && !v.match?(/\A[\d.]+\z/)) || (IPAddr.new(v).ipv4? && IPAddr.new(v).to_s == v)))
|
|
79
|
+
rescue IPAddr::InvalidAddressError
|
|
80
|
+
false
|
|
81
|
+
end, 'must be null, a DNS name, or a canonical IPv4 address; IPv6 is not supported'],
|
|
82
|
+
environment: [lambda { |v|
|
|
83
|
+
v.is_a?(String) && v.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
84
|
+
}, 'must be a Puppet environment name'],
|
|
85
|
+
allowlist: [->(v) { v.is_a?(Array) && v.all? { |d| Network::ProxyPolicy.valid_domain?(d) } },
|
|
86
|
+
'must contain lowercase DNS domains or *.domain entries (no URLs, IPs or whitespace)'],
|
|
87
|
+
bootstrap_scripts: [lambda { |v|
|
|
88
|
+
v.is_a?(Array) && v.all? do |path|
|
|
89
|
+
path.is_a?(String) && !path.empty? && !path.start_with?('/') &&
|
|
90
|
+
!path.include?("\0") && !path.split('/').intersect?(%w[. ..])
|
|
91
|
+
end
|
|
92
|
+
}, 'must list relative local script paths within the project'],
|
|
93
|
+
version: [->(v) { v.is_a?(Integer) && v == 1 }, 'must be the supported schema version 1'],
|
|
94
|
+
engine: [->(v) { CONTAINER_ENGINES.include?(v) }, "must be one of: #{CONTAINER_ENGINES.join(', ')}"],
|
|
95
|
+
boolean: [->(v) { [true, false].include?(v) }, 'must be a boolean'],
|
|
96
|
+
optional_string: [lambda { |v|
|
|
97
|
+
v.nil? || (v.is_a?(String) && !v.strip.empty? && v == v.strip)
|
|
98
|
+
}, 'must be a non-empty string or null (without surrounding whitespace)'],
|
|
99
|
+
positive_integer: [->(v) { v.is_a?(Integer) && v.positive? }, 'must be a positive integer'],
|
|
100
|
+
vm_disk: [->(v) { v.is_a?(Integer) && (1..2048).cover?(v) },
|
|
101
|
+
'must be an integer from 1 to 2048 GiB']
|
|
102
|
+
}.tap { |checks| Immutable.deep_freeze(checks) }
|
|
103
|
+
|
|
104
|
+
def initialize(cross_field_rules: [])
|
|
105
|
+
@cross_field_rules = cross_field_rules.dup.freeze
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate_effective!(data)
|
|
109
|
+
validate_fragment!(data)
|
|
110
|
+
require_keys!(data, RULES, [])
|
|
111
|
+
CommandMocks.validate!(data.dig('mocks', 'commands'), 'mocks.commands', effective: true)
|
|
112
|
+
Addons.validate!(data.fetch('addons'), effective: true)
|
|
113
|
+
validate_operational!(data)
|
|
114
|
+
@cross_field_rules.each { |rule| rule.call(data) }
|
|
115
|
+
data
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_fragment!(data)
|
|
119
|
+
validate_mapping!(data, RULES, [])
|
|
120
|
+
data
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def validate_operational!(data)
|
|
126
|
+
validate_images!(data.fetch('images'))
|
|
127
|
+
NodeImages.effective!(data.fetch('images'))
|
|
128
|
+
Server::Runtime.resolve(data)
|
|
129
|
+
Server::PuppetDBRuntime.resolve(data)
|
|
130
|
+
validate_dns!(data.dig('dns', 'upstream'))
|
|
131
|
+
validate_bootstrap!(data.fetch('bootstrap'))
|
|
132
|
+
AgentSchema.effective!(data.fetch('agent'))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def validate_bootstrap!(bootstrap)
|
|
136
|
+
BootstrapGuests.effective!(bootstrap.fetch('guests'))
|
|
137
|
+
Node::PackageBootstrap.validate!(bootstrap.fetch('packages'), 'bootstrap.packages', effective: true)
|
|
138
|
+
return if bootstrap.fetch('enabled') || bootstrap.fetch('scripts').empty?
|
|
139
|
+
|
|
140
|
+
raise ConfigurationError, 'bootstrap.scripts requires bootstrap.enabled=true'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def validate_dns!(upstream)
|
|
144
|
+
return unless (upstream['mode'] == 'explicit') == upstream['servers'].empty?
|
|
145
|
+
|
|
146
|
+
raise ConfigurationError, 'dns.upstream.servers must be nonempty in explicit mode and empty in host mode'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_images!(images)
|
|
150
|
+
%w[server puppetdb postgres dns proxy direct_egress relay r10k network_adapter_builder].each do |key|
|
|
151
|
+
ImageSchema.validate!(images[key], "images.#{key}", effective: true)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def require_keys!(data, rules, path)
|
|
156
|
+
rules.each do |key, rule|
|
|
157
|
+
child_path = path + [key]
|
|
158
|
+
raise ConfigurationError, "#{child_path.join('.')} is required" unless data.key?(key)
|
|
159
|
+
|
|
160
|
+
require_keys!(data.fetch(key), rule, child_path) if rule.is_a?(Hash)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_mapping!(data, rules, path)
|
|
165
|
+
location = path.empty? ? '$' : path.join('.')
|
|
166
|
+
raise ConfigurationError, "#{location} must be a mapping" unless data.is_a?(Hash)
|
|
167
|
+
|
|
168
|
+
data.each do |key, value|
|
|
169
|
+
# Do not echo untrusted keys: they may contain credentials or YAML values.
|
|
170
|
+
raise ConfigurationError, "#{location} contains a non-string key" unless key.is_a?(String)
|
|
171
|
+
|
|
172
|
+
child_path = path + [key]
|
|
173
|
+
unless rules.key?(key)
|
|
174
|
+
if child_path == %w[network internet]
|
|
175
|
+
validate_value!(value, :boolean, child_path)
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
raise ConfigurationError,
|
|
180
|
+
"#{child_path.join('.')} is not a supported configuration key"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
validate_value!(value, rules.fetch(key), child_path)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# rubocop:disable-next Metrics/AbcSize -- One schema-to-validator dispatch table.
|
|
188
|
+
def structured_validator(rule)
|
|
189
|
+
{ additional_services: AdditionalServices.method(:validate!), proxy_rules: Network::HostPolicy.method(:validate!),
|
|
190
|
+
hiera_mounts: HieraMountSchema.method(:validate!), command_mocks: CommandMocks.method(:validate!),
|
|
191
|
+
server_mounts: ServerMounts.method(:validate!), dns_rewrites: DNSRewrites.method(:validate!),
|
|
192
|
+
image: ImageSchema.method(:validate!), addons: Addons.method(:validate!),
|
|
193
|
+
node_images: NodeImages.method(:nodes),
|
|
194
|
+
agent: AgentSchema.method(:validate!), bootstrap_guests: BootstrapGuests.method(:validate!),
|
|
195
|
+
bootstrap_packages: Node::PackageBootstrap.method(:validate!),
|
|
196
|
+
network_egress: Network::DirectEgress.method(:validate!),
|
|
197
|
+
network_redirects: NetworkRedirects.method(:validate!),
|
|
198
|
+
vm_interfaces: VMInterfaces.method(:validate!),
|
|
199
|
+
server_runtime: Server::Runtime.method(:validate!),
|
|
200
|
+
puppetdb_runtime: Server::PuppetDBRuntime.method(:validate!) }[rule]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def validate_value!(value, rule, path)
|
|
204
|
+
return validate_mapping!(value, rule, path) if rule.is_a?(Hash)
|
|
205
|
+
return VersionRequirement.parse(value) if rule == :requirement
|
|
206
|
+
|
|
207
|
+
validator = structured_validator(rule)
|
|
208
|
+
return validator.call(value, path.join('.')) if validator
|
|
209
|
+
|
|
210
|
+
check, message = CHECKS.fetch(rule)
|
|
211
|
+
raise ConfigurationError, "#{path.join('.')} #{message}" unless check.call(value)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'find'
|
|
4
|
+
|
|
5
|
+
module Empeira
|
|
6
|
+
module Configuration
|
|
7
|
+
# Resolve explicit host data without exposing runtime-specific mount options.
|
|
8
|
+
class ServerMounts
|
|
9
|
+
FIELDS = %w[source target readonly].freeze
|
|
10
|
+
RESERVED = %w[/dev /proc /sys].freeze
|
|
11
|
+
|
|
12
|
+
def self.validate!(entries, path = 'server.mounts')
|
|
13
|
+
raise ConfigurationError, "#{path} must be an array of mounts" unless entries.is_a?(Array)
|
|
14
|
+
|
|
15
|
+
targets = entries.each_with_index.map do |entry, index|
|
|
16
|
+
validate_entry!(entry, "#{path}[#{index}]")
|
|
17
|
+
Pathname(entry.fetch('target')).cleanpath.to_s
|
|
18
|
+
end
|
|
19
|
+
targets.each_with_index do |target, index|
|
|
20
|
+
next unless targets.take(index).any? { |other| overlap?(target, other) }
|
|
21
|
+
|
|
22
|
+
raise ConfigurationError, "#{path}[#{index}].target overlaps another server mount target"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.safe_path?(value)
|
|
27
|
+
value.is_a?(String) && !value.strip.empty? && !value.match?(/[\p{Cc},]/)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.overlap?(left, right)
|
|
31
|
+
left == '/' || right == '/' || HieraMountSchema.overlap?(left, right)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- Validate the small public mapping together.
|
|
35
|
+
def self.validate_entry!(entry, path)
|
|
36
|
+
raise ConfigurationError, "#{path} must be a mount mapping" unless entry.is_a?(Hash)
|
|
37
|
+
unless (entry.keys - FIELDS).empty?
|
|
38
|
+
raise ConfigurationError,
|
|
39
|
+
"#{path} only supports source, target and readonly"
|
|
40
|
+
end
|
|
41
|
+
raise ConfigurationError, "#{path}.source must be a safe local path" unless safe_path?(entry['source'])
|
|
42
|
+
|
|
43
|
+
target = entry['target']
|
|
44
|
+
unless safe_path?(target) && Pathname(target).absolute? && !target.split('/').include?('..')
|
|
45
|
+
raise ConfigurationError, "#{path}.target must be an absolute path without .., commas or control characters"
|
|
46
|
+
end
|
|
47
|
+
if RESERVED.any? { |reserved| overlap?(Pathname(target).cleanpath.to_s, reserved) }
|
|
48
|
+
raise ConfigurationError, "#{path}.target must not cover /, /dev, /proc or /sys"
|
|
49
|
+
end
|
|
50
|
+
return unless entry.key?('readonly') && ![true, false].include?(entry['readonly'])
|
|
51
|
+
|
|
52
|
+
raise ConfigurationError, "#{path}.readonly must be a boolean"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
attr_reader :entries
|
|
56
|
+
|
|
57
|
+
def initialize(entries:, project:)
|
|
58
|
+
self.class.validate!(entries)
|
|
59
|
+
@entries = entries.each_with_index.map do |entry, index|
|
|
60
|
+
{ 'source' => resolve(entry.fetch('source'), project, index),
|
|
61
|
+
'target' => Pathname(entry.fetch('target')).cleanpath.to_s,
|
|
62
|
+
'readonly' => entry.fetch('readonly', true) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def verify_targets!(managed_targets)
|
|
67
|
+
entries.each_with_index do |entry, index|
|
|
68
|
+
next unless managed_targets.any? { |target| self.class.overlap?(entry.fetch('target'), target) }
|
|
69
|
+
|
|
70
|
+
raise ConfigurationError, "server.mounts[#{index}].target overlaps an Empeira-managed server mount"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def resolve(value, project, index)
|
|
77
|
+
source = Pathname(value).expand_path(project).realpath
|
|
78
|
+
unless data_source?(source)
|
|
79
|
+
raise ConfigurationError, "server.mounts[#{index}].source must resolve to a regular file or data directory"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
verify_data_tree!(source, index) if source.directory?
|
|
83
|
+
source.to_s
|
|
84
|
+
rescue SystemCallError
|
|
85
|
+
raise ConfigurationError, "server.mounts[#{index}].source must exist and allow canonical resolution",
|
|
86
|
+
cause: nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def data_source?(source)
|
|
90
|
+
self.class.safe_path?(source.to_s) && (source.file? || source.directory?) &&
|
|
91
|
+
RESERVED.none? { |reserved| self.class.overlap?(source.to_s, reserved) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def verify_data_tree!(source, index)
|
|
95
|
+
# Do not follow directory symlinks or read file contents. Reject socket/device directories too.
|
|
96
|
+
Find.find(source.to_s, ignore_error: false) do |path|
|
|
97
|
+
stat = File.lstat(path)
|
|
98
|
+
next if stat.file? || stat.directory? || stat.symlink?
|
|
99
|
+
|
|
100
|
+
raise ConfigurationError,
|
|
101
|
+
"server.mounts[#{index}].source must not expose sockets, devices or other special files"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Empeira
|
|
4
|
+
module Configuration
|
|
5
|
+
class ServiceConfiguration
|
|
6
|
+
RUNTIME_TARGETS = %w[/etc/hosts /etc/hostname /etc/resolv.conf].freeze
|
|
7
|
+
|
|
8
|
+
def self.validate!(value, path)
|
|
9
|
+
unless value.is_a?(Hash) && value.size == 2 && %w[content target].all? { |key| value.key?(key) }
|
|
10
|
+
raise ConfigurationError, "#{path} must contain only target and content"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
target = value['target']
|
|
14
|
+
unless safe_target?(target)
|
|
15
|
+
raise ConfigurationError, "#{path}.target must be a safe absolute file path without traversal"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
validate_targets!(target, RUNTIME_TARGETS, path)
|
|
19
|
+
serialize(value['content'], path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.safe_target?(target)
|
|
23
|
+
target.is_a?(String) && target.start_with?('/') && HieraMountSchema.safe_target?(target.delete_prefix('/'))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.validate_targets!(target, targets, path, field: 'target')
|
|
27
|
+
return unless targets.any? { |other| other == '/' || HieraMountSchema.overlap?(target, other) }
|
|
28
|
+
|
|
29
|
+
raise ConfigurationError, "#{path}.#{field} overlaps an existing managed mount target"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.serialize(content, path)
|
|
33
|
+
YAML.safe_dump(content, permitted_classes: [], permitted_symbols: [], aliases: false, line_width: -1)
|
|
34
|
+
rescue Psych::Exception
|
|
35
|
+
raise ConfigurationError, "#{path}.content must contain only YAML data without objects, tags or aliases",
|
|
36
|
+
cause: nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Empeira
|
|
6
|
+
module Configuration
|
|
7
|
+
# Personal login preferences are resolved independently of infrastructure definitions.
|
|
8
|
+
class SSHPreferences
|
|
9
|
+
FIELDS = %w[user identity port].freeze
|
|
10
|
+
KEYS = [*FIELDS, 'rules'].freeze
|
|
11
|
+
USER = /\A[a-zA-Z0-9_.@-]+\z/
|
|
12
|
+
|
|
13
|
+
def self.validate!(data, path = 'ssh')
|
|
14
|
+
mapping!(data, KEYS, path)
|
|
15
|
+
fields!(data, path)
|
|
16
|
+
rules = data.fetch('rules', [])
|
|
17
|
+
raise ConfigurationError, "#{path}.rules must be an array" unless rules.is_a?(Array)
|
|
18
|
+
|
|
19
|
+
rules.each_with_index { |rule, index| rule!(rule, "#{path}.rules.#{index}") }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.mapping!(data, keys, path)
|
|
23
|
+
raise ConfigurationError, "#{path} must be a mapping" unless data.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
data.each_key do |key|
|
|
26
|
+
raise ConfigurationError, "#{path} contains a non-string key" unless key.is_a?(String)
|
|
27
|
+
raise ConfigurationError, "#{path}.#{key} is not a supported configuration key" unless keys.include?(key)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.fields!(data, path)
|
|
32
|
+
if data.key?('user') && !(data['user'].is_a?(String) && data['user'].match?(USER))
|
|
33
|
+
raise ConfigurationError, "#{path}.user must be a valid login name"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
identity!(data['identity'], path) if data.key?('identity')
|
|
37
|
+
port!(data['port'], "#{path}.port") if data.key?('port')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.port!(value, path = 'SSH port')
|
|
41
|
+
return value if value.is_a?(Integer) && value.between?(1, 65_535)
|
|
42
|
+
|
|
43
|
+
raise ConfigurationError, "#{path} must be an integer from 1 to 65535"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.identity!(value, path)
|
|
47
|
+
return if value.is_a?(String) && !value.match?(/[\p{Cc}\p{Cf}]/) &&
|
|
48
|
+
(value.start_with?('~/') || Pathname(value).absolute?)
|
|
49
|
+
|
|
50
|
+
raise ConfigurationError, "#{path}.identity must be an absolute path or start with ~/"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.rule!(rule, path)
|
|
54
|
+
mapping!(rule, [*FIELDS, 'hosts'], path)
|
|
55
|
+
fields!(rule, path)
|
|
56
|
+
unless rule.keys.intersect?(FIELDS)
|
|
57
|
+
raise ConfigurationError, "#{path} requires at least one of user, identity or port"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
hosts = rule['hosts']
|
|
61
|
+
return if hosts.is_a?(Array) && !hosts.empty? && hosts.all? { |pattern| HostnamePattern.valid?(pattern) }
|
|
62
|
+
|
|
63
|
+
raise ConfigurationError, "#{path}.hosts must contain full hostname globs using * and ?"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize(config = {})
|
|
67
|
+
@config = config
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity -- Three independent preference fields share one matcher.
|
|
71
|
+
def resolve(hostname:, user: nil, identity: nil, port: nil)
|
|
72
|
+
self.class.port!(port) unless port.nil?
|
|
73
|
+
fields = @config.slice(*FIELDS)
|
|
74
|
+
@config.fetch('rules', []).each do |rule|
|
|
75
|
+
fields.merge!(rule.slice(*FIELDS)) if rule.fetch('hosts').any? do |pattern|
|
|
76
|
+
HostnamePattern.matches?(pattern, hostname)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
{ user: user || fields['user'], identity: identity || fields['identity'], port: port || fields['port'] }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Empeira
|
|
4
|
+
module Configuration
|
|
5
|
+
# Host preferences only; project behavior remains defined by the control repository.
|
|
6
|
+
class User
|
|
7
|
+
KEYS = { 'runtime' => ['container_engine'].freeze, 'images' => ['registry'].freeze,
|
|
8
|
+
'ssh' => SSHPreferences::KEYS }.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(path:, schema: Schema.new)
|
|
11
|
+
@path = path
|
|
12
|
+
@schema = schema
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load
|
|
16
|
+
return {} unless @path.exist? || @path.symlink?
|
|
17
|
+
|
|
18
|
+
data = Document.new.read(@path)
|
|
19
|
+
validate_keys!(data)
|
|
20
|
+
SSHPreferences.validate!(data.fetch('ssh')) if data.key?('ssh')
|
|
21
|
+
@schema.validate_fragment!(data.except('ssh'))
|
|
22
|
+
data
|
|
23
|
+
rescue ConfigurationError => e
|
|
24
|
+
raise ConfigurationError, "#{@path}: #{e.message}", cause: nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def validate_keys!(data)
|
|
30
|
+
raise ConfigurationError, '$ must be a mapping' unless data.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
data.each do |section, values|
|
|
33
|
+
reject!(section) unless KEYS.key?(section)
|
|
34
|
+
raise ConfigurationError, "#{section} must be a mapping" unless values.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
values.each_key { |key| reject!("#{section}.#{key}") unless KEYS.fetch(section).include?(key) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reject!(path)
|
|
41
|
+
raise ConfigurationError, "#{path} is not allowed; user configuration may only configure " \
|
|
42
|
+
'runtime.container_engine, images.registry and ssh preferences'
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|