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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ # A guest controlling PTY with local raw-mode restoration; no login or input logging.
6
+ class GuestShell
7
+ def initialize(input:, output:)
8
+ @input = input
9
+ @output = output
10
+ end
11
+
12
+ def consume(channel)
13
+ raise Error, 'Node shell requires a local input and output terminal' unless @input.tty? && @output.tty?
14
+
15
+ @channel = channel
16
+ previous = Signal.trap('WINCH') { @resized = true } if Thread.current == Thread.main
17
+ channel.send_frame('type' => 'shell', 'size' => @input.winsize, 'term' => ENV.fetch('TERM', 'xterm'))
18
+ Execution::Terminal.with_interrupts { @input.raw { exchange } }
19
+ ensure
20
+ Signal.trap('WINCH', previous) if previous
21
+ end
22
+
23
+ private
24
+
25
+ def exchange
26
+ @keyboard = @input
27
+ loop do
28
+ resize
29
+ sources = readable_sources
30
+ if sources.include?(@channel.io) || @channel.readable?
31
+ result = consume_frame
32
+ return result if result
33
+
34
+ next
35
+ end
36
+ forward_input if @keyboard && sources.include?(@keyboard)
37
+ end
38
+ end
39
+
40
+ def resize
41
+ return unless @resized
42
+
43
+ @channel.send_frame('type' => 'resize', 'size' => @input.winsize)
44
+ @resized = false
45
+ end
46
+
47
+ def readable_sources
48
+ keyboard = @credit ? @keyboard : nil
49
+ IO.select([@channel.io, keyboard].compact, nil, nil, @channel.readable? ? 0 : 0.1)&.first || []
50
+ end
51
+
52
+ def forward_input
53
+ bytes = @keyboard.read_nonblock(GuestChannel::CHUNK, exception: false)
54
+ return if bytes == :wait_readable
55
+
56
+ @channel.send_frame('type' => bytes ? 'input' : 'eof', 'data' => Base64.strict_encode64(bytes || ''))
57
+ @credit = false
58
+ @keyboard = nil unless bytes
59
+ end
60
+
61
+ def consume_frame
62
+ frame = @channel.receive(timeout: 5)
63
+ case frame['type']
64
+ when 'started' then nil
65
+ when 'ready'
66
+ @credit = true
67
+ nil
68
+ when 'stdout'
69
+ @output.write(Base64.strict_decode64(frame.fetch('data')))
70
+ @output.flush
71
+ nil
72
+ when 'exit'
73
+ GuestOutput.new.consume_completion(frame)
74
+ else raise Error, 'VM shell returned an unexpected protocol message'
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ # Integrity-pinned binary uploads are atomically published by the guest adapter.
6
+ class GuestUpload
7
+ def initialize(channel)
8
+ @channel = channel
9
+ end
10
+
11
+ def write(file, destination, mode:)
12
+ @channel.send_frame('type' => 'upload')
13
+ ready!
14
+ digest = write_chunks(file)
15
+ @channel.send_frame('type' => 'install', 'destination' => destination, 'mode' => mode,
16
+ 'sha256' => digest.hexdigest)
17
+ result = GuestOutput.new.consume(@channel, timeout: 35)
18
+ return if result.success?
19
+
20
+ details = Execution::Diagnostics.command(result, operation: 'VM upload', tool: 'VirtIO management')
21
+ raise Error, "VM upload failed; staging cleaned up, VM retained\n#{details}", cause: nil
22
+ end
23
+
24
+ private
25
+
26
+ def write_chunks(file)
27
+ digest = OpenSSL::Digest.new('SHA256')
28
+ while (chunk = file.read(GuestChannel::CHUNK))
29
+ digest.update(chunk)
30
+ @channel.send_frame('type' => 'chunk', 'data' => Base64.strict_encode64(chunk))
31
+ ready!
32
+ end
33
+ digest
34
+ end
35
+
36
+ def ready!
37
+ return if @channel.receive(timeout: 30)['type'] == 'ready'
38
+
39
+ raise Error, 'VM upload protocol did not acknowledge staging'
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'net/http'
6
+ require 'openssl'
7
+ require 'tempfile'
8
+ require 'uri'
9
+
10
+ module Empeira
11
+ module VM
12
+ # Base images are immutable and addressed by all source and checksum fields.
13
+ class ImageCache
14
+ def initialize(locations:, downloader: nil)
15
+ @locations = locations
16
+ @downloader = downloader || method(:download)
17
+ end
18
+
19
+ def fetch(image)
20
+ directory = @locations.image(image.identity)
21
+ FileUtils.mkdir_p(directory, mode: 0o700)
22
+ destination = directory.join('base.qcow2')
23
+ return destination if verified?(destination, image.identity.checksum)
24
+
25
+ cache_image(image, directory, destination)
26
+ destination
27
+ rescue SystemCallError, IOError => e
28
+ raise Error, "Cannot cache the verified VM image (#{e.class.name})", cause: nil
29
+ end
30
+
31
+ private
32
+
33
+ def cache_image(image, directory, destination)
34
+ Tempfile.create(['vm-image-', '.part'], directory) do |temporary|
35
+ temporary.binmode
36
+ @downloader.call(image.url, temporary)
37
+ temporary.flush
38
+ temporary.fsync
39
+ raise Error, 'Downloaded VM image failed SHA-256 verification' unless
40
+ verified?(temporary.path, image.identity.checksum)
41
+
42
+ temporary.chmod(0o444)
43
+ File.rename(temporary.path, destination)
44
+ end
45
+ end
46
+
47
+ def verified?(path, checksum)
48
+ File.file?(path) && !File.symlink?(path) && OpenSSL::Digest::SHA256.file(path).hexdigest == checksum
49
+ end
50
+
51
+ def download(url, file)
52
+ received = nil
53
+ uri = URI(url)
54
+ raise Error, 'VM image source must use HTTPS' unless uri.is_a?(URI::HTTPS)
55
+
56
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 60) do |http|
57
+ http.request(Net::HTTP::Get.new(uri.request_uri)) do |response|
58
+ received = response
59
+ validate_response!(response, url)
60
+ response.read_body { |chunk| file.write(chunk) }
61
+ end
62
+ end
63
+ rescue SocketError, OpenSSL::SSL::SSLError, IOError, SystemCallError, Timeout::Error, Net::ProtocolError => e
64
+ diagnostic = Execution::Diagnostics.transport(operation: 'Download VM base image', url: url, error: e,
65
+ response: received)
66
+ raise Error, "VM image download failed\n#{diagnostic}", cause: nil
67
+ end
68
+
69
+ def validate_response!(response, url)
70
+ return if response.is_a?(Net::HTTPSuccess)
71
+
72
+ diagnostic = Execution::Diagnostics.http_response(response, operation: 'Download VM base image', url: url)
73
+ raise Error, "VM image download failed\n#{diagnostic}", cause: nil
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+
6
+ module Empeira
7
+ module VM
8
+ # Resolve moving upstream aliases to a content-addressed image before downloading.
9
+ class ImageSource
10
+ Image = Data.define(:identity, :url, :filename)
11
+ UBUNTU = { '22.04' => 'jammy', '24.04' => 'noble' }.freeze
12
+ ARCHITECTURES = { 'amd64' => 'x86_64', 'arm64' => 'aarch64' }.freeze
13
+
14
+ def initialize(fetch_text: nil)
15
+ @fetch_text = fetch_text || method(:read_text)
16
+ end
17
+
18
+ def resolve(distribution:, version:, architecture:)
19
+ arch = architecture.to_s
20
+ raise UnavailableFeature, 'VM image architecture must be amd64 or arm64' unless ARCHITECTURES.key?(arch)
21
+
22
+ url, manifest = case distribution
23
+ when 'ubuntu' then ubuntu(version, arch)
24
+ when 'rocky' then rocky(version, arch)
25
+ else raise UnavailableFeature, "No verified VM image source for #{distribution} #{version}"
26
+ end
27
+ filename = URI(url).path.split('/').last
28
+ checksum = checksum_for(@fetch_text.call(manifest), filename)
29
+ identity = Images::Identity.new(distribution: distribution, version: version, architecture: arch,
30
+ source: url, revision: checksum, checksum: checksum)
31
+ Image.new(identity: identity, url: url, filename: filename)
32
+ end
33
+
34
+ private
35
+
36
+ def ubuntu(version, arch)
37
+ codename = UBUNTU[version]
38
+ raise UnavailableFeature, "Ubuntu #{version} VM image is not supported" unless codename
39
+
40
+ base = "https://cloud-images.ubuntu.com/releases/#{codename}/release"
41
+ ["#{base}/ubuntu-#{version}-server-cloudimg-#{arch}.img", "#{base}/SHA256SUMS"]
42
+ end
43
+
44
+ def rocky(version, arch)
45
+ raise UnavailableFeature, "Rocky Linux #{version} VM image is not supported" unless %w[8 9].include?(version)
46
+
47
+ arch_name = ARCHITECTURES.fetch(arch)
48
+ base = "https://dl.rockylinux.org/pub/rocky/#{version}/images/#{arch_name}"
49
+ image = "Rocky-#{version}-GenericCloud-Base.latest.#{arch_name}.qcow2"
50
+ ["#{base}/#{image}", "#{base}/#{image}.CHECKSUM"]
51
+ end
52
+
53
+ def checksum_for(text, filename)
54
+ escaped = Regexp.escape(filename)
55
+ ubuntu = /\A([0-9a-f]{64}) \*?#{escaped}\s*\z/i
56
+ rocky = /\ASHA256 \(#{escaped}\) = ([0-9a-f]{64})\s*\z/i
57
+ text.each_line do |line|
58
+ match = ubuntu.match(line)
59
+ return match[1].downcase if match
60
+
61
+ match = rocky.match(line)
62
+ return match[1].downcase if match
63
+ end
64
+ raise Error, 'Upstream image checksum is missing or malformed'
65
+ end
66
+
67
+ def read_text(url)
68
+ uri = URI(url)
69
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
70
+ http.get(uri.request_uri)
71
+ end
72
+ validate_text!(response, url)
73
+ rescue SocketError, OpenSSL::SSL::SSLError, IOError, SystemCallError, Timeout::Error, Net::ProtocolError => e
74
+ diagnostic = Execution::Diagnostics.transport(operation: 'Retrieve VM image checksum', url: url, error: e)
75
+ raise Error, "Cannot retrieve upstream VM image checksum\n#{diagnostic}", cause: nil
76
+ end
77
+
78
+ def validate_text!(response, url)
79
+ unless response.is_a?(Net::HTTPSuccess)
80
+ diagnostic = Execution::Diagnostics.http(operation: 'Retrieve VM image checksum', url: url,
81
+ status: response.code, reason: response.message,
82
+ body: response.body, content_type: response['Content-Type'])
83
+ raise Error, "Cannot retrieve upstream VM image checksum\n#{diagnostic}", cause: nil
84
+ end
85
+ raise Error, 'Upstream VM image checksum is too large' if response.body.bytesize > 100_000
86
+
87
+ response.body
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ class Interface < Providers::Lifecycle
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ class InterfaceOwnership
6
+ PARENT = Configuration::VMInterfaces::PARENT
7
+
8
+ def initialize(context:, record:, inventory:, guest:)
9
+ @record = record
10
+ @inventory = inventory
11
+ @guest = guest
12
+ node = Digest::SHA256.hexdigest(record.fetch('hostname'))[0, 16]
13
+ @owner = "empeira:#{context.workspace.id}:#{node}:#{inventory.fetch('token')}"
14
+ end
15
+
16
+ def marker(name, definition)
17
+ "#{@owner}:#{name}:#{Infrastructure::Definition.fingerprint(definition)}"
18
+ end
19
+
20
+ def address(name, definition)
21
+ ['02', *Digest::SHA256.hexdigest(marker(name, definition))[0, 10].scan(/../)].join(':')
22
+ end
23
+
24
+ def accepted(name)
25
+ return [{}] if name == PARENT && @inventory.fetch('parent')
26
+
27
+ entry = @inventory.fetch('devices')[name]
28
+ entry ? [entry.fetch('definition'), entry['previous']].compact.uniq : []
29
+ end
30
+
31
+ def observed_definition(name)
32
+ link = @guest.links[name]
33
+ return unless link
34
+
35
+ definition = accepted(name).find { |candidate| marker_matches?(name, link, candidate) }
36
+ unless definition
37
+ conflict!(name,
38
+ 'existing interface is foreign: MAC/alias ownership marker does not match recorded intent')
39
+ end
40
+ unless correct_type?(name, link, definition) && !link.key?('master') && !link.key?('link_netnsid')
41
+ conflict!(name, 'existing interface is foreign, has changed type/parent, or is attached to another network')
42
+ end
43
+ verify_addresses!(name)
44
+ definition
45
+ end
46
+
47
+ def marker_matches?(name, link, definition)
48
+ link['address'] == address(name, definition) &&
49
+ (link.fetch('ifalias', '').empty? || link['ifalias'] == marker(name, definition))
50
+ end
51
+
52
+ def correct_type?(name, link, definition)
53
+ info = link.fetch('linkinfo', {})
54
+ return info['info_kind'] == 'dummy' if name == PARENT || !definition.key?('vlan_id')
55
+
56
+ correct_vlan?(link, info, definition)
57
+ end
58
+
59
+ def correct_vlan?(link, info, definition)
60
+ parent = @guest.links[PARENT]
61
+ info['info_kind'] == 'vlan' && info.dig('info_data', 'id') == definition.fetch('vlan_id') &&
62
+ info.dig('info_data', 'protocol').to_s.downcase == '802.1q' && parent && @guest.parent_name(link) == PARENT
63
+ end
64
+
65
+ def verify_addresses!(name)
66
+ addresses = @guest.addresses.fetch(name, [])
67
+ networks = accepted(name).filter_map { |definition| definition['network'] }
68
+ return if addresses.all? do |address|
69
+ address['family'] == 'inet' && networks.include?("#{address['local']}/#{address['prefixlen']}")
70
+ end
71
+
72
+ conflict!(name, 'interface contains an unowned address; preserve it and investigate')
73
+ end
74
+
75
+ def validate!(names)
76
+ names.each { |name| observed_definition(name) }
77
+ validate_markers!(names)
78
+ validate_dependencies!(names)
79
+ end
80
+
81
+ def validate_markers!(names)
82
+ @guest.links.each do |name, link|
83
+ next unless owned_marker?(link, names)
84
+ next if names.include?(name)
85
+
86
+ conflict!(name, 'owned marker appears on an unrecorded or renamed interface')
87
+ end
88
+ end
89
+
90
+ def owned_marker?(link, names)
91
+ link.fetch('ifalias', '').start_with?("#{@owner}:") || names.any? do |name|
92
+ accepted(name).any? { |definition| link['address'] == address(name, definition) }
93
+ end
94
+ end
95
+
96
+ def validate_dependencies!(names)
97
+ names.each do |name|
98
+ link = @guest.links[name]
99
+ next unless link
100
+
101
+ children = @guest.links.values.select { |child| @guest.parent_name(child) == name }
102
+ children.each do |child|
103
+ child_name = child.fetch('ifname')
104
+ conflict!(name, "dependent interface #{child_name} is foreign") unless names.include?(child_name)
105
+ end
106
+ end
107
+ end
108
+
109
+ def deletable!(name)
110
+ observed_definition(name)
111
+ link = @guest.links[name]
112
+ return unless link
113
+ return unless @guest.links.values.any? { |child| @guest.parent_name(child) == name }
114
+
115
+ conflict!(name, 'dependent interfaces still exist; parent deletion refused')
116
+ end
117
+
118
+ def conflict!(name, reason)
119
+ raise Providers::OwnershipError,
120
+ "Host #{@record.fetch('hostname')}, interface #{name}, vm.interfaces: #{reason}"
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ class InterfaceRoutes
6
+ def self.validate_static!(desired, hostname:, config:, subnet:)
7
+ protected = [subnet].compact + config.dig('network', 'redirects').map do |entry|
8
+ entry.fetch('from').fetch('ip')
9
+ end
10
+ desired.each do |name, definition|
11
+ protected.each { |range| reject_overlap!(definition.fetch('network'), range, hostname, name) }
12
+ end
13
+ end
14
+
15
+ def self.reject_overlap!(network, range, hostname, name)
16
+ return unless Configuration::VMInterfaces.overlap?(IPAddr.new(network), IPAddr.new(range))
17
+
18
+ raise ConfigurationError, "Host #{hostname}, interface #{name}, vm.interfaces: network #{network} " \
19
+ "conflicts with protected route/address #{range} (including network.redirects)"
20
+ end
21
+
22
+ def initialize(guest:, ownership:, hostname:)
23
+ @guest = guest
24
+ @ownership = ownership
25
+ @hostname = hostname
26
+ end
27
+
28
+ def validate!(desired)
29
+ protected = foreign_addresses + foreign_routes
30
+ desired.each do |name, definition|
31
+ protected.each { |range| self.class.reject_overlap!(definition.fetch('network'), range, @hostname, name) }
32
+ end
33
+ rescue IPAddr::InvalidAddressError, KeyError, TypeError
34
+ raise Error, "Host #{@hostname}, vm.interfaces: guest routes/addresses cannot be verified", cause: nil
35
+ end
36
+
37
+ def foreign_addresses
38
+ @guest.addresses.flat_map do |name, addresses|
39
+ next [] unless @ownership.accepted(name).empty?
40
+
41
+ addresses.filter_map do |address|
42
+ "#{address.fetch('local')}/#{address.fetch('prefixlen')}" if address['family'] == 'inet'
43
+ end
44
+ end
45
+ end
46
+
47
+ def foreign_routes
48
+ @guest.routes.filter_map do |route|
49
+ raise Error, "Host #{@hostname}, vm.interfaces: route destination is missing" if route['dst'].nil?
50
+ raise Error, "Host #{@hostname}, vm.interfaces: unresolved route nexthop ID" if route.key?('nhid')
51
+ next if foreign_default?(route)
52
+ next if automatic_owned_route?(route)
53
+
54
+ owned = owned_route_device(route)
55
+ @ownership.conflict!(owned, 'interface carries a non-generated route; mutation refused') if owned
56
+
57
+ route.fetch('dst')
58
+ end
59
+ end
60
+
61
+ def foreign_default?(route)
62
+ %w[default 0.0.0.0/0].include?(route['dst']) && owned_route_device(route).nil?
63
+ end
64
+
65
+ def owned_route_device(route)
66
+ hops = route.values_at('multipath', 'nexthops').compact.flatten
67
+ devices = [route['dev'], *hops.map { |hop| hop.fetch('dev') }].compact
68
+ devices.find { |name| !@ownership.accepted(name).empty? }
69
+ end
70
+
71
+ def automatic_owned_route?(route)
72
+ return false unless ordinary_kernel_route?(route)
73
+
74
+ @ownership.accepted(route['dev']).any? do |definition|
75
+ next false unless definition['network']
76
+
77
+ generated_route?(route, definition.fetch('network'))
78
+ end
79
+ end
80
+
81
+ def ordinary_kernel_route?(route)
82
+ route['protocol'] == 'kernel' && !route.key?('gateway') && !route.key?('multipath') &&
83
+ !route.key?('nexthops') && [nil, 'main', 'local', 254, 255].include?(route['table'])
84
+ end
85
+
86
+ def generated_route?(route, network)
87
+ subnet = IPAddr.new(network)
88
+ target = IPAddr.new(route.fetch('dst'))
89
+ case route['type']
90
+ when 'local' then target.prefix == 32 && target.to_s == network.split('/').first
91
+ when 'broadcast' then broadcast?(target, subnet)
92
+ else route['scope'] == 'link' && target == subnet && target.prefix == subnet.prefix
93
+ end
94
+ end
95
+
96
+ def broadcast?(target, subnet)
97
+ target.prefix == 32 && [subnet.to_range.first.to_s, subnet.to_range.last.to_s].include?(target.to_s)
98
+ end
99
+ end
100
+ end
101
+ end