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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/wait'
4
+
5
+ module Empeira
6
+ module Network
7
+ module Peer
8
+ class ChannelServer
9
+ def initialize(path, runner: Execution::Runner.new)
10
+ @directory = Pathname(path).dirname
11
+ @config = JSON.parse(File.read(path))
12
+ @runner = runner
13
+ end
14
+
15
+ def run
16
+ File.umask(0o077)
17
+ control = @directory.join('control.sock')
18
+ @listener = UNIXServer.new(control)
19
+ File.chmod(0o600, control)
20
+ File.write(@directory.join('channel.pid'), Process.pid.to_s, mode: 'w', perm: 0o600)
21
+ @connection = UNIXSocket.new(@directory.join('ethernet.sock'))
22
+ @worker = Thread.new { forward }
23
+ serve
24
+ ensure
25
+ cleanup
26
+ end
27
+
28
+ private
29
+
30
+ def cleanup
31
+ begin
32
+ @connection&.shutdown
33
+ rescue StandardError
34
+ nil
35
+ end
36
+ @worker.kill.join if @worker && !@worker.join(10)
37
+ @listener&.close
38
+ FileUtils.rm_f(@directory.join('control.sock'))
39
+ FileUtils.rm_f(@directory.join('channel.pid'))
40
+ end
41
+
42
+ def forward
43
+ File.open(@directory.join('channel.log'), 'w', 0o600) do |log|
44
+ command = @config.fetch('command')
45
+ @result = @runner.stream(command.first, arguments: command.drop(1),
46
+ input: @connection, output: @connection, error: log)
47
+ end
48
+ end
49
+
50
+ def serve
51
+ loop do
52
+ break unless @worker.alive?
53
+ next unless @listener.wait_readable(0.2)
54
+
55
+ client = @listener.accept
56
+ begin
57
+ message = JSON.parse(client.gets(4096))
58
+ next unless message['token'] == @config.fetch('token')
59
+
60
+ client.puts(JSON.generate('token' => @config.fetch('token'), 'running' => @worker.alive?))
61
+ stop = message['operation'] == 'stop'
62
+ ensure
63
+ client.close
64
+ end
65
+ break if stop
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ module Peer
6
+ # The same whole-NIC transport serves local Docker and Docker Desktop.
7
+ # rubocop:disable-next Metrics/ClassLength -- Owned NIC and system SSH share adapter security checks.
8
+ class DockerAdapter < Backend
9
+ def preflight(state)
10
+ super
11
+ return if runtime.architecture == context.platform.architecture.to_s
12
+
13
+ raise Error, 'Peer adapter and VM architecture differ; emulation is not supported'
14
+ end
15
+
16
+ def prepare(record, state)
17
+ check_record(record)
18
+ channel(record).prepare
19
+ @image = AdapterImage.new(context: context)
20
+ @image.ensure!(runtime)
21
+ resource = ensure_adapter(record, state)
22
+ runtime.start_service(resource) unless resource['state'] == 'running'
23
+ verify_adapter(record)
24
+ store.write(state)
25
+ attach_bridge(resource)
26
+ end
27
+
28
+ def arguments(record)
29
+ ['-netdev', "stream,id=peer,addr.type=unix,addr.path=#{channel(record).socket},server=on",
30
+ '-device', "virtio-net-pci,netdev=peer,mac=#{record.fetch('mac_address')}"]
31
+ end
32
+
33
+ def launch(executable, arguments)
34
+ command(executable, *arguments)
35
+ end
36
+
37
+ def connect(record, _state)
38
+ File.chmod(0o600, channel(record).socket)
39
+ channel(record).start(packet_command(record))
40
+ end
41
+
42
+ def stop(record)
43
+ channel(record).stop
44
+ end
45
+
46
+ def destroy(record, state)
47
+ channel(record).destroy
48
+ runtime.remove_service(identity(record), expected_id: record.dig('peer', 'adapter_id'))
49
+ record.fetch('peer')['adapter_id'] = nil
50
+ store.write(state)
51
+ end
52
+
53
+ def healthy?(record)
54
+ channel(record).healthy?
55
+ end
56
+
57
+ def system_ssh_command(record, port:)
58
+ address = system_address(record, port)
59
+ @image = AdapterImage.new(context: context)
60
+ verify_adapter(record)
61
+ resource = runtime.inspect_service(identity(record), expected_id: record.dig('peer', 'adapter_id'))
62
+ verify_definition!(record, resource)
63
+ raise Providers::OwnershipError, 'VM peer channel is not active' unless healthy?(record)
64
+
65
+ source = system_source(resource, address)
66
+ [*connector_engine, 'exec', '-i', resource.fetch('id'), '/adapter', 'connect', address, port.to_s, source]
67
+ end
68
+
69
+ protected
70
+
71
+ def connector_engine
72
+ [runtime.name]
73
+ end
74
+
75
+ def system_source(resource, target)
76
+ source = resource.dig('networks', network_name, 'IPAddress')
77
+ unless source.is_a?(String) && IPAddr.new("#{target}/24").include?(IPAddr.new(source))
78
+ raise Providers::OwnershipError, 'Peer connector address is outside its owned subnet'
79
+ end
80
+
81
+ source
82
+ rescue IPAddr::InvalidAddressError
83
+ raise Providers::OwnershipError, 'Peer connector address is unavailable', cause: nil
84
+ end
85
+
86
+ def capless_adapter_definition(record)
87
+ Services::Definition.new(key: identity(record).key, workspace: context.workspace,
88
+ network: network_name, image: @image.image, memory: 64, cpus: 1,
89
+ command: ['hold'], read_only: true, cap_drop: ['ALL'],
90
+ security_options: ['no-new-privileges'])
91
+ end
92
+
93
+ def channel(record)
94
+ Channel.new(context: context, runner: runner, record: record)
95
+ end
96
+
97
+ def identity(record)
98
+ Services::Definition.new(key: "peer-#{record.fetch('peer').fetch('token')[0, 16]}",
99
+ workspace: context.workspace)
100
+ end
101
+
102
+ def adapter_definition(record)
103
+ Services::Definition.new(key: identity(record).key, workspace: context.workspace,
104
+ network: network_name, image: @image.image, memory: 64, cpus: 1,
105
+ command: ['hold'], read_only: true, cap_drop: ['ALL'], cap_add: ['NET_ADMIN'],
106
+ devices: ['/dev/net/tun'], security_options: ['no-new-privileges'],
107
+ sysctls: { 'net.ipv4.ip_forward' => '0', 'net.ipv6.conf.all.disable_ipv6' => '1',
108
+ 'net.ipv6.conf.default.disable_ipv6' => '1' })
109
+ end
110
+
111
+ def ensure_adapter(record, state)
112
+ expected = record.dig('peer', 'adapter_id')
113
+ resource = runtime.inspect_service(identity(record), expected_id: expected)
114
+ verify_definition!(record, resource) if resource
115
+ # The random per-instance token and adapter identity were persisted with the lease.
116
+ resource ||= runtime.create_service(adapter_definition(record))
117
+ record.fetch('peer')['adapter_id'] = resource.fetch('id')
118
+ store.write(state)
119
+ resource
120
+ end
121
+
122
+ def verify_definition!(record, resource)
123
+ return if resource.dig('labels', 'io.empeira.definition') == adapter_definition(record).fingerprint
124
+
125
+ raise Providers::OwnershipError, 'Unexpected peer adapter definition'
126
+ end
127
+
128
+ def packet_command(record)
129
+ ['docker', 'exec', '-i', record.fetch('peer').fetch('adapter_id'), '/adapter', 'tap', tap(record), 'peerbr']
130
+ end
131
+
132
+ def validate_exposure!(resource)
133
+ unless resource && resource.fetch('networks').keys == [network_name] && resource.fetch('ports').empty? &&
134
+ resource.fetch('published_ports').values.all?(&:nil?)
135
+ raise Providers::OwnershipError, 'Peer helper has unexpected network or port exposure'
136
+ end
137
+ end
138
+
139
+ def attach_bridge(resource)
140
+ result = runtime.service_exec(resource, %w[/adapter bridge peerbr eth0])
141
+ raise Error, 'Cannot attach the Docker peer adapter bridge' unless result.success?
142
+ end
143
+
144
+ def binary_digest(resource)
145
+ result = runtime.service_exec(resource, %w[/adapter sha256])
146
+ digest = result.stdout.strip
147
+ return digest if result.success? && digest.match?(/\A[0-9a-f]{64}\z/)
148
+
149
+ raise Error, 'Cannot verify peer adapter binary integrity'
150
+ end
151
+
152
+ def verify_adapter(record)
153
+ resource = runtime.inspect_service(identity(record), expected_id: record.dig('peer', 'adapter_id'))
154
+ validate_exposure!(resource)
155
+ HelperSecurity.verify!(resource, privileged_tap: key == 'DockerAdapter')
156
+ digest = binary_digest(resource)
157
+ expected = record.dig('peer', 'helper_sha256')
158
+ raise Providers::OwnershipError, 'Peer helper checksum changed' if expected && expected != digest
159
+
160
+ record.fetch('peer')['helper_sha256'] = digest
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ module Peer
6
+ module HelperSecurity
7
+ def self.verify!(resource, privileged_tap:)
8
+ config = resource.fetch('host_config')
9
+ return if base_valid?(config) && resource.fetch('mounts').empty? &&
10
+ capabilities_valid?(config, privileged_tap) && devices_valid?(config, privileged_tap)
11
+
12
+ raise Providers::OwnershipError, 'Peer helper privileges or mounts differ from the managed definition'
13
+ end
14
+
15
+ def self.base_valid?(config)
16
+ config['Privileged'] == false && config['ReadonlyRootfs'] == true &&
17
+ Array(config['SecurityOpt']).include?('no-new-privileges')
18
+ end
19
+
20
+ def self.capabilities_valid?(config, tap)
21
+ caps = Array(config['CapAdd']).map { |cap| cap.delete_prefix('CAP_') }
22
+ dropped = Array(config['CapDrop'])
23
+ return dropped == ['ALL'] && caps == ['NET_ADMIN'] if tap
24
+
25
+ !dropped.empty? && caps.empty?
26
+ end
27
+
28
+ def self.devices_valid?(config, tap)
29
+ devices = Array(config['Devices'])
30
+ return devices.empty? unless tap
31
+
32
+ devices.size == 1 && devices.first['PathOnHost'] == '/dev/net/tun' &&
33
+ devices.first['PathInContainer'] == '/dev/net/tun' &&
34
+ config.dig('Sysctls', 'net.ipv4.ip_forward') == '0' &&
35
+ config.dig('Sysctls', 'net.ipv6.conf.all.disable_ipv6') == '1'
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'securerandom'
5
+
6
+ module Empeira
7
+ module Network
8
+ module Peer
9
+ # Addresses belong to node instances, under the existing workspace mutation lock.
10
+ class Layout
11
+ attr_reader :subnet
12
+
13
+ def initialize(subnet)
14
+ @subnet = subnet
15
+ @range = IPAddr.new(subnet)
16
+ unless @range.ipv4? && @range.private? && @range.prefix == 24 && "#{@range}/24" == subnet
17
+ raise Infrastructure::StateError, 'Invalid workspace peer subnet'
18
+ end
19
+ rescue IPAddr::InvalidAddressError
20
+ raise Infrastructure::StateError, 'Invalid workspace peer subnet', cause: nil
21
+ end
22
+
23
+ def address(offset)
24
+ IPAddr.new(@range.to_i + offset, Socket::AF_INET).to_s
25
+ end
26
+
27
+ def container_pool
28
+ "#{address(128)}/25"
29
+ end
30
+
31
+ def lease(nodes)
32
+ used = nodes.values.filter_map { |record| record.dig('peer', 'ip') }
33
+ ip = (32..95).map { |offset| address(offset) }.find { |candidate| !used.include?(candidate) }
34
+ raise Error, 'Workspace VM address pool exhausted; destroy an unused VM to release its lease' unless ip
35
+
36
+ { 'ip' => ip, 'token' => SecureRandom.hex(16), 'adapter_id' => nil }
37
+ end
38
+
39
+ def container_lease(nodes)
40
+ used = nodes.values.filter_map { |record| record.dig('definition', 'ip') }
41
+ ip = (96..127).map { |offset| address(offset) }.find { |candidate| !used.include?(candidate) }
42
+ raise Error, 'Workspace container node pool exhausted; destroy an unused node' unless ip
43
+
44
+ ip
45
+ end
46
+
47
+ def valid_lease?(entry)
48
+ entry.is_a?(Hash) && (32..95).any? { |offset| address(offset) == entry['ip'] } &&
49
+ entry['token'].is_a?(String) && entry['token'].match?(/\A[0-9a-f]{32}\z/) &&
50
+ Node::Inventory.valid_id?(entry['adapter_id'])
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ module Peer
6
+ class LinuxPodman < DockerAdapter
7
+ def preflight(state)
8
+ super
9
+ info = JSON.parse(command('podman', '--remote=false', 'info', '--format', 'json'))
10
+ unless info.dig('host', 'security', 'rootless') && info.dig('host', 'networkBackend') == 'netavark'
11
+ raise Error, 'VM peer networking requires local rootless Podman/Netavark; no rootful fallback'
12
+ end
13
+
14
+ namespace('test', '-r', '/dev/net/tun')
15
+ namespace('test', '-w', '/dev/net/tun')
16
+ namespace('test', '-r', '/dev/kvm')
17
+ namespace('test', '-w', '/dev/kvm')
18
+ verify_forwarding
19
+ end
20
+
21
+ def prepare(record, state)
22
+ check_record(record)
23
+ verify_tap_absent!(record)
24
+ @image = AdapterImage.new(context: context)
25
+ @image.ensure!(runtime)
26
+ resource = ensure_adapter(record, state)
27
+ runtime.start_service(resource) unless resource['state'] == 'running'
28
+ verify_adapter(record)
29
+ store.write(state)
30
+ end
31
+
32
+ def arguments(record)
33
+ ['-netdev', "tap,id=peer,ifname=#{tap(record)},script=no,downscript=no",
34
+ '-device', "virtio-net-pci,netdev=peer,mac=#{record.fetch('mac_address')}"]
35
+ end
36
+
37
+ def launch(executable, arguments)
38
+ namespace(executable, *arguments)
39
+ end
40
+
41
+ def connect(record, _state)
42
+ # QEMU owns the nonpersistent TAP FD. Closing it removes the interface.
43
+ namespace('ip', 'link', 'set', 'dev', tap(record), 'alias', record.fetch('peer').fetch('token'))
44
+ namespace('ip', 'link', 'set', 'dev', tap(record), 'master', bridge)
45
+ namespace('ip', 'link', 'set', 'dev', tap(record), 'up')
46
+ verify_forwarding
47
+ end
48
+
49
+ def stop(record)
50
+ links = JSON.parse(namespace('ip', '-j', 'link', 'show'))
51
+ return unless links.any? { |link| link['ifname'] == tap(record) }
52
+
53
+ raise Error, 'VM TAP is still present after shutdown; refusing to release the node lease'
54
+ end
55
+
56
+ def destroy(record, state)
57
+ stop(record)
58
+ runtime.remove_service(identity(record), expected_id: record.dig('peer', 'adapter_id'))
59
+ record.fetch('peer')['adapter_id'] = nil
60
+ store.write(state)
61
+ end
62
+
63
+ def healthy?(record)
64
+ health([record]).fetch(record.fetch('hostname'))
65
+ end
66
+
67
+ def health(records)
68
+ return {} if records.empty?
69
+
70
+ links = JSON.parse(namespace('ip', '-j', 'link', 'show', timeout: 2))
71
+ records.to_h { |record| [record.fetch('hostname'), attached?(record, links)] }
72
+ rescue Error, JSON::ParserError
73
+ records.to_h { |record| [record.fetch('hostname'), false] }
74
+ end
75
+
76
+ protected
77
+
78
+ def adapter_definition(record)
79
+ capless_adapter_definition(record)
80
+ end
81
+
82
+ def connector_engine
83
+ %w[podman --remote=false]
84
+ end
85
+
86
+ private
87
+
88
+ def verify_tap_absent!(record)
89
+ links = JSON.parse(namespace('ip', '-j', 'link', 'show'))
90
+ return unless links.any? { |link| link['ifname'] == tap(record) }
91
+
92
+ raise Providers::OwnershipError, 'VM TAP already exists; inspect its owning process'
93
+ end
94
+
95
+ def namespace(*, **)
96
+ command('podman', '--remote=false', 'unshare', '--rootless-netns', *, **)
97
+ end
98
+
99
+ def attached?(record, links)
100
+ links.any? do |link|
101
+ link['ifname'] == tap(record) && link['ifalias'] == record.dig('peer', 'token') &&
102
+ link['master'] == bridge && link.fetch('flags', []).include?('UP')
103
+ end
104
+ end
105
+
106
+ def verify_forwarding
107
+ return if namespace('cat', "/proc/sys/net/ipv4/conf/#{bridge}/forwarding").strip == '0'
108
+
109
+ raise Error, 'Peer bridge routing is enabled; refusing VM attachment'
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Empeira
6
+ module Network
7
+ module Peer
8
+ class PodmanMachine < DockerAdapter
9
+ def preflight(state)
10
+ super
11
+ select_machine
12
+ verify_rootless
13
+ namespace('test', '-w', '/dev/net/tun')
14
+ raise Error, 'Machine peer bridge routing is enabled' unless
15
+ namespace('cat', "/proc/sys/net/ipv4/conf/#{bridge}/forwarding").strip == '0'
16
+ end
17
+
18
+ def prepare(record, state)
19
+ check_record(record)
20
+ bind_machine(record, state)
21
+ channel(record).prepare
22
+ @image = AdapterImage.new(context: context)
23
+ @image.ensure!(runtime)
24
+ resource = ensure_adapter(record, state)
25
+ runtime.start_service(resource) unless resource['state'] == 'running'
26
+ export_helper(record, resource)
27
+ store.write(state)
28
+ end
29
+
30
+ protected
31
+
32
+ def adapter_definition(record)
33
+ capless_adapter_definition(record)
34
+ end
35
+
36
+ def packet_command(record)
37
+ machine = record.fetch('peer').fetch('machine')
38
+ namespace_command(machine, helper_path(record).to_s, 'tap', tap(record), bridge)
39
+ end
40
+
41
+ def helper_path(record)
42
+ context.locations.workspace(context.workspace).join('vms', record.fetch('hostname'), 'peer-adapter')
43
+ end
44
+
45
+ private
46
+
47
+ def bind_machine(record, state)
48
+ if record.dig('peer', 'machine') && record.dig('peer', 'machine') != @machine
49
+ raise Providers::OwnershipError, 'Node belongs to a different Podman Machine'
50
+ end
51
+
52
+ record.fetch('peer')['machine'] = @machine
53
+ store.write(state)
54
+ end
55
+
56
+ def selected_connection
57
+ connections = JSON.parse(command('podman', 'system', 'connection', 'list', '--format', 'json'))
58
+ connections.select { |entry| entry['Default'] }
59
+ end
60
+
61
+ def select_machine
62
+ machines = JSON.parse(command('podman', 'machine', 'list', '--format', 'json'))
63
+ active = machines.select { |entry| entry['Running'] }
64
+ selected = selected_connection
65
+ unless active.size == 1 && selected.size == 1 && selected.first['Name'] == active.first['Name']
66
+ raise Error, 'Select one unambiguous running rootless Podman Machine and its default connection'
67
+ end
68
+
69
+ @machine = active.first.fetch('Name')
70
+ end
71
+
72
+ def verify_rootless
73
+ info = JSON.parse(command('podman', 'info', '--format', 'json'))
74
+ return if info.dig('host', 'security', 'rootless') && info.dig('host', 'networkBackend') == 'netavark'
75
+
76
+ raise Error, 'Podman Machine peer networking requires rootless Netavark; no rootful fallback'
77
+ end
78
+
79
+ def verify_empty_capabilities(resource)
80
+ result = runtime.service_exec(resource, %w[/adapter capabilities])
81
+ return if result.success? && result.stdout.strip.match?(/\A0{16}\z/)
82
+
83
+ raise Providers::OwnershipError, 'Podman staging helper must have no effective capabilities'
84
+ end
85
+
86
+ def export_helper(record, resource)
87
+ path = helper_path(record)
88
+ runtime.copy_from(resource, '/adapter', path)
89
+ verify_empty_capabilities(resource)
90
+ digest = verify_adapter(record)
91
+ raise Error, 'Exported peer helper checksum mismatch' unless Digest::SHA256.file(path).hexdigest == digest
92
+
93
+ File.chmod(0o700, path)
94
+ observed = command('podman', 'machine', 'ssh', @machine,
95
+ Shellwords.join(['sha256sum', path.to_s])).split.first
96
+ return if observed == digest
97
+
98
+ raise Error, 'Podman Machine cannot verify the peer helper at its host path; configure its shared mount'
99
+ end
100
+
101
+ def namespace(*)
102
+ executable, *arguments = namespace_command(@machine, *)
103
+ command(executable, *arguments)
104
+ end
105
+
106
+ def namespace_command(machine, *)
107
+ ['podman', 'machine', 'ssh', machine, Shellwords.join(['podman', 'unshare', '--rootless-netns', *])]
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Lint/UnusedMethodArgument -- Preserve explicit adapter keyword contracts.
4
+
5
+ module Empeira
6
+ module Network
7
+ class UnsupportedPolicy < Error; end
8
+
9
+ Policy = Data.define do
10
+ def require_support!(isolated:, controlled_egress:)
11
+ raise UnsupportedPolicy, 'Isolated networking is required' unless isolated == true
12
+ end
13
+ end
14
+
15
+ class Interface
16
+ def initialize(context:, runner:)
17
+ @context = context
18
+ @runner = runner
19
+ end
20
+
21
+ def provision(policy:)
22
+ policy.require_support!(**capabilities)
23
+ create(policy: policy)
24
+ end
25
+
26
+ protected
27
+
28
+ def capabilities
29
+ { isolated: false, controlled_egress: false }
30
+ end
31
+
32
+ def create(policy:)
33
+ raise UnavailableFeature, 'This command is not implemented yet.'
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ # rubocop:enable Lint/UnusedMethodArgument
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ class ProxyBindings
6
+ def initialize(context:, resources:, network:, nodes:)
7
+ @context = context
8
+ @resources = resources
9
+ @network = network
10
+ @nodes = nodes
11
+ @config = context.configuration.fetch('proxy')
12
+ end
13
+
14
+ def configuration
15
+ entries = %w[server].map do |name|
16
+ rules(name.tr('-', '_'), address(name), @config.fetch('global'))
17
+ end
18
+ @nodes.sort.each_with_index do |(hostname, record), index|
19
+ entries << node_rules(hostname, record, index)
20
+ end
21
+ "#{entries.compact.join("\n")}\n"
22
+ end
23
+
24
+ private
25
+
26
+ def node_rules(hostname, record, index)
27
+ return if record['network_phase'] == 'bootstrap'
28
+
29
+ domains = HostPolicy.new(@config).resolve(hostname)
30
+ rules("node_#{index}", address(hostname), domains)
31
+ end
32
+
33
+ def address(name)
34
+ @resources[name]&.dig('networks', @network, 'IPAddress')
35
+ end
36
+
37
+ def rules(name, address, domains, authorization = nil)
38
+ return unless address && !address.empty? && !domains.empty?
39
+
40
+ IPAddr.new(address)
41
+ entries = ["acl #{name}_source src #{address}"]
42
+ condition = "#{name}_source"
43
+ if authorization
44
+ entries << "acl #{name}_identity req_header Proxy-Authorization " \
45
+ "^Basic[[:space:]]#{Regexp.escape(authorization)}$"
46
+ condition += " #{name}_identity"
47
+ end
48
+ entries.concat(ProxyPolicy.domain_rules(domains, name: "#{name}_domains", condition: condition)).join("\n")
49
+ end
50
+ end
51
+ end
52
+ end