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,404 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'socket'
6
+ require 'timeout'
7
+
8
+ module Empeira
9
+ module VM
10
+ # QEMU process control is a replaceable VM-engine concern.
11
+ # rubocop:disable-next Metrics/ClassLength -- Launch and ownership-safe monitor control form one engine boundary.
12
+ class QemuRuntime
13
+ def initialize(engine:, runner:, context:)
14
+ @engine = engine
15
+ @runner = runner
16
+ @context = context
17
+ end
18
+
19
+ def launch(record:, overlay:, seed:, network:)
20
+ Management.validate!(record)
21
+ hostname = record.fetch('hostname')
22
+ monitor = monitor_path(hostname)
23
+ ensure_launchable_monitor!(record, monitor)
24
+ execute_launch(record, overlay, seed, network, monitor)
25
+ end
26
+
27
+ def running?(record)
28
+ monitor = monitor_path(record.fetch('hostname'))
29
+ return false unless monitor.socket?
30
+
31
+ Timeout.timeout(2) do
32
+ UNIXSocket.open(monitor) do |socket|
33
+ read_monitor_prompt(socket)
34
+ verify_monitor_identity!(socket, record)
35
+ true
36
+ end
37
+ end
38
+ rescue SystemCallError, IOError, Timeout::Error
39
+ false
40
+ end
41
+
42
+ def observed_running?(record)
43
+ process_alive?(record) && monitor_path(record.fetch('hostname')).socket?
44
+ end
45
+
46
+ def stop(record)
47
+ unless running?(record)
48
+ raise Error, 'QEMU process is alive but monitor is unavailable; VM disk retained' if process_alive?(record)
49
+
50
+ return nil
51
+ end
52
+
53
+ monitor_command(record, 'system_powerdown')
54
+ wait_for_shutdown(record)
55
+ if running?(record)
56
+ monitor_command(record, 'quit')
57
+ wait_for_shutdown(record, seconds: 5)
58
+ end
59
+ raise Error, 'QEMU did not stop; VM overlay retained' if running?(record)
60
+
61
+ File.unlink(monitor_path(record.fetch('hostname'))) if monitor_path(record.fetch('hostname')).exist?
62
+ record
63
+ end
64
+
65
+ def cleanup(record)
66
+ raise Error, 'QEMU process is still alive; monitor state retained' if process_alive?(record)
67
+
68
+ directory = monitor_directory(record.fetch('hostname'))
69
+ return unless directory.exist? || directory.symlink?
70
+
71
+ validate_monitor_directory!(directory)
72
+ validate_management_cleanup!(record)
73
+ unlink_monitor_socket(directory.join('monitor.sock'))
74
+ unlink_monitor_socket(directory.join('console.sock'))
75
+ unlink_management_socket(record)
76
+ Dir.rmdir(directory)
77
+ end
78
+
79
+ def process_alive?(record)
80
+ pid = record['pid'] || unrecorded_pid(record)
81
+ return false unless pid.is_a?(Integer) && pid.positive?
82
+
83
+ Process.kill(0, pid)
84
+ true
85
+ rescue Errno::ESRCH
86
+ false
87
+ rescue Errno::EPERM
88
+ true
89
+ end
90
+
91
+ def unrecorded_pid(record)
92
+ path = pid_path(record.fetch('hostname'))
93
+ return unless path.exist? || path.symlink?
94
+
95
+ root = @context.locations.workspace(@context.workspace)
96
+ Infrastructure::OwnedFiles.verify_removable!(path, root: root)
97
+ value = File.read(path, 32).strip
98
+ unless value.match?(/\A[1-9][0-9]{0,9}\z/)
99
+ raise Error, 'QEMU PID file is not safely interpretable; VM disk retained'
100
+ end
101
+
102
+ Integer(value, 10)
103
+ end
104
+
105
+ def serial_log(hostname)
106
+ node_directory(hostname).join('serial.log')
107
+ end
108
+
109
+ def console(record)
110
+ raise Error, 'VM is stopped; start it before attaching the console' unless running?(record)
111
+
112
+ path = console_path(record.fetch('hostname'))
113
+ validate_monitor_directory!(path.dirname)
114
+ metadata = path.lstat
115
+ unless metadata.socket? && metadata.uid == Process.uid
116
+ raise Providers::OwnershipError, 'VM console endpoint ownership is invalid'
117
+ end
118
+
119
+ @runner.console(path, guidance: console_guidance(record.fetch('hostname')))
120
+ rescue Errno::ENOENT
121
+ raise Error, 'VM console endpoint is missing'
122
+ end
123
+
124
+ def console_path(hostname)
125
+ monitor_directory(hostname).join('console.sock')
126
+ end
127
+
128
+ def management_socket(record)
129
+ Management.validate!(record)
130
+ raise Error, 'VM is stopped; management unavailable' unless running?(record)
131
+
132
+ path = management_path(record.fetch('hostname'))
133
+ verify_management_socket!(record, path)
134
+ path
135
+ end
136
+
137
+ def with_management_socket(record)
138
+ Management.validate!(record)
139
+ raise Error, 'VM is stopped; management unavailable' unless running?(record)
140
+
141
+ Management::SLOTS.times do |slot|
142
+ path = management_path(record.fetch('hostname'), slot)
143
+ expected = slot.zero? ? record['management_socket'] : record.dig('management_sockets', slot.to_s)
144
+ verify_management_socket!(record, path, expected: expected)
145
+ lock = management_slot_lock(path)
146
+ next unless lock
147
+
148
+ begin
149
+ return yield path, expected
150
+ ensure
151
+ lock.close
152
+ end
153
+ end
154
+ raise Error, 'All eight VM management sessions are busy; detach a session and retry'
155
+ end
156
+
157
+ private
158
+
159
+ def console_guidance(hostname)
160
+ message = "Connected to VM serial console.\nPress Ctrl+] to detach. The VM will keep running.\n"
161
+ return message if @context.configuration.dig('vm', 'console', 'root_password')
162
+
163
+ "#{message}No console password is configured. Use empeira node ssh #{hostname} instead.\n"
164
+ end
165
+
166
+ def validate_monitor_directory!(directory)
167
+ metadata = File.lstat(directory)
168
+ return if metadata.directory? && metadata.uid == Process.uid && metadata.mode.nobits?(0o077)
169
+
170
+ raise Error, 'VM monitor directory ownership changed; cleanup refused'
171
+ end
172
+
173
+ def unlink_monitor_socket(socket)
174
+ return unless socket.exist? || socket.symlink?
175
+
176
+ if socket.symlink? || !socket.lstat.socket? || socket.lstat.uid != Process.uid
177
+ raise Error, 'Unexpected VM monitor path; cleanup refused'
178
+ end
179
+
180
+ File.unlink(socket) if socket.socket?
181
+ end
182
+
183
+ def ensure_launchable_monitor!(record, monitor)
184
+ prepare_monitor_directory(record.fetch('hostname'))
185
+ raise Error, 'Recorded QEMU process is still alive; refusing a second launch' if process_alive?(record)
186
+
187
+ unlink_management_socket(record)
188
+ unlink_monitor_socket(console_path(record.fetch('hostname')))
189
+ File.unlink(monitor) if monitor.socket? && !running?(record)
190
+ raise Error, 'VM monitor already exists; inspect the recorded process first' if monitor.exist?
191
+ end
192
+
193
+ def execute_launch(record, overlay, seed, network, monitor)
194
+ arguments = launch_arguments(record, overlay, seed, network.arguments, monitor)
195
+ network.launch(@engine.executable, arguments)
196
+
197
+ pid = Integer(File.read(pid_path(record.fetch('hostname'))).strip)
198
+ record['pid'] = pid
199
+ raise Error, 'QEMU did not create its owned monitor socket' unless running?(record)
200
+
201
+ capture_management_socket!(record)
202
+ network.connect
203
+
204
+ pid
205
+ end
206
+
207
+ def wait_for_shutdown(record, seconds: 30)
208
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
209
+ while (running?(record) || process_alive?(record)) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
210
+ sleep 0.25
211
+ end
212
+ end
213
+
214
+ def launch_arguments(record, overlay, seed, network, monitor)
215
+ hostname = record.fetch('hostname')
216
+ arch = @context.platform.architecture
217
+ machine = arch == :arm64 ? 'virt' : 'q35'
218
+ ['-name', process_name(record), '-machine', machine,
219
+ '-accel', @engine.accelerator, '-cpu', 'host', '-m', record.fetch('memory').to_s,
220
+ '-smp', record.fetch('cpus').to_s, '-display', 'none', '-daemonize',
221
+ '-chardev', console_device(hostname), '-serial', 'chardev:console',
222
+ '-pidfile', pid_path(hostname).to_s,
223
+ '-monitor', "unix:#{escape(monitor)},server=on,wait=off",
224
+ *management_devices(hostname), *boot_drives(overlay, seed), *firmware(arch), *network]
225
+ end
226
+
227
+ def management_devices(hostname)
228
+ ['-device', 'virtio-serial-pci,id=management', *Management::SLOTS.times.flat_map do |slot|
229
+ identifier = slot.zero? ? 'management' : "management#{slot}"
230
+ ['-chardev', "socket,id=#{identifier},path=#{escape(management_path(hostname, slot))},server=on,wait=off",
231
+ '-device', "virtserialport,chardev=#{identifier},name=org.empeira.management.#{slot}"]
232
+ end]
233
+ end
234
+
235
+ def management_path(hostname, slot = 0)
236
+ monitor_directory(hostname).join(slot.zero? ? 'guest.sock' : "guest-#{slot}.sock")
237
+ end
238
+
239
+ # rubocop:disable-next Metrics/AbcSize -- Pin each private VirtIO endpoint before exposing management.
240
+ def capture_management_socket!(record)
241
+ record['management_sockets'] = Management::SLOTS.times.to_h do |slot|
242
+ path = management_path(record.fetch('hostname'), slot)
243
+ validate_monitor_directory!(path.dirname)
244
+ metadata = path.lstat
245
+ unless metadata.socket? && metadata.uid == Process.uid
246
+ raise Providers::OwnershipError, 'QEMU management endpoint is unsafe; VM retained'
247
+ end
248
+
249
+ File.chmod(0o600, path)
250
+ [slot.to_s, { 'device' => metadata.dev, 'inode' => metadata.ino }]
251
+ end
252
+ record['management_socket'] = record['management_sockets'].fetch('0')
253
+ verify_management_socket!(record, management_path(record.fetch('hostname')))
254
+ end
255
+
256
+ def verify_management_socket!(record, path, expected: record['management_socket'])
257
+ validate_monitor_directory!(path.dirname)
258
+ metadata = path.lstat
259
+ unless metadata.socket? && metadata.uid == Process.uid && metadata.mode.nobits?(0o077) &&
260
+ expected == { 'device' => metadata.dev, 'inode' => metadata.ino }
261
+ raise Providers::OwnershipError,
262
+ 'VM management socket ownership or recorded identity changed; operation refused'
263
+ end
264
+ end
265
+
266
+ def validate_management_cleanup!(record)
267
+ path = management_path(record.fetch('hostname'))
268
+ verify_management_socket!(record, path) if path.exist? || path.symlink?
269
+ end
270
+
271
+ def unlink_management_socket(record)
272
+ Management::SLOTS.times do |slot|
273
+ path = management_path(record.fetch('hostname'), slot)
274
+ if path.exist? || path.symlink?
275
+ expected = slot.zero? ? record['management_socket'] : record.dig('management_sockets', slot.to_s)
276
+ verify_management_socket!(record, path, expected: expected)
277
+ File.unlink(path)
278
+ end
279
+ lock = Pathname("#{path}.lock")
280
+ unlink_management_lock(lock) if lock.exist? || lock.symlink?
281
+ end
282
+ end
283
+
284
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity -- Ownership and nonblocking acquisition precede transferring the live descriptor.
285
+ def management_slot_lock(path)
286
+ target = Pathname("#{path}.lock")
287
+ raise Providers::OwnershipError, 'VM management session lock is a symlink' if target.symlink?
288
+
289
+ # rubocop:disable-next Style/FileOpen -- The caller owns and closes the acquired slot descriptor.
290
+ lock = File.open(target, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
291
+ metadata = lock.stat
292
+ unless metadata.file? && metadata.uid == Process.uid && metadata.mode.nobits?(0o077)
293
+ raise Providers::OwnershipError, 'VM management session lock ownership is invalid'
294
+ end
295
+
296
+ acquired = lock.flock(File::LOCK_EX | File::LOCK_NB)
297
+ acquired ? lock : nil
298
+ ensure
299
+ lock&.close unless acquired
300
+ end
301
+
302
+ def unlink_management_lock(path)
303
+ metadata = path.lstat
304
+ unless metadata.file? && metadata.uid == Process.uid && metadata.mode.nobits?(0o077)
305
+ raise Providers::OwnershipError, 'VM management session lock ownership is invalid'
306
+ end
307
+
308
+ File.unlink(path)
309
+ end
310
+
311
+ def console_device(hostname)
312
+ "socket,id=console,path=#{escape(console_path(hostname))},server=on,wait=off," \
313
+ "logfile=#{escape(serial_log(hostname))},logappend=on"
314
+ end
315
+
316
+ def boot_drives(overlay, seed)
317
+ ['-drive', "file=#{escape(overlay)},if=virtio,format=qcow2,cache=none",
318
+ '-drive', "file=#{escape(seed)},if=virtio,format=raw,readonly=on"]
319
+ end
320
+
321
+ def firmware(architecture)
322
+ return [] unless architecture == :arm64
323
+
324
+ ['-bios', @engine.firmware.to_s]
325
+ end
326
+
327
+ def escape(path)
328
+ path.to_s.gsub(',', ',,')
329
+ end
330
+
331
+ def monitor_command(record, command)
332
+ Timeout.timeout(3) do
333
+ UNIXSocket.open(monitor_path(record.fetch('hostname'))) do |socket|
334
+ read_monitor_prompt(socket)
335
+ verify_monitor_identity!(socket, record)
336
+ socket.write("#{command}\n")
337
+ command == 'quit' ? wait_monitor_close(socket) : read_monitor_prompt(socket)
338
+ end
339
+ end
340
+ rescue SystemCallError, IOError, Timeout::Error
341
+ raise Error, 'Cannot control the recorded QEMU process', cause: nil
342
+ end
343
+
344
+ def process_name(record)
345
+ "empeira-#{@context.workspace.id}-#{record.fetch('hostname')}-#{record.fetch('peer').fetch('token')}"
346
+ end
347
+
348
+ def verify_monitor_identity!(socket, record)
349
+ socket.write("info name\n")
350
+ return if read_monitor_prompt(socket).include?(process_name(record))
351
+
352
+ raise Providers::OwnershipError, 'QEMU monitor belongs to a different node instance; control refused'
353
+ end
354
+
355
+ def wait_monitor_close(socket)
356
+ loop { socket.readpartial(1024) }
357
+ rescue EOFError
358
+ nil
359
+ end
360
+
361
+ def read_monitor_prompt(socket)
362
+ buffer = +''
363
+ buffer << socket.readpartial(1024) until buffer.include?('(qemu)') || buffer.bytesize > 16_384
364
+ raise Error, 'Unexpected QEMU monitor response' unless buffer.include?('(qemu)')
365
+
366
+ buffer
367
+ end
368
+
369
+ def monitor_path(hostname)
370
+ path = monitor_directory(hostname).join('monitor.sock')
371
+ raise Error, 'Temporary path is too long for a QEMU monitor socket' if path.to_s.bytesize > 100
372
+
373
+ path
374
+ end
375
+
376
+ def monitor_directory(hostname)
377
+ identifier = Digest::SHA256.hexdigest("#{@context.workspace.id}:#{hostname}")[0, 24]
378
+ [@context.locations.temporary, @context.locations.home].each do |root|
379
+ directory = root.join("empeira-#{identifier}")
380
+ return directory if directory.join('monitor.sock').to_s.bytesize <= 100
381
+ end
382
+
383
+ raise Error, 'No short private path is available for a QEMU monitor socket'
384
+ end
385
+
386
+ def prepare_monitor_directory(hostname)
387
+ directory = monitor_directory(hostname)
388
+ FileUtils.mkdir_p(directory, mode: 0o700)
389
+ metadata = File.lstat(directory)
390
+ return if metadata.directory? && metadata.uid == Process.uid && metadata.mode.nobits?(0o077)
391
+
392
+ raise Error, 'VM monitor directory is not private to the current user'
393
+ end
394
+
395
+ def node_directory(hostname)
396
+ @context.locations.workspace(@context.workspace).join('vms', hostname)
397
+ end
398
+
399
+ def pid_path(hostname)
400
+ node_directory(hostname).join('qemu.pid')
401
+ end
402
+ end
403
+ end
404
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ # Removal validates host ownership and never contacts the guest or verifies base-image contents.
6
+ class Removal
7
+ def initialize(context:, runner:, runtime:, store:)
8
+ @context = context
9
+ @runner = runner
10
+ @runtime = runtime
11
+ @store = store
12
+ end
13
+
14
+ def remove(record, state)
15
+ root = @store.directory
16
+ path = root.join('vms', record.fetch('hostname'))
17
+ Infrastructure::OwnedFiles.verify_removable!(path, root: root)
18
+ SessionLock.new(context: @context, record: record).exclusive do
19
+ engine = VM.registry.build('qemu', context: @context, runner: @runner)
20
+ qemu = QemuRuntime.new(engine: engine, runner: @runner, context: @context)
21
+ peer = Network::Peer::Backend.build(context: @context, runtime: @runtime, runner: @runner, store: @store)
22
+ qemu.stop(record)
23
+ peer.destroy(record, state)
24
+ qemu.cleanup(record)
25
+ Infrastructure::OwnedFiles.remove(path, root: root)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Empeira
6
+ module VM
7
+ # Observe cloud-init growth before package bootstrap without changing guest partitions.
8
+ class RootDisk
9
+ ALIGNMENT_MARGIN = 16 * (1024**2)
10
+
11
+ def initialize(guest:)
12
+ @guest = guest
13
+ end
14
+
15
+ def verify!(record, size_gib:)
16
+ data = JSON.parse(observe(record, %w[lsblk --json --bytes --paths --output NAME,TYPE,SIZE,MOUNTPOINT]))
17
+ raise TypeError unless data.is_a?(Hash)
18
+
19
+ root, disk = root_device(data.fetch('blockdevices'))
20
+ verify_partition!(root, disk, size_gib * Disk::GIB)
21
+ verify_filesystem!(record, root)
22
+ rescue JSON::ParserError, KeyError, TypeError, ArgumentError
23
+ raise Error, 'Cannot verify VM root disk growth: malformed guest disk metadata; inspect cloud-init logs',
24
+ cause: nil
25
+ end
26
+
27
+ private
28
+
29
+ def verify_filesystem!(record, root)
30
+ capacity = Integer(observe(record, ['df', '--block-size=1', '--output=size', '/']).lines.last, 10)
31
+ root_size = byte_size(root)
32
+ return if capacity.between?(root_size * 95 / 100, root_size)
33
+
34
+ raise Error, "VM root filesystem has not grown: #{root.fetch('name')} partition=#{root_size} bytes, " \
35
+ "filesystem=#{capacity} bytes; inspect cloud-init logs"
36
+ end
37
+
38
+ def observe(record, arguments)
39
+ result = @guest.run(record, arguments)
40
+ return result.stdout if result.success?
41
+
42
+ details = Execution::Diagnostics.command(result, operation: 'Verify VM root disk growth', tool: arguments.first)
43
+ raise Error, "Cannot verify VM root disk growth\n#{details}", cause: nil
44
+ end
45
+
46
+ def root_device(devices)
47
+ roots = device_pairs(devices).select { |device, _parent| device['mountpoint'] == '/' }
48
+ unless roots.size == 1
49
+ raise Error,
50
+ 'Cannot identify a unique VM root partition from lsblk; inspect cloud-init logs'
51
+ end
52
+
53
+ root, disk = roots.first
54
+ unless root['type'] == 'part' && disk && disk['type'] == 'disk'
55
+ raise Error, 'Cannot verify VM root growth for this block-device layout; inspect cloud-init logs'
56
+ end
57
+
58
+ [root, disk]
59
+ end
60
+
61
+ def device_pairs(devices, parent = nil)
62
+ raise TypeError unless devices.is_a?(Array) && devices.all?(Hash)
63
+
64
+ devices.flat_map { |device| [[device, parent], *device_pairs(device.fetch('children', []), device)] }
65
+ end
66
+
67
+ def verify_partition!(root, disk, expected)
68
+ free = unpartitioned(disk)
69
+ disk_size = byte_size(disk)
70
+ return if disk_size == expected && free.between?(0, ALIGNMENT_MARGIN)
71
+
72
+ raise Error, "VM root partition has not grown: #{root.fetch('name')}, disk=#{disk_size} bytes, " \
73
+ "expected=#{expected} bytes, unpartitioned=#{free} bytes; inspect cloud-init logs"
74
+ end
75
+
76
+ def unpartitioned(disk)
77
+ partitions = disk.fetch('children').select { |device| device['type'] == 'part' }
78
+ sizes = [byte_size(disk), *partitions.map { |partition| byte_size(partition) }]
79
+
80
+ sizes.first - sizes.drop(1).sum
81
+ end
82
+
83
+ def byte_size(device)
84
+ size = device.fetch('size')
85
+ size = Integer(size, 10) if size.is_a?(String) && size.match?(/\A[0-9]+\z/)
86
+ raise TypeError unless size.is_a?(Integer) && size.positive?
87
+
88
+ size
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ # Pin one node instance during access without retaining the workspace mutation lock.
6
+ class SessionLock
7
+ def initialize(context:, record:)
8
+ directory = context.locations.workspace(context.workspace)
9
+ @path = directory.join("vm-session-#{record.fetch('peer').fetch('token')}.lock")
10
+ end
11
+
12
+ def acquire(shared:)
13
+ lock = open_lock
14
+ mode = shared ? File::LOCK_SH : File::LOCK_EX
15
+ unless lock.flock(mode | File::LOCK_NB)
16
+ raise Infrastructure::Locked, 'VM instance has active access or lifecycle operations; detach and retry'
17
+ end
18
+
19
+ lock.close_on_exec = true
20
+ acquired = true
21
+ lock
22
+ ensure
23
+ lock&.close unless acquired
24
+ end
25
+
26
+ def exclusive
27
+ lock = acquire(shared: false)
28
+ yield
29
+ ensure
30
+ lock&.close
31
+ end
32
+
33
+ private
34
+
35
+ def open_lock
36
+ raise Providers::OwnershipError, 'VM session lock path is unsafe' if @path.dirname.symlink? || @path.symlink?
37
+
38
+ # A successful open passes descriptor ownership to acquire.
39
+ # rubocop:disable-next Style/FileOpen
40
+ lock = File.open(@path, File::RDWR | File::CREAT, 0o600)
41
+ verify_lock!(lock.stat)
42
+
43
+ opened = true
44
+ lock
45
+ ensure
46
+ lock&.close unless opened
47
+ end
48
+
49
+ def verify_lock!(metadata)
50
+ return if metadata.file? && metadata.uid == Process.uid && metadata.mode.nobits?(0o077)
51
+
52
+ raise Providers::OwnershipError, 'VM session lock ownership is invalid'
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ # Direct peer transport; system login never invokes the privileged management channel.
6
+ class SystemSSH
7
+ def initialize(context:, runner:, runtime:, qemu:)
8
+ @context = context
9
+ @runner = runner
10
+ @qemu = qemu
11
+ @peer = Network::Peer::Backend.build(context: context, runner: runner, runtime: runtime,
12
+ store: Infrastructure::Store.new(context: context))
13
+ end
14
+
15
+ def session(record, user: nil, identity: nil, port: nil)
16
+ selected = Configuration::SSHPreferences.port!(port || 22)
17
+ raise Error, 'Recorded VM is not running; system SSH refused' unless @qemu.running?(record)
18
+
19
+ proxy = @peer.system_ssh_command(record, port: selected)
20
+ endpoint = record.merge('ssh_port' => selected)
21
+ credentials = Node::SSHCredentials.new(context: @context, runner: @runner, provider: 'vm',
22
+ hostname: record.fetch('hostname'), purpose: :system)
23
+ Node::UserSSH.new(runner: @runner, credentials: credentials, proxy_command: proxy,
24
+ default_user: CloudInit::USER, managed_identity: true,
25
+ home: @context.locations.home).session(endpoint, user: user, identity: identity)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'pathname'
6
+
7
+ module Empeira
8
+ class Workspace
9
+ attr_reader :id, :path
10
+
11
+ def self.recover(path, user_home: Dir.home)
12
+ root = recovery_root(path)
13
+ user = Pathname(user_home).realpath.to_s
14
+ identity = allocate
15
+ identity.instance_variable_set(:@id, Digest::SHA256.hexdigest(JSON.generate([user, root.to_s]))[0, 24].freeze)
16
+ identity.instance_variable_set(:@path, root.freeze)
17
+ identity.freeze
18
+ rescue SystemCallError
19
+ raise ConfigurationError, 'Recovery project path and user home must be accessible'
20
+ end
21
+
22
+ def self.recovery_root(path)
23
+ unless path.is_a?(String) && !path.include?("\0") && Pathname(path).absolute?
24
+ raise ConfigurationError, 'Recovery project path must be an absolute former Git root path'
25
+ end
26
+
27
+ root = Pathname(path).cleanpath
28
+ root.exist? ? root.realpath : root
29
+ end
30
+
31
+ def initialize(path: Dir.pwd, user_home: Dir.home)
32
+ @path = ProjectRoot.resolve(path).path
33
+ raise ConfigurationError, 'project path must be a directory' unless @path.directory?
34
+
35
+ user = Pathname(user_home).realpath.to_s
36
+ @id = Digest::SHA256.hexdigest(JSON.generate([user, @path.to_s]))[0, 24].freeze
37
+ freeze
38
+ rescue SystemCallError
39
+ raise ConfigurationError, 'project path and user home must exist and be accessible'
40
+ end
41
+ end
42
+ end