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,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # rubocop:disable-next Metrics/ModuleLength -- VM creation and its ordered first-boot preparation are one workflow.
6
+ module VMPreparation
7
+ def run(request)
8
+ @created_vm = nil
9
+ mutate do
10
+ server = ready_server
11
+ image, accelerator = preflight_vm(request)
12
+ record, overlay, seed = prepare_vm_disk(request, image, accelerator)
13
+ provision_vm(record, overlay, seed, server)
14
+ lifecycle_result(record.fetch('hostname'), :running, changed: true)
15
+ rescue StandardError => e
16
+ report_retained_vm(e)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def provision_vm(record, overlay, seed, server)
23
+ packages = package_bootstrap(record)
24
+ Network::Gateway.new(context: context, runtime: @runtime, state: @state).phase(record, bootstrap: true)
25
+ boot_vm(record, overlay, seed)
26
+ @bootstrap_proxy.start(@state, @requirements, source: record.fetch('peer').fetch('ip')) if
27
+ @requirements.required? || packages.required?
28
+ finish_vm(record, server, packages)
29
+ ensure
30
+ @bootstrap_proxy.cleanup(@state)
31
+ end
32
+
33
+ def report_retained_vm(error)
34
+ raise error unless @created_vm
35
+
36
+ name = @created_vm.fetch('hostname')
37
+ message = "#{error.message}\nVM #{name} retained for diagnosis. Cleanup: empeira node destroy #{name}"
38
+ raise error.exception(message), cause: error
39
+ end
40
+
41
+ def prepare_vm_disk(request, image, accelerator)
42
+ @progress.stage(20, 'Preparing VM image...')
43
+ base = @cache.fetch(image)
44
+ record = reserve_vm(request, image.identity, accelerator)
45
+ @created_vm = record
46
+ @progress.stage(35, 'Creating overlay and cloud-init seed...')
47
+ overlay = @disk.create(hostname: record.fetch('hostname'), base: base,
48
+ size_gib: context.configuration.dig('vm', 'disk'))
49
+ [record, overlay, @cloud.prepare(record)]
50
+ end
51
+
52
+ def boot_vm(record, overlay, seed)
53
+ @progress.stage(50, 'Connecting VM to managed services...')
54
+ @peer.prepare(record, @state)
55
+ attachment = Network::Peer::Attachment.new(backend: @peer, record: record, state: @state)
56
+ refresh_dns
57
+ @progress.stage(55, 'Starting virtual machine...')
58
+ begin
59
+ record['pid'] = @qemu.launch(record: record, overlay: overlay, seed: seed, network: attachment)
60
+ ensure
61
+ save if record['pid']
62
+ end
63
+ @progress.stage(60, 'Waiting for VM network and bootstrap...')
64
+ verify_vm_boot(record)
65
+ end
66
+
67
+ def verify_vm_boot(record)
68
+ @guest.wait(record, progress: @progress)
69
+ ::Empeira::VM::RootDisk.new(guest: @guest).verify!(record, size_gib: context.configuration.dig('vm', 'disk'))
70
+ VMBootstrap.new(guest: @guest).verify(record: record, bootstrap: Bootstrap.new(provider: 'vm'))
71
+ record['empeira_fact'] = ExternalFact.fingerprint('vm')
72
+ save
73
+ @cloud.finish(record, guest: @guest)
74
+ end
75
+
76
+ # rubocop:disable-next Metrics/AbcSize -- Verified certificate retirement precedes enrollment and ready-state persistence.
77
+ def finish_vm(record, server, packages)
78
+ @progress.stage(70, 'Installing/configuring Puppet agent...')
79
+ prepare_agent(record, packages)
80
+ reconcile_interfaces(record)
81
+ @progress.stage(85, 'Signing VM certificate...')
82
+ certificate_runtime = ::Empeira::VM::CertificateRuntime.new(runtime: @runtime, guest: @guest, record: record)
83
+ certificates = Certificates.new(runtime: certificate_runtime, server: server)
84
+ certificates.clean_retired(@state, record.fetch('hostname')) { save } if @state['retired_certificates']
85
+ certificates.enroll({ 'vm' => true }, record) { save }
86
+ reconcile_guest(record)
87
+ record['provisioned'] = true
88
+ record['state'] = 'running'
89
+ save
90
+ @progress.stage(95, 'Running Puppet...')
91
+ puppet_run(record)
92
+ end
93
+
94
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- Preserve bootstrap cleanup before runtime activation.
95
+ def prepare_agent(record, packages)
96
+ execute = ->(arguments) { @guest.run(record, arguments, timeout: 300) }
97
+ package_configuration(record, execute).preserve do
98
+ if packages.required?
99
+ @progress.stage(75, 'Bootstrapping packages...')
100
+ packages.run(proxy_url: @bootstrap_proxy.url)
101
+ end
102
+ @agent.ensure_installed(record, requirements: @requirements, proxy_url: @bootstrap_proxy.url)
103
+ end
104
+ PackageSources.verify!(execute: ->(arguments) { @guest.run(record, arguments) })
105
+ @bootstrap_proxy.cleanup(@state)
106
+ record['network_phase'] = 'runtime'
107
+ save
108
+ Network::Gateway.new(context: context, runtime: @runtime, state: @state).phase(record, bootstrap: false)
109
+ refresh_dns
110
+ reconcile_runtime_proxy(record)
111
+ reconcile_interactive_tools(record)
112
+ configure_agent(record)
113
+ end
114
+
115
+ def package_configuration(record, execute)
116
+ family = PackageBootstrap::FAMILIES.fetch(record.fetch('os'))
117
+ klass = family == 'debian' ? AptConfiguration : RpmConfiguration
118
+ options = family == 'debian' ? { on_retry: @progress.method(:warning) } : {}
119
+ klass.new(os: record.fetch('os'), execute: execute, **options)
120
+ end
121
+
122
+ def package_bootstrap(record)
123
+ PackageBootstrap.new(config: context.configuration.dig('bootstrap', 'packages'), os: record.fetch('os'),
124
+ execute: ->(arguments) { @guest.run(record, arguments, timeout: 300) },
125
+ copy: lambda { |source, destination, mode|
126
+ @guest.copy_to(record, source, destination, mode: mode)
127
+ },
128
+ rpm_options: @requirements.rpm_options,
129
+ progress: @progress.method(:heartbeat))
130
+ end
131
+
132
+ def refresh_dns
133
+ plan = ControlPlane::Plan.new(context: context)
134
+ ControlPlane::Discovery.new(plan: plan, runtime: @runtime, state: @state).refresh
135
+ end
136
+
137
+ def configure_agent(record)
138
+ { 'certname' => record.fetch('hostname'), 'server' => 'server.empeira.internal',
139
+ 'environment' => context.configuration.dig('server', 'environment') }.each do |key, value|
140
+ result = @guest.run(record, [Certificates::PUPPET, 'config', 'set', key, value, '--section', 'main'])
141
+ next if result.success?
142
+
143
+ details = Execution::Diagnostics.command(result, operation: "VM agent configuration: #{key}", tool: 'puppet')
144
+ raise Error, "VM agent configuration failed; node retained for diagnosis\n#{details}", cause: nil
145
+ end
146
+ end
147
+
148
+ def puppet_run(record)
149
+ reconcile_provider_fact(record)
150
+ result = @progress.streaming do
151
+ @guest.stream(record, RuntimeProxy.command(context, PuppetCommand.arguments))
152
+ end
153
+ record['last_puppet_exit'] = result.exit_status
154
+ save
155
+ return result if [0, 2].include?(result.exit_status)
156
+
157
+ raise Error, "Puppet agent failed (exit #{result.exit_status}); VM retained for shell/logs"
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'socket'
5
+
6
+ module Empeira
7
+ module Node
8
+ # VM records live in the same locked workspace inventory as container records.
9
+ # rubocop:disable-next Metrics/ModuleLength -- Workspace state and record validation share one boundary.
10
+ module VMState
11
+ def list
12
+ load_state
13
+ health = @peer.health(vm_records.values)
14
+ vm_records.map do |_, record|
15
+ record.slice('hostname', 'provider', 'os', 'version', 'last_puppet_exit').merge(
16
+ 'runtime' => 'qemu', 'state' => observed_state(record),
17
+ 'network' => if health[record.fetch('hostname')]
18
+ 'running'
19
+ else
20
+ (record['state'] == 'stopped' ? 'reserved' : 'degraded')
21
+ end
22
+ )
23
+ end
24
+ end
25
+
26
+ def inspect_resource(name:)
27
+ load_state
28
+ record = vm_records[name.downcase]
29
+ return unless record
30
+
31
+ state = record['provisioned'] && @qemu.observed_running?(record) ? :running : :stopped
32
+ Providers::Resource.new(name: record.fetch('hostname'), owner: context.workspace.id, state: state)
33
+ end
34
+
35
+ private
36
+
37
+ def observed_state(record)
38
+ return 'incomplete' unless record['provisioned']
39
+
40
+ return 'running' if @qemu.observed_running?(record)
41
+
42
+ return 'preparing' if record['state'] == 'preparing'
43
+
44
+ record['state'] == 'stopped' ? 'stopped' : 'missing'
45
+ end
46
+
47
+ def vm_records
48
+ @nodes.select { |_, record| record['provider'] == 'vm' }
49
+ end
50
+
51
+ def mutate(availability: true)
52
+ @build_info.require_compatible!(context.configuration.dig('requirements', 'empeira'))
53
+ @store.with_lock do
54
+ load_state
55
+ check_runtime!(availability: availability)
56
+ yield
57
+ end
58
+ end
59
+
60
+ def load_state(state = @store.load)
61
+ @state = state
62
+ @nodes = state&.fetch('nodes', {}) || {}
63
+ end
64
+
65
+ def check_runtime!(availability: true)
66
+ raise Error, 'Control plane is down; run empeira up first' unless @state&.dig('resources', 'network', 'id')
67
+ if @state.fetch('runtime') != context.container_engine
68
+ raise Error, 'Node runtime differs from workspace ownership; restore runtime.container_engine'
69
+ end
70
+
71
+ @runtime.check_available! if availability
72
+ end
73
+
74
+ def save
75
+ @state['nodes'] = @nodes
76
+ @store.write(@state)
77
+ end
78
+
79
+ def fetch_vm(name)
80
+ record = vm_records[name.downcase]
81
+ raise Providers::NotFound, 'VM node does not exist in this workspace' unless record
82
+
83
+ record
84
+ end
85
+
86
+ def ready_server
87
+ plan = ControlPlane::Plan.new(context: context)
88
+ definition = plan.definitions.fetch('server')
89
+ expected = @state.dig('control_plane', 'services', 'server', 'id')
90
+ server = @runtime.inspect_service(definition, expected_id: expected)
91
+ unless server && server['state'] == 'running' &&
92
+ server.dig('labels', 'io.empeira.definition') == definition.fingerprint &&
93
+ ControlPlane::Health.new(runtime: @runtime, plan: plan).ready?('server', 'server' => server)
94
+ raise Error, 'Configuration server is not ready; run empeira up first'
95
+ end
96
+
97
+ server
98
+ end
99
+
100
+ def reserve_vm(request, identity, accelerator)
101
+ name = request.hostname.downcase
102
+ reject_vm_name!(name)
103
+ record = vm_record(request, identity, accelerator, name)
104
+ @nodes[name] = record
105
+ save
106
+ record
107
+ end
108
+
109
+ def reject_vm_name!(name)
110
+ aliases = [name, name.delete_suffix('.empeira.internal')]
111
+ taken = @nodes.keys.any? { |key| aliases.include?(key.delete_suffix('.empeira.internal')) }
112
+ return unless taken || aliases.intersect?(Network::Naming.new.reserved(context.configuration))
113
+
114
+ raise Providers::AlreadyExists, 'Hostname is reserved or already exists in this workspace'
115
+ end
116
+
117
+ def vm_record(request, identity, accelerator, name)
118
+ request.to_h.transform_keys(&:to_s).merge(
119
+ 'hostname' => name, 'engine' => 'qemu', 'accelerator' => accelerator,
120
+ 'architecture' => context.platform.architecture.to_s, 'base_image' => identity.to_h,
121
+ 'overlay' => "vms/#{name}/disk.qcow2", 'network' => @state.dig('resources', 'network', 'logical_identity'),
122
+ 'mac_address' => vm_mac(name),
123
+ 'peer' => peer_lease,
124
+ 'management_layout' => ::Empeira::VM::Management::VERSION, 'pid' => nil,
125
+ 'network_phase' => 'bootstrap', 'state' => 'preparing', 'provisioned' => false,
126
+ 'certificate_key' => nil, 'last_puppet_exit' => nil,
127
+ 'created_at' => Time.now.utc.iso8601
128
+ )
129
+ end
130
+
131
+ # rubocop:disable-next Metrics/AbcSize -- Lease and shared DNS/gateway bindings are one identity.
132
+ def peer_lease
133
+ plan = ControlPlane::Plan.new(context: context)
134
+ dns = @runtime.inspect_service(plan.definitions.fetch('dns'),
135
+ expected_id: @state.dig('control_plane', 'services', 'dns', 'id'))
136
+ address = dns&.dig('networks', plan.network, 'IPAddress')
137
+ raise Error, 'CoreDNS is unavailable on the peer network' unless address
138
+
139
+ Network::Peer::Layout.new(@state.fetch('peer_network').fetch('subnet')).lease(@nodes)
140
+ .merge('backend' => @peer.key, 'dns' => address,
141
+ 'gateway' => Network::Gateway.address(@state.fetch('peer_network').fetch('subnet')))
142
+ end
143
+
144
+ def vm_mac(_hostname)
145
+ hex = SecureRandom.hex(4)
146
+ "52:54:#{hex.scan(/../).join(':')}"
147
+ end
148
+
149
+ def base_for(record)
150
+ fields = record.fetch('base_image').transform_keys(&:to_sym)
151
+ identity = Images::Identity.new(**fields)
152
+ context.locations.image(identity).join('base.qcow2')
153
+ end
154
+
155
+ def lifecycle_result(name, state, changed:)
156
+ resource = Providers::Resource.new(name: name, owner: context.workspace.id, state: state)
157
+ Providers::Result.new(resource: resource, changed: changed)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+
5
+ module Empeira
6
+ module Platform
7
+ class Facts
8
+ attr_reader :os, :architecture
9
+
10
+ def initialize(host_os: RbConfig::CONFIG.fetch('host_os'), host_cpu: RbConfig::CONFIG.fetch('host_cpu'),
11
+ environment: ENV, kernel_release: nil)
12
+ @os = detect_os(host_os, environment, kernel_release)
13
+ @architecture = normalize_architecture(host_cpu)
14
+ freeze
15
+ end
16
+
17
+ def process_groups?
18
+ true
19
+ end
20
+
21
+ def vm_install_hint
22
+ return 'Install QEMU, xorriso and OpenSSH, for example: brew install qemu xorriso' if os == :macos
23
+
24
+ release = File.read('/etc/os-release')
25
+ if release.match?(/^(?:ID|ID_LIKE)=.*(?:ubuntu|debian)/)
26
+ package = architecture == :arm64 ? 'qemu-system-arm qemu-efi-aarch64' : 'qemu-system-x86'
27
+ return "Install missing dependencies: sudo apt install #{package} qemu-utils xorriso openssh-client"
28
+ end
29
+ 'Install QEMU system tools, qemu-img, xorriso and OpenSSH using your host distribution package manager.'
30
+ rescue SystemCallError
31
+ 'Install QEMU system tools, qemu-img, xorriso and OpenSSH using your host distribution package manager.'
32
+ end
33
+
34
+ private
35
+
36
+ def detect_os(host_os, environment, kernel_release)
37
+ case host_os.downcase
38
+ when /linux/ then wsl?(environment, kernel_release) ? :wsl : :linux
39
+ when /darwin/ then :macos
40
+ else raise UnsupportedPlatform, 'Unsupported operating system (expected Linux, macOS, or WSL2)'
41
+ end
42
+ end
43
+
44
+ def wsl?(environment, kernel_release)
45
+ return true if %w[WSL_INTEROP WSL_DISTRO_NAME].any? { |key| !environment[key].to_s.empty? }
46
+
47
+ release = kernel_release || read_kernel_release
48
+ release.match?(/microsoft|wsl/i)
49
+ end
50
+
51
+ def read_kernel_release
52
+ File.read('/proc/sys/kernel/osrelease')
53
+ rescue SystemCallError
54
+ ''
55
+ end
56
+
57
+ def normalize_architecture(cpu)
58
+ case cpu.downcase
59
+ when /\A(?:x86_64|amd64|x64)(?:\W|\z)/ then :amd64
60
+ when /\A(?:aarch64|arm64)(?:\W|\z)/ then :arm64
61
+ else raise UnsupportedPlatform, 'Unsupported architecture (expected amd64 or arm64)'
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'etc'
4
+ require 'shellwords'
5
+
6
+ module Empeira
7
+ module Platform
8
+ class Kvm
9
+ DEVICE = '/dev/kvm'
10
+
11
+ def initialize(platform:)
12
+ @platform = platform
13
+ end
14
+
15
+ def verify!
16
+ version = File.open(DEVICE, File::RDWR) do |device|
17
+ device.ioctl(0xae00)
18
+ rescue SystemCallError => e
19
+ raise UnavailableFeature, "KVM device opens but KVM_GET_API_VERSION ioctl fails (#{e.class})", cause: nil
20
+ end
21
+ return version if version == 12
22
+
23
+ raise UnavailableFeature, "KVM returned unsupported API version #{version} (expected 12)"
24
+ rescue Errno::ENOENT
25
+ raise UnavailableFeature, 'KVM is unavailable: /dev/kvm is missing; enable host KVM virtualization', cause: nil
26
+ rescue Errno::EACCES, Errno::EPERM
27
+ raise UnavailableFeature, permission_diagnostic, cause: nil
28
+ rescue SystemCallError => e
29
+ raise UnavailableFeature, "KVM device cannot be opened (#{e.class}); check host device policy", cause: nil
30
+ end
31
+
32
+ private
33
+
34
+ def permission_diagnostic
35
+ stat = File.stat(DEVICE)
36
+ groups = current_groups
37
+ ["KVM is unavailable: #{DEVICE} is #{access_description}.",
38
+ "Owner: #{user(stat.uid)}:#{group(stat.gid)} (mode #{format('%04o', stat.mode & 0o777)})",
39
+ "Current user: #{user(Process.euid)}", "Current groups: #{group_names(groups)}",
40
+ permission_hint(stat, groups)].join("\n")
41
+ rescue SystemCallError
42
+ 'KVM is unavailable: device permissions deny access; inspect /dev/kvm ownership and host device policy'
43
+ end
44
+
45
+ def group_names(groups)
46
+ groups.map { |id| group(id) }.join(' ')
47
+ end
48
+
49
+ def current_groups
50
+ (Process.groups + [Process.egid]).uniq
51
+ end
52
+
53
+ def access_description
54
+ access = []
55
+ access << 'unreadable' unless File.readable?(DEVICE)
56
+ access << 'unwritable' unless File.writable?(DEVICE)
57
+ access << 'denied by device policy' if access.empty?
58
+ access.join(' and ')
59
+ end
60
+
61
+ def missing_group?(stat, groups)
62
+ Process.euid != stat.uid && !groups.include?(stat.gid) && stat.mode.allbits?(0o060) &&
63
+ (!File.readable?(DEVICE) || !File.writable?(DEVICE))
64
+ end
65
+
66
+ def permission_hint(stat, groups)
67
+ unless missing_group?(stat, groups)
68
+ return 'Device permissions or host security policy deny access; check mode, ACLs and device restrictions.'
69
+ end
70
+
71
+ required_group = Shellwords.escape(group(stat.gid))
72
+ hint = "Required group: #{group(stat.gid)}\nSuggested fix: sudo usermod -aG #{required_group} \"$USER\"\n" \
73
+ 'Start a new login session for group membership to take effect.'
74
+ hint += "\nOn WSL2 restart the instance/session: wsl.exe --shutdown" if @platform.os == :wsl
75
+ hint
76
+ end
77
+
78
+ def user(id)
79
+ Etc.getpwuid(id).name
80
+ rescue ArgumentError
81
+ id.to_s
82
+ end
83
+
84
+ def group(id)
85
+ Etc.getgrgid(id).name
86
+ rescue ArgumentError
87
+ id.to_s
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'tmpdir'
5
+
6
+ module Empeira
7
+ module Platform
8
+ class Locations
9
+ attr_reader :cache, :state, :temporary, :home
10
+
11
+ def initialize(facts: Facts.new, home: Dir.home, environment: ENV, temporary_root: Dir.tmpdir)
12
+ @home = Pathname(home)
13
+ @environment = environment
14
+ @cache, @state = roots(facts.os).map { |path| path.join('empeira') }
15
+ @temporary = Pathname(temporary_root)
16
+ end
17
+
18
+ def workspace(identity)
19
+ state.join('workspaces', identity.id)
20
+ end
21
+
22
+ def user_configuration
23
+ home.join('.empeira.yaml')
24
+ end
25
+
26
+ def addons
27
+ home.join('.empeira', 'addons').expand_path
28
+ end
29
+
30
+ def image(identity)
31
+ cache.join('images', identity.cache_key)
32
+ end
33
+
34
+ private
35
+
36
+ def roots(os)
37
+ case os
38
+ when :macos then [@home.join('Library/Caches'), @home.join('Library/Application Support')]
39
+ else
40
+ [absolute_environment('XDG_CACHE_HOME', @home.join('.cache')),
41
+ absolute_environment('XDG_STATE_HOME', @home.join('.local/state'))]
42
+ end
43
+ end
44
+
45
+ def absolute_environment(key, fallback)
46
+ value = @environment[key]
47
+ value && Pathname(value).absolute? ? Pathname(value) : fallback
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Platform
5
+ class Resolvers
6
+ attr_reader :routes
7
+
8
+ def initialize(platform:, runner:, reader: File)
9
+ @platform = platform
10
+ @runner = runner
11
+ @reader = reader
12
+ @routes = {}
13
+ end
14
+
15
+ def resolve(config)
16
+ return usable(config.fetch('servers')) if config.fetch('mode') == 'explicit'
17
+
18
+ usable(@platform.os == :macos ? macos : linux)
19
+ end
20
+
21
+ private
22
+
23
+ def linux
24
+ addresses = read('/etc/resolv.conf').scan(/^nameserver\s+(\S+)/).flatten
25
+ return addresses if addresses.any? { |address| reachable?(address) }
26
+
27
+ read('/run/systemd/resolve/resolv.conf').scan(/^nameserver\s+(\S+)/).flatten
28
+ end
29
+
30
+ def macos
31
+ result = @runner.run('scutil', arguments: ['--dns'], timeout: 10)
32
+ unless result.success?
33
+ raise Error,
34
+ 'Cannot discover macOS DNS; configure dns.upstream.mode=explicit and servers'
35
+ end
36
+
37
+ parse_macos(result.stdout)
38
+ end
39
+
40
+ def parse_macos(output)
41
+ defaults = []
42
+ output.split(/^resolver #/).each do |block|
43
+ next if block.match?(/options\s*:\s*mdns/)
44
+
45
+ addresses = macos_addresses(block)
46
+ domain = block[/^\s*domain\s*:\s*([a-zA-Z0-9.-]+)\s*$/, 1]&.downcase
47
+ defaults = route_or_default(domain, addresses, defaults)
48
+ end
49
+ defaults
50
+ end
51
+
52
+ def route_or_default(domain, addresses, defaults)
53
+ return defaults if domain&.end_with?('empeira.internal')
54
+ return defaults.empty? ? addresses : defaults unless domain
55
+
56
+ @routes[domain] = addresses unless addresses.empty?
57
+ defaults
58
+ end
59
+
60
+ def macos_addresses(block)
61
+ block.scan(/nameserver\[\d+\]\s*:\s*(\S+)/).flatten.select { |ip| reachable?(ip) }
62
+ end
63
+
64
+ def read(path)
65
+ @reader.read(path)
66
+ rescue SystemCallError
67
+ ''
68
+ end
69
+
70
+ def reachable?(address)
71
+ ip = IPAddr.new(address)
72
+ !ip.loopback? && !ip.link_local? && ip.to_i.positive?
73
+ rescue IPAddr::InvalidAddressError
74
+ false
75
+ end
76
+
77
+ def usable(addresses)
78
+ result = addresses.select { |address| reachable?(address) }.uniq
79
+ return result unless result.empty?
80
+
81
+ raise Error, 'No container-reachable DNS upstream found; configure dns.upstream.mode=explicit and servers'
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Platform
5
+ class Routes
6
+ def initialize(platform:, runner:)
7
+ @platform = platform
8
+ @runner = runner
9
+ end
10
+
11
+ def ipv4
12
+ return linux_routes if @platform.os != :macos
13
+
14
+ mac_routes
15
+ end
16
+
17
+ private
18
+
19
+ def mac_routes
20
+ result = @runner.run('netstat', arguments: %w[-rn -f inet], timeout: 10)
21
+ raise Error, 'Cannot inspect host IPv4 routes before allocating the peer network' unless result.success?
22
+
23
+ result.stdout.lines.filter_map do |line|
24
+ destination = line.split.first
25
+ next unless destination&.match?(/\A\d/)
26
+
27
+ expand_destination(destination)
28
+ end
29
+ end
30
+
31
+ def expand_destination(destination)
32
+ address, prefix = destination.split('/')
33
+ octets = address.split('.')
34
+ "#{(octets + (['0'] * (4 - octets.size))).join('.')}/#{prefix || (octets.size * 8)}"
35
+ end
36
+
37
+ def linux_routes
38
+ result = @runner.run('ip', arguments: %w[-j -4 route show table all], timeout: 10)
39
+ raise Error, 'iproute2 is required to verify host routes before peer subnet allocation' unless result.success?
40
+
41
+ JSON.parse(result.stdout).filter_map do |route|
42
+ destination = route.fetch('dst')
43
+ destination unless %w[default 0.0.0.0/0].include?(destination)
44
+ end
45
+ rescue JSON::ParserError, KeyError
46
+ raise Error, 'Cannot verify host IPv4 route inventory', cause: nil
47
+ end
48
+ end
49
+ end
50
+ end