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.
Files changed (274) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +661 -0
  3. data/README.md +150 -0
  4. data/bin/empeira +18 -0
  5. data/config/defaults.yaml +229 -0
  6. data/docs/addons.md +331 -0
  7. data/docs/architecture.md +277 -0
  8. data/docs/configuration.md +1204 -0
  9. data/docs/development.md +472 -0
  10. data/docs/hiera-and-eyaml.md +361 -0
  11. data/docs/installation.md +280 -0
  12. data/docs/networking.md +362 -0
  13. data/docs/nodes.md +642 -0
  14. data/docs/proxy.md +136 -0
  15. data/docs/troubleshooting.md +361 -0
  16. data/lib/empeira/addons/environment.rb +82 -0
  17. data/lib/empeira/addons/error.rb +14 -0
  18. data/lib/empeira/addons/installation.rb +119 -0
  19. data/lib/empeira/addons/manifest.rb +112 -0
  20. data/lib/empeira/addons/service.rb +50 -0
  21. data/lib/empeira/addons/storage.rb +94 -0
  22. data/lib/empeira/agent/acquisition.rb +120 -0
  23. data/lib/empeira/agent/artifact.rb +41 -0
  24. data/lib/empeira/agent/authentication.rb +122 -0
  25. data/lib/empeira/agent/cache.rb +122 -0
  26. data/lib/empeira/agent/cache_storage.rb +82 -0
  27. data/lib/empeira/agent/download.rb +84 -0
  28. data/lib/empeira/agent/native_commands.rb +84 -0
  29. data/lib/empeira/agent/release_sources.rb +64 -0
  30. data/lib/empeira/agent/target.rb +66 -0
  31. data/lib/empeira/agent/versions.rb +29 -0
  32. data/lib/empeira/application.rb +86 -0
  33. data/lib/empeira/build.json +5 -0
  34. data/lib/empeira/build_info.rb +46 -0
  35. data/lib/empeira/cli/addons.rb +45 -0
  36. data/lib/empeira/cli/base.rb +73 -0
  37. data/lib/empeira/cli/completion.rb +42 -0
  38. data/lib/empeira/cli/config.rb +22 -0
  39. data/lib/empeira/cli/main.rb +114 -0
  40. data/lib/empeira/cli/node.rb +87 -0
  41. data/lib/empeira/cli/progress.rb +169 -0
  42. data/lib/empeira/cli/proxy.rb +36 -0
  43. data/lib/empeira/cli/update.rb +23 -0
  44. data/lib/empeira/completion/addons.rb +93 -0
  45. data/lib/empeira/completion/bash.rb +62 -0
  46. data/lib/empeira/completion/catalog.rb +170 -0
  47. data/lib/empeira/configuration/additional_services.rb +74 -0
  48. data/lib/empeira/configuration/addons.rb +85 -0
  49. data/lib/empeira/configuration/agent_schema.rb +163 -0
  50. data/lib/empeira/configuration/agent_values.rb +54 -0
  51. data/lib/empeira/configuration/bootstrap_guests.rb +42 -0
  52. data/lib/empeira/configuration/command_mocks.rb +65 -0
  53. data/lib/empeira/configuration/display.rb +20 -0
  54. data/lib/empeira/configuration/dns_rewrites.rb +58 -0
  55. data/lib/empeira/configuration/document.rb +67 -0
  56. data/lib/empeira/configuration/eyaml.rb +46 -0
  57. data/lib/empeira/configuration/hiera_modulepath.rb +40 -0
  58. data/lib/empeira/configuration/hiera_mount_schema.rb +62 -0
  59. data/lib/empeira/configuration/hiera_mounts.rb +83 -0
  60. data/lib/empeira/configuration/image_schema.rb +68 -0
  61. data/lib/empeira/configuration/loader.rb +70 -0
  62. data/lib/empeira/configuration/merge.rb +48 -0
  63. data/lib/empeira/configuration/network_redirects.rb +73 -0
  64. data/lib/empeira/configuration/node_images.rb +64 -0
  65. data/lib/empeira/configuration/schema.rb +215 -0
  66. data/lib/empeira/configuration/server_mounts.rb +106 -0
  67. data/lib/empeira/configuration/service_configuration.rb +40 -0
  68. data/lib/empeira/configuration/ssh_preferences.rb +83 -0
  69. data/lib/empeira/configuration/user.rb +46 -0
  70. data/lib/empeira/configuration/vm_interfaces.rb +147 -0
  71. data/lib/empeira/control_plane/additional_configurations.rb +106 -0
  72. data/lib/empeira/control_plane/browser.rb +73 -0
  73. data/lib/empeira/control_plane/controller.rb +763 -0
  74. data/lib/empeira/control_plane/discovery.rb +69 -0
  75. data/lib/empeira/control_plane/environment.rb +101 -0
  76. data/lib/empeira/control_plane/files.rb +263 -0
  77. data/lib/empeira/control_plane/health.rb +104 -0
  78. data/lib/empeira/control_plane/plan.rb +356 -0
  79. data/lib/empeira/control_plane/storage.rb +48 -0
  80. data/lib/empeira/errors.rb +9 -0
  81. data/lib/empeira/execution/console.rb +45 -0
  82. data/lib/empeira/execution/diagnostics.rb +210 -0
  83. data/lib/empeira/execution/launch_error.rb +14 -0
  84. data/lib/empeira/execution/result.rb +11 -0
  85. data/lib/empeira/execution/runner.rb +235 -0
  86. data/lib/empeira/execution/terminal.rb +35 -0
  87. data/lib/empeira/hostname_pattern.rb +14 -0
  88. data/lib/empeira/images/configuration.rb +44 -0
  89. data/lib/empeira/images/identity.rb +30 -0
  90. data/lib/empeira/images/recipe.rb +22 -0
  91. data/lib/empeira/images/reference.rb +43 -0
  92. data/lib/empeira/images/source.rb +19 -0
  93. data/lib/empeira/immutable.rb +19 -0
  94. data/lib/empeira/infrastructure/cleanup.rb +328 -0
  95. data/lib/empeira/infrastructure/definition.rb +59 -0
  96. data/lib/empeira/infrastructure/owned_files.rb +63 -0
  97. data/lib/empeira/infrastructure/recovery_inventory.rb +53 -0
  98. data/lib/empeira/infrastructure/service.rb +237 -0
  99. data/lib/empeira/infrastructure/status.rb +73 -0
  100. data/lib/empeira/infrastructure/store.rb +274 -0
  101. data/lib/empeira/modules/events.rb +67 -0
  102. data/lib/empeira/modules/git_safety.rb +60 -0
  103. data/lib/empeira/modules/host_git.rb +91 -0
  104. data/lib/empeira/modules/image.rb +27 -0
  105. data/lib/empeira/modules/installer.rb +67 -0
  106. data/lib/empeira/modules/request.rb +68 -0
  107. data/lib/empeira/modules/state.rb +87 -0
  108. data/lib/empeira/modules/storage.rb +35 -0
  109. data/lib/empeira/network/bootstrap_proxy.rb +157 -0
  110. data/lib/empeira/network/definition.rb +61 -0
  111. data/lib/empeira/network/direct_egress.rb +133 -0
  112. data/lib/empeira/network/egress.rb +27 -0
  113. data/lib/empeira/network/gateway.rb +79 -0
  114. data/lib/empeira/network/host_policy.rb +53 -0
  115. data/lib/empeira/network/naming.rb +28 -0
  116. data/lib/empeira/network/peer/adapter_image.rb +32 -0
  117. data/lib/empeira/network/peer/allocation.rb +45 -0
  118. data/lib/empeira/network/peer/attachment.rb +27 -0
  119. data/lib/empeira/network/peer/backend.rb +108 -0
  120. data/lib/empeira/network/peer/channel.rb +133 -0
  121. data/lib/empeira/network/peer/channel_server.rb +71 -0
  122. data/lib/empeira/network/peer/docker_adapter.rb +165 -0
  123. data/lib/empeira/network/peer/helper_security.rb +40 -0
  124. data/lib/empeira/network/peer/layout.rb +55 -0
  125. data/lib/empeira/network/peer/linux_podman.rb +114 -0
  126. data/lib/empeira/network/peer/podman_machine.rb +112 -0
  127. data/lib/empeira/network/policy.rb +39 -0
  128. data/lib/empeira/network/proxy_bindings.rb +52 -0
  129. data/lib/empeira/network/proxy_policy.rb +64 -0
  130. data/lib/empeira/network/redirects.rb +89 -0
  131. data/lib/empeira/network/resource.rb +50 -0
  132. data/lib/empeira/node/agent_installer.rb +31 -0
  133. data/lib/empeira/node/agent_package.rb +125 -0
  134. data/lib/empeira/node/agent_repository.rb +160 -0
  135. data/lib/empeira/node/apt_configuration.rb +85 -0
  136. data/lib/empeira/node/bootstrap.rb +17 -0
  137. data/lib/empeira/node/certificates.rb +98 -0
  138. data/lib/empeira/node/command_mock.rb +38 -0
  139. data/lib/empeira/node/command_mocks.rb +114 -0
  140. data/lib/empeira/node/container.rb +134 -0
  141. data/lib/empeira/node/container_agent_bootstrap.rb +43 -0
  142. data/lib/empeira/node/container_bootstrap.rb +52 -0
  143. data/lib/empeira/node/container_guest.rb +42 -0
  144. data/lib/empeira/node/container_lifecycle.rb +145 -0
  145. data/lib/empeira/node/container_preparation.rb +144 -0
  146. data/lib/empeira/node/container_state.rb +129 -0
  147. data/lib/empeira/node/definition.rb +20 -0
  148. data/lib/empeira/node/diagnostic_command.rb +19 -0
  149. data/lib/empeira/node/dnf_agent_repository.rb +202 -0
  150. data/lib/empeira/node/external_fact.rb +21 -0
  151. data/lib/empeira/node/image.rb +38 -0
  152. data/lib/empeira/node/interactive_tools.rb +33 -0
  153. data/lib/empeira/node/interface.rb +18 -0
  154. data/lib/empeira/node/inventory.rb +143 -0
  155. data/lib/empeira/node/package_bootstrap.rb +129 -0
  156. data/lib/empeira/node/package_proxy.rb +102 -0
  157. data/lib/empeira/node/package_sources.rb +19 -0
  158. data/lib/empeira/node/puppet_command.rb +12 -0
  159. data/lib/empeira/node/rpm_configuration.rb +62 -0
  160. data/lib/empeira/node/run_request.rb +52 -0
  161. data/lib/empeira/node/runtime_proxy.rb +86 -0
  162. data/lib/empeira/node/service.rb +72 -0
  163. data/lib/empeira/node/ssh_client.rb +25 -0
  164. data/lib/empeira/node/ssh_credentials.rb +78 -0
  165. data/lib/empeira/node/ssh_endpoint.rb +58 -0
  166. data/lib/empeira/node/ssh_identity.rb +37 -0
  167. data/lib/empeira/node/user_ssh.rb +60 -0
  168. data/lib/empeira/node/vm.rb +89 -0
  169. data/lib/empeira/node/vm_bootstrap.rb +53 -0
  170. data/lib/empeira/node/vm_lifecycle.rb +212 -0
  171. data/lib/empeira/node/vm_preflight.rb +37 -0
  172. data/lib/empeira/node/vm_preparation.rb +161 -0
  173. data/lib/empeira/node/vm_state.rb +161 -0
  174. data/lib/empeira/platform/facts.rb +66 -0
  175. data/lib/empeira/platform/kvm.rb +91 -0
  176. data/lib/empeira/platform/locations.rb +51 -0
  177. data/lib/empeira/platform/resolvers.rb +85 -0
  178. data/lib/empeira/platform/routes.rb +50 -0
  179. data/lib/empeira/progress.rb +70 -0
  180. data/lib/empeira/project_root.rb +47 -0
  181. data/lib/empeira/providers/lifecycle.rb +96 -0
  182. data/lib/empeira/providers/registry.rb +21 -0
  183. data/lib/empeira/providers/resource.rb +32 -0
  184. data/lib/empeira/providers.rb +55 -0
  185. data/lib/empeira/runtime/agent_helper.rb +34 -0
  186. data/lib/empeira/runtime/container.rb +184 -0
  187. data/lib/empeira/runtime/diagnostic_execution.rb +52 -0
  188. data/lib/empeira/runtime/docker.rb +87 -0
  189. data/lib/empeira/runtime/image_metadata.rb +83 -0
  190. data/lib/empeira/runtime/interface.rb +8 -0
  191. data/lib/empeira/runtime/mount_probe.rb +57 -0
  192. data/lib/empeira/runtime/podman.rb +122 -0
  193. data/lib/empeira/runtime/recipe_build.rb +103 -0
  194. data/lib/empeira/runtime/registry_diagnostics.rb +23 -0
  195. data/lib/empeira/runtime/service_arguments.rb +55 -0
  196. data/lib/empeira/runtime/service_images.rb +118 -0
  197. data/lib/empeira/runtime/service_inspection.rb +159 -0
  198. data/lib/empeira/runtime/services.rb +120 -0
  199. data/lib/empeira/runtime/update_helper.rb +97 -0
  200. data/lib/empeira/runtime/workspace_network.rb +69 -0
  201. data/lib/empeira/server/environment_configuration.rb +53 -0
  202. data/lib/empeira/server/http.rb +38 -0
  203. data/lib/empeira/server/puppetdb_runtime.rb +74 -0
  204. data/lib/empeira/server/relay_certificate.rb +76 -0
  205. data/lib/empeira/server/runtime.rb +90 -0
  206. data/lib/empeira/services/definition.rb +42 -0
  207. data/lib/empeira/updates/images.rb +104 -0
  208. data/lib/empeira/updates/modules.rb +45 -0
  209. data/lib/empeira/updates/service.rb +45 -0
  210. data/lib/empeira/version_requirement.rb +23 -0
  211. data/lib/empeira/vm/agent.rb +66 -0
  212. data/lib/empeira/vm/bootstrap_requirements.rb +69 -0
  213. data/lib/empeira/vm/certificate_runtime.rb +30 -0
  214. data/lib/empeira/vm/cloud_init.rb +171 -0
  215. data/lib/empeira/vm/disk.rb +136 -0
  216. data/lib/empeira/vm/guest.rb +123 -0
  217. data/lib/empeira/vm/guest_channel.rb +110 -0
  218. data/lib/empeira/vm/guest_network.rb +94 -0
  219. data/lib/empeira/vm/guest_output.rb +52 -0
  220. data/lib/empeira/vm/guest_shell.rb +79 -0
  221. data/lib/empeira/vm/guest_upload.rb +43 -0
  222. data/lib/empeira/vm/image_cache.rb +77 -0
  223. data/lib/empeira/vm/image_source.rb +91 -0
  224. data/lib/empeira/vm/interface.rb +8 -0
  225. data/lib/empeira/vm/interface_ownership.rb +124 -0
  226. data/lib/empeira/vm/interface_routes.rb +101 -0
  227. data/lib/empeira/vm/interfaces.rb +240 -0
  228. data/lib/empeira/vm/management.rb +73 -0
  229. data/lib/empeira/vm/prerequisites.rb +42 -0
  230. data/lib/empeira/vm/qemu.rb +83 -0
  231. data/lib/empeira/vm/qemu_runtime.rb +404 -0
  232. data/lib/empeira/vm/removal.rb +30 -0
  233. data/lib/empeira/vm/root_disk.rb +92 -0
  234. data/lib/empeira/vm/session_lock.rb +56 -0
  235. data/lib/empeira/vm/system_ssh.rb +29 -0
  236. data/lib/empeira/workspace.rb +42 -0
  237. data/lib/empeira.rb +209 -0
  238. data/resources/browser/relay.rb +22 -0
  239. data/resources/gateway/Containerfile +8 -0
  240. data/resources/gateway/bridge.rb +66 -0
  241. data/resources/gateway/configure.rb +323 -0
  242. data/resources/modules/Containerfile +15 -0
  243. data/resources/modules/Gemfile +5 -0
  244. data/resources/modules/Gemfile.lock +105 -0
  245. data/resources/modules/worker.rb +161 -0
  246. data/resources/network/adapter/Containerfile +13 -0
  247. data/resources/network/adapter/bridge_linux.go +51 -0
  248. data/resources/network/adapter/framing.go +45 -0
  249. data/resources/network/adapter/framing_test.go +53 -0
  250. data/resources/network/adapter/go.mod +3 -0
  251. data/resources/network/adapter/main_linux.go +273 -0
  252. data/resources/network/channel.rb +18 -0
  253. data/resources/network/ssh_connect.rb +32 -0
  254. data/resources/nodes/apt_sources.rb +66 -0
  255. data/resources/nodes/debian/Containerfile +10 -0
  256. data/resources/nodes/diagnostic_exec.rb +109 -0
  257. data/resources/nodes/interactive_tools.rb +139 -0
  258. data/resources/nodes/managed_file.rb +91 -0
  259. data/resources/nodes/management/guest_agent.py +415 -0
  260. data/resources/nodes/management/setup.sh +20 -0
  261. data/resources/nodes/management/shell_rc.sh +12 -0
  262. data/resources/nodes/management/system_account.sh +24 -0
  263. data/resources/nodes/management/system_home.sh +15 -0
  264. data/resources/nodes/rpm/Containerfile +10 -0
  265. data/resources/nodes/runtime_proxy.rb +244 -0
  266. data/resources/nodes/ssh/proxy.rb +16 -0
  267. data/resources/nodes/ssh/sshd_config +15 -0
  268. data/resources/nodes/ssh/start +6 -0
  269. data/resources/proxy/Containerfile +10 -0
  270. data/resources/relay/Containerfile +5 -0
  271. data/resources/relay/puppetdb.rb +81 -0
  272. data/resources/relay/server_environment.rb +10 -0
  273. data/resources/relay/server_start.sh +41 -0
  274. 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