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,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+
5
+ module Empeira
6
+ module Configuration
7
+ # rubocop:disable-next Metrics/ClassLength -- Fragment validation and matching share one configuration contract.
8
+ class VMInterfaces
9
+ PARENT = 'empeira-vlan'
10
+ RESERVED = %w[lo eth0 peer].push(PARENT).freeze
11
+ UNSUITABLE = %w[0.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16
12
+ 192.0.0.0/24 192.88.99.0/24 224.0.0.0/3].map { |value| IPAddr.new(value) }.freeze
13
+
14
+ def self.validate!(rules, path = 'vm.interfaces')
15
+ raise ConfigurationError, "#{path} must be an array" unless rules.is_a?(Array)
16
+
17
+ rules.each_with_index { |rule, index| validate_rule!(rule, "#{path}[#{index}]") }
18
+ end
19
+
20
+ def self.validate_rule!(rule, path)
21
+ unless rule.is_a?(Hash) && rule.keys.sort_by(&:to_s) == %w[devices hosts]
22
+ raise ConfigurationError, "#{path} requires only hosts and devices"
23
+ end
24
+
25
+ hosts = rule.fetch('hosts')
26
+ raise ConfigurationError, "#{path}.hosts must contain hostname globs using * and ?" unless valid_hosts?(hosts)
27
+
28
+ validate_devices!(rule.fetch('devices'), path, hosts)
29
+ end
30
+
31
+ def self.valid_hosts?(hosts)
32
+ hosts.is_a?(Array) && !hosts.empty? && hosts.all? { |host| valid_pattern?(host) }
33
+ end
34
+
35
+ def self.validate_devices!(devices, path, hosts)
36
+ unless devices.is_a?(Hash) && !devices.empty?
37
+ raise ConfigurationError, "#{path}.devices must be a nonempty mapping"
38
+ end
39
+
40
+ devices.each do |name, definition|
41
+ device!(name, definition, "#{path}.devices.#{name}")
42
+ end
43
+ unique!(devices, path)
44
+ rescue ConfigurationError => e
45
+ raise ConfigurationError, "#{e.message} (hosts: #{hosts.join(', ')})", cause: nil
46
+ end
47
+
48
+ def self.valid_pattern?(value)
49
+ value.is_a?(String) && Network::HostPolicy.valid_glob?(value.downcase)
50
+ end
51
+
52
+ def self.device!(name, definition, path)
53
+ unless valid_name?(name)
54
+ raise ConfigurationError, "#{path}: interface must use 1-15 ASCII letters, digits, _, . or -, " \
55
+ 'start with a letter or digit, and not use a reserved Empeira name'
56
+ end
57
+ unless valid_definition_keys?(definition)
58
+ raise ConfigurationError, "#{path} requires network and permits only optional vlan_id"
59
+ end
60
+
61
+ network!(definition.fetch('network'), "#{path}.network")
62
+ return unless definition.key?('vlan_id')
63
+
64
+ vlan!(definition['vlan_id'], path)
65
+ end
66
+
67
+ def self.valid_definition_keys?(definition)
68
+ definition.is_a?(Hash) && definition.key?('network') && (definition.keys - %w[network vlan_id]).empty?
69
+ end
70
+
71
+ def self.vlan!(value, path)
72
+ return if value.is_a?(Integer) && value.between?(1, 4094)
73
+
74
+ raise ConfigurationError, "#{path}.vlan_id must be an integer from 1 through 4094"
75
+ end
76
+
77
+ def self.valid_name?(name)
78
+ name.is_a?(String) && name.bytesize.between?(1, 15) &&
79
+ name.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z/) && !RESERVED.include?(name)
80
+ end
81
+
82
+ def self.network!(value, path)
83
+ unless value.is_a?(String) && value.match?(%r{\A(?:\d{1,3}\.){3}\d{1,3}/(?:[0-9]|[12][0-9]|3[0-2])\z})
84
+ raise ConfigurationError, "#{path} must be a canonical IPv4 host address with CIDR prefix (IPv6 unsupported)"
85
+ end
86
+
87
+ address = IPAddr.new(value.split('/').first)
88
+ subnet = IPAddr.new(value)
89
+ valid = address.ipv4? && address.to_s == value.split('/').first && usable_host?(address, subnet)
90
+ raise ConfigurationError, "#{path} must be a usable unicast IPv4 host address and subnet" unless valid
91
+ rescue IPAddr::InvalidAddressError
92
+ raise ConfigurationError, "#{path} must be a valid IPv4 host address with CIDR prefix", cause: nil
93
+ end
94
+
95
+ def self.usable_host?(address, subnet)
96
+ UNSUITABLE.none? { |range| overlap?(range, subnet) } &&
97
+ (subnet.prefix >= 31 || ![subnet.to_range.first, subnet.to_range.last].include?(address))
98
+ end
99
+
100
+ def self.overlap?(left, right)
101
+ left.include?(right.to_range.first) || right.include?(left.to_range.first)
102
+ end
103
+
104
+ # One parent cannot carry two devices with the same VLAN ID.
105
+ def self.unique!(devices, path)
106
+ %w[network vlan_id].each { |key| unique_value!(devices, key, path) }
107
+ devices.to_a.combination(2) do |(left_name, left), (right_name, right)|
108
+ next unless overlap?(IPAddr.new(left.fetch('network')), IPAddr.new(right.fetch('network')))
109
+
110
+ raise ConfigurationError, "#{path}: interfaces #{left_name} and #{right_name} have overlapping networks"
111
+ end
112
+ end
113
+
114
+ def self.unique_value!(devices, key, path)
115
+ groups = devices.group_by { |_, definition| definition[key].to_s.split('/').first }
116
+ groups.each do |value, entries|
117
+ next if value.nil? || entries.size == 1
118
+
119
+ raise ConfigurationError, "#{path}: devices #{entries.map(&:first).join(', ')} have duplicate #{key} #{value}"
120
+ end
121
+ end
122
+
123
+ def self.resolve(rules, hostname)
124
+ devices = {}
125
+ paths = {}
126
+ rules.each_with_index do |rule, index|
127
+ next unless rule.fetch('hosts').any? { |pattern| Network::HostPolicy.matches?(pattern, hostname) }
128
+
129
+ rule.fetch('devices').each do |name, definition|
130
+ path = "vm.interfaces[#{index}].devices.#{name}"
131
+ compatible!(devices, name, definition, hostname, path, paths[name])
132
+ devices[name] = definition
133
+ paths[name] ||= path
134
+ end
135
+ end
136
+ unique!(devices, "vm.interfaces (host #{hostname})")
137
+ devices
138
+ end
139
+
140
+ def self.compatible!(devices, name, definition, hostname, path, previous)
141
+ return unless devices.key?(name) && devices[name] != definition
142
+
143
+ raise ConfigurationError, "Host #{hostname}, interface #{name}: #{path} conflicts with #{previous}"
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module ControlPlane
5
+ # Generated data stays outside service-wide directory mounts and control code.
6
+ class AdditionalConfigurations
7
+ attr_reader :directory
8
+
9
+ def initialize(context:)
10
+ @context = context
11
+ @workspace = context.locations.workspace(context.workspace)
12
+ @directory = @workspace.join('additional-configurations')
13
+ end
14
+
15
+ def path(entry)
16
+ directory.join("#{Digest::SHA256.hexdigest(entry.fetch('name'))}.yaml")
17
+ end
18
+
19
+ def options(entry, location:, mounts: [])
20
+ return {} unless entry.key?('configuration')
21
+
22
+ value = entry.fetch('configuration')
23
+ targets = mounts.map do |mount|
24
+ mount.split(',').find do |part|
25
+ part.start_with?('dst=')
26
+ end.to_s.delete_prefix('dst=')
27
+ end
28
+ Configuration::ServiceConfiguration.validate_targets!(value.fetch('target'), targets, location)
29
+ content = Configuration::ServiceConfiguration.serialize(value.fetch('content'), location)
30
+ { configuration: content,
31
+ mounts: [*mounts, "type=bind,src=#{path(entry)},dst=#{value.fetch('target')},readonly"] }
32
+ end
33
+
34
+ def prepare
35
+ return if entries.empty?
36
+
37
+ verify_directory!
38
+ FileUtils.mkdir_p(directory, mode: 0o700)
39
+ entries.each { |entry| write(entry) }
40
+ end
41
+
42
+ def prune
43
+ return unless directory.exist? || directory.symlink?
44
+
45
+ verify_directory!
46
+
47
+ expected = entries.map { |entry| path(entry) }
48
+ directory.children.each { |file| File.unlink(file) unless expected.include?(file) }
49
+ end
50
+
51
+ def cleanup
52
+ return unless directory.exist? || directory.symlink?
53
+
54
+ verify_directory!
55
+ FileUtils.remove_entry_secure(directory) if directory.exist?
56
+ end
57
+
58
+ private
59
+
60
+ def entries
61
+ @context.configuration.dig('containers', 'additional').select { |entry| entry.key?('configuration') }
62
+ end
63
+
64
+ def verify_directory!
65
+ state = @context.locations.state
66
+ [state, @workspace.parent, @workspace, directory].each do |parent|
67
+ raise Error, 'Additional configuration directory must not be a symlink' if parent.symlink?
68
+ end
69
+ verify_external!
70
+ raise Error, 'Additional configuration mount source is unsafe' if directory.to_s.match?(/[,\r\n]/)
71
+ end
72
+
73
+ def verify_external!
74
+ ancestor = directory
75
+ ancestor = ancestor.parent until ancestor.exist?
76
+ project = @context.project.path.to_s
77
+ return unless ancestor.realpath.to_s == project || ancestor.realpath.to_s.start_with?("#{project}/")
78
+
79
+ raise Error, 'Additional configuration files must be outside the control repository'
80
+ end
81
+
82
+ def write(entry)
83
+ destination = path(entry)
84
+ if destination.symlink? || (destination.exist? && !destination.file?)
85
+ raise Error, 'Additional configuration file must be a regular file, not a symlink'
86
+ end
87
+
88
+ content = Configuration::ServiceConfiguration.serialize(entry.dig('configuration', 'content'), 'configuration')
89
+ return if destination.file? && destination.binread == content
90
+
91
+ atomic_write(destination, content)
92
+ end
93
+
94
+ def atomic_write(destination, content)
95
+ Tempfile.create('configuration-', directory) do |file|
96
+ # The private workspace protects host access; arbitrary container UIDs need read access.
97
+ file.chmod(0o644)
98
+ file.write(content)
99
+ file.flush
100
+ file.fsync
101
+ File.rename(file.path, destination)
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module ControlPlane
5
+ # A fixed TCP UI relay publishes HTTPS without giving Chromium an egress attachment.
6
+ class Browser
7
+ def initialize(plan)
8
+ @plan = plan
9
+ end
10
+
11
+ def definitions(dns: nil)
12
+ { 'browser' => browser(dns), 'browser-ui' => gateway(dns) }
13
+ end
14
+
15
+ def browser(dns)
16
+ Services::Definition.new(key: 'browser', workspace: @plan.context.workspace,
17
+ hostname: @plan.naming.hostname('browser'), network: @plan.network, dns: dns,
18
+ image: Images::Configuration.reference(@plan.config.dig('browser', 'image'),
19
+ registry: @plan.config.dig('images',
20
+ 'registry')),
21
+ memory: 2048, cpus: 2, shm_size: '1g',
22
+ mounts: ['type=tmpfs,dst=/config'],
23
+ environment: { 'CHROME_CLI' => @plan.config.dig('browser', 'start_url'),
24
+ 'http_proxy' => '', 'https_proxy' => '', 'all_proxy' => '',
25
+ 'HTTP_PROXY' => '', 'HTTPS_PROXY' => '', 'ALL_PROXY' => '' })
26
+ end
27
+
28
+ # rubocop:disable-next Metrics/AbcSize -- Fixed relay options stay auditable together.
29
+ def gateway(dns)
30
+ relay = Pathname(__dir__).join('../../../resources/browser/relay.rb').realpath
31
+ access = Network::Egress.new(workspace: @plan.context.workspace, policy: Network::Policy.new)
32
+ Services::Definition.new(key: 'browser-ui', workspace: @plan.context.workspace,
33
+ hostname: @plan.naming.hostname('browser-ui'), network: access.backend_name, dns: dns,
34
+ **relay_image, memory: 96, cpus: 1,
35
+ entrypoint: 'ruby', command: ['/empeira-browser/relay.rb'],
36
+ configuration: File.read(relay),
37
+ sysctls: { 'net.ipv4.ip_forward' => '0' },
38
+ mounts: [@plan.bind(relay, '/empeira-browser/relay.rb'),
39
+ @plan.bind(@plan.files.path('browser-address'),
40
+ '/empeira-browser/address')],
41
+ ports: ['127.0.0.1::3001/tcp'])
42
+ end
43
+
44
+ def relay_image
45
+ Images::Configuration.artifact(
46
+ @plan.config.dig('images', 'relay'), purpose: 'relay', registry: @plan.config.dig('images', 'registry')
47
+ )
48
+ end
49
+
50
+ # rubocop:disable-next Metrics/AbcSize -- Compare both configured and observed publications.
51
+ def self.valid_ports?(resource)
52
+ configured = resource.fetch('ports')
53
+ published = resource.fetch('published_ports', {}).compact
54
+ return false unless configured.keys == ['3001/tcp']
55
+ return false unless published.keys == ['3001/tcp'] || resource['state'] != 'running'
56
+
57
+ requested = configured['3001/tcp']
58
+ return false unless Node::SSHEndpoint.loopback_binding?(requested)
59
+ return true unless resource['state'] == 'running'
60
+
61
+ actual = published['3001/tcp']
62
+ Node::SSHEndpoint.binding?(actual, nil) &&
63
+ ['', '0', actual.first['HostPort']].include?(requested.first['HostPort'])
64
+ end
65
+
66
+ def self.url(resource)
67
+ raise Providers::OwnershipError, 'Browser UI must publish only HTTPS on loopback' unless valid_ports?(resource)
68
+
69
+ "https://127.0.0.1:#{resource.fetch('published_ports').fetch('3001/tcp').first.fetch('HostPort')}/"
70
+ end
71
+ end
72
+ end
73
+ end