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,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Empeira
6
+ module VM
7
+ # Intent is saved before netlink changes; uncertain transport outcomes are inspected on retry.
8
+ # rubocop:disable-next Metrics/ClassLength -- One locked transaction owns device and parent recovery.
9
+ class Interfaces
10
+ PARENT = Configuration::VMInterfaces::PARENT
11
+
12
+ def initialize(context:, record:, state:, guest:, persist:)
13
+ @context = context
14
+ @record = record
15
+ @state = state
16
+ @persist = persist
17
+ @guest = GuestNetwork.new(guest: guest, record: record)
18
+ end
19
+
20
+ def self.desired(context, hostname)
21
+ Configuration::VMInterfaces.resolve(context.configuration.dig('vm', 'interfaces'), hostname)
22
+ end
23
+
24
+ def self.validate_static!(context, hostname, state)
25
+ definitions = desired(context, hostname)
26
+ InterfaceRoutes.validate_static!(definitions, hostname: hostname, config: context.configuration,
27
+ subnet: state&.dig('peer_network', 'subnet'))
28
+ definitions
29
+ end
30
+
31
+ def self.valid_inventory?(inventory)
32
+ return false unless valid_envelope?(inventory)
33
+
34
+ inventory['devices'].all? { |name, entry| valid_entry?(name, entry) }
35
+ end
36
+
37
+ def self.valid_envelope?(inventory)
38
+ inventory.is_a?(Hash) && inventory.keys.sort_by(&:to_s) == %w[devices parent token] &&
39
+ inventory['token'].is_a?(String) && inventory['token'].match?(/\A[a-f0-9]{32}\z/) &&
40
+ [true, false].include?(inventory['parent']) && inventory['devices'].is_a?(Hash)
41
+ end
42
+
43
+ def self.valid_entry?(name, entry)
44
+ return false unless entry.is_a?(Hash) && entry.key?('definition') &&
45
+ (entry.keys - %w[definition previous]).empty?
46
+
47
+ [entry.fetch('definition'), *entry.values_at('previous').compact].each do |definition|
48
+ Configuration::VMInterfaces.device!(name, definition, 'vm.interfaces inventory')
49
+ end
50
+ true
51
+ rescue ConfigurationError
52
+ false
53
+ end
54
+
55
+ # rubocop:disable-next Metrics/AbcSize -- Ordered mutations retain inspectable intent on every failure.
56
+ def reconcile
57
+ @desired = self.class.validate_static!(@context, @record.fetch('hostname'), @state)
58
+ return false if @desired.empty? && !@record.key?('network_interfaces')
59
+
60
+ prepare_inventory
61
+ observe!
62
+ defaults = @guest.defaults
63
+ @changed = false
64
+ prepare_intents
65
+ prune
66
+ vlan? ? ensure_parent : remove_parent
67
+ @desired.each { |name, definition| apply(name, definition) }
68
+ verify!(defaults)
69
+ @desired.empty? ? release_inventory : @guest.verify_facter!(@desired)
70
+ @changed
71
+ end
72
+
73
+ private
74
+
75
+ def observe!
76
+ @guest.load_modules(vlan: vlan?) unless @desired.empty?
77
+ @guest.refresh
78
+ names = (@inventory.fetch('devices').keys + @desired.keys + (parent_needed? ? [PARENT] : [])).uniq
79
+ @ownership.validate!(names)
80
+ InterfaceRoutes.new(guest: @guest, ownership: @ownership,
81
+ hostname: @record.fetch('hostname')).validate!(@desired)
82
+ end
83
+
84
+ def release_inventory
85
+ @record.delete('network_interfaces')
86
+ @persist.call
87
+ end
88
+
89
+ def prepare_inventory
90
+ unless @record.key?('network_interfaces')
91
+ @record['network_interfaces'] = { 'token' => SecureRandom.hex(16), 'parent' => false, 'devices' => {} }
92
+ @persist.call
93
+ end
94
+ @inventory = @record.fetch('network_interfaces')
95
+ @ownership = InterfaceOwnership.new(context: @context, record: @record, inventory: @inventory, guest: @guest)
96
+ end
97
+
98
+ def vlan?
99
+ @desired.values.any? { |definition| definition.key?('vlan_id') }
100
+ end
101
+
102
+ def parent_needed?
103
+ vlan? || @inventory.fetch('parent')
104
+ end
105
+
106
+ def prune
107
+ # Snapshot names because confirmed removals change this inventory.
108
+ @inventory.fetch('devices').keys.each do |name| # rubocop:disable Style/HashEachMethods
109
+ if @desired.key?(name)
110
+ observed = @ownership.observed_definition(name)
111
+ delete_link(name) if observed && observed != @desired.fetch(name)
112
+ next
113
+ end
114
+
115
+ delete_link(name)
116
+ @inventory.fetch('devices').delete(name)
117
+ @persist.call
118
+ end
119
+ end
120
+
121
+ def prepare_intents
122
+ @desired.each do |name, definition|
123
+ entry = @inventory.fetch('devices')[name]
124
+ next if entry && entry.fetch('definition') == definition
125
+
126
+ observed = @ownership.observed_definition(name)
127
+ @inventory.fetch('devices')[name] = { 'definition' => definition,
128
+ **(observed ? { 'previous' => observed } : {}) }
129
+ @persist.call
130
+ end
131
+ end
132
+
133
+ def ensure_parent
134
+ unless @inventory.fetch('parent')
135
+ @inventory['parent'] = true
136
+ @persist.call
137
+ end
138
+ create_link(PARENT, {}) unless @guest.links.key?(PARENT)
139
+ activate(PARENT)
140
+ end
141
+
142
+ def remove_parent
143
+ return unless @inventory.fetch('parent')
144
+
145
+ delete_link(PARENT)
146
+ @inventory['parent'] = false
147
+ @persist.call
148
+ end
149
+
150
+ def apply(name, definition)
151
+ entry = @inventory.fetch('devices').fetch(name)
152
+ create_link(name, definition) unless @guest.links.key?(name)
153
+ mark_link(name, definition)
154
+ configure_address(name, definition)
155
+ activate(name)
156
+ @persist.call if entry.delete('previous')
157
+ end
158
+
159
+ def create_link(name, definition)
160
+ # Linux applies IFLA_ADDRESS on creation; IFLA_IFALIAS needs a subsequent setlink.
161
+ arguments = ['ip', 'link', 'add', 'name', name, 'address', @ownership.address(name, definition)]
162
+ arguments += if definition.key?('vlan_id')
163
+ ['link', PARENT, 'type', 'vlan', 'id', definition.fetch('vlan_id').to_s]
164
+ else
165
+ %w[type dummy]
166
+ end
167
+ change(arguments)
168
+ @ownership.observed_definition(name)
169
+ end
170
+
171
+ def mark_link(name, definition)
172
+ @ownership.observed_definition(name)
173
+ marker = @ownership.marker(name, definition)
174
+ return if @guest.links.fetch(name)['ifalias'] == marker
175
+
176
+ change(['ip', 'link', 'set', 'dev', name, 'alias', marker])
177
+ return if @guest.links.fetch(name)['ifalias'] == marker
178
+
179
+ @ownership.conflict!(name, 'ownership alias could not be verified')
180
+ end
181
+
182
+ def configure_address(name, definition)
183
+ return unless @guest.ipv4(name).empty?
184
+
185
+ change(['ip', 'address', 'add', definition.fetch('network'), 'dev', name])
186
+ end
187
+
188
+ def activate(name)
189
+ mark_link(name, @ownership.observed_definition(name))
190
+ return if @guest.links.fetch(name).fetch('flags').include?('UP')
191
+
192
+ change(['ip', 'link', 'set', 'dev', name, 'up'])
193
+ end
194
+
195
+ def delete_link(name)
196
+ @guest.refresh
197
+ return unless @guest.links.key?(name)
198
+
199
+ @ownership.deletable!(name)
200
+ change(['ip', 'link', 'delete', 'dev', name])
201
+ @ownership.conflict!(name, 'deletion could not be verified') if @guest.links.key?(name)
202
+ end
203
+
204
+ def change(arguments)
205
+ @guest.command(arguments)
206
+ @changed = true
207
+ @guest.refresh
208
+ end
209
+
210
+ def verify!(defaults)
211
+ @guest.refresh
212
+ unless @guest.defaults == defaults
213
+ raise Error, "Host #{@record.fetch('hostname')}, vm.interfaces: default routes changed; Puppet was not run"
214
+ end
215
+
216
+ @desired.each { |name, definition| verify_device!(name, definition) }
217
+ return unless @inventory.fetch('parent')
218
+
219
+ verify_device!(PARENT, {})
220
+ end
221
+
222
+ def verify_device!(name, definition)
223
+ observed = @ownership.observed_definition(name)
224
+ link = @guest.links[name]
225
+ network = definition['network']
226
+ bindings = @guest.ipv4(name).map { |address| "#{address.fetch('local')}/#{address.fetch('prefixlen')}" }
227
+ return if observed == definition && active_link?(name, link, definition) &&
228
+ bindings == (network ? [network] : [])
229
+
230
+ raise Error, "Host #{@record.fetch('hostname')}, interface #{name}, vm.interfaces: " \
231
+ 'guest type, address or active state verification failed; Puppet was not run'
232
+ end
233
+
234
+ def active_link?(name, link, definition)
235
+ link.fetch('flags').include?('UP') && link['ifalias'] == @ownership.marker(name, definition) &&
236
+ %w[UP UNKNOWN].include?(link['operstate'])
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ # Numeric-root execution over a private VirtIO port, independent of guest login policy.
6
+ module Management
7
+ VERSION = 2
8
+ SLOTS = 8
9
+ CHANNEL = 'org.empeira.management.0'
10
+ DEVICE = "/dev/virtio-ports/#{CHANNEL}".freeze
11
+ DIRECTORY = '/etc/empeira/management'
12
+ UNIT = '/etc/systemd/system/empeira-management.service'
13
+ AGENT = '/usr/local/libexec/empeira-management-agent'
14
+ SHELL_RC = '/usr/local/libexec/empeira-management-shell-rc'
15
+ SETUP = '/usr/local/libexec/empeira-management-setup'
16
+ UPLOADS = '/run/empeira-management'
17
+
18
+ def self.validate!(record)
19
+ return if record['management_layout'] == VERSION
20
+
21
+ raise Error, 'Incompatible VM management layout; use node destroy to remove the owned VM, then recreate it'
22
+ end
23
+
24
+ # rubocop:disable-next Metrics/AbcSize -- Seed the service, adapter and one-time regular account together.
25
+ def self.files(record, system_key:)
26
+ validate!(record)
27
+ raise Error, 'VM system SSH requires a public key' if system_key.to_s.empty?
28
+
29
+ setup = File.read(resource('setup.sh')).sub('@SYSTEM_SETUP@', File.read(resource('system_account.sh')) +
30
+ File.read(resource('system_home.sh')))
31
+ { AGENT => [File.read(resource('guest_agent.py')), '0755'], UNIT => [service(record), '0644'],
32
+ SETUP => [setup, '0755'], SHELL_RC => [File.read(resource('shell_rc.sh')), '0644'],
33
+ "#{DIRECTORY}/system_authorized_keys" => ["#{system_key}\n", '0644'] }.map do |path, entry|
34
+ { 'path' => path, 'permissions' => entry[1], 'owner' => 'root:root', 'content' => entry[0] }
35
+ end
36
+ end
37
+
38
+ # rubocop:disable-next Metrics/MethodLength -- Keep the complete private service policy in one literal.
39
+ def self.service(record)
40
+ token = record.fetch('peer').fetch('token')
41
+ raise Error, 'Invalid VM instance identity' unless token.match?(/\A[0-9a-f]{32}\z/)
42
+
43
+ <<~UNIT
44
+ # Managed by Empeira; no SSH, PAM, NSS login or sudo dependency.
45
+ [Unit]
46
+ Description=Empeira private VirtIO management
47
+ After=systemd-udev-trigger.service
48
+ [Service]
49
+ Type=simple
50
+ User=0
51
+ Group=0
52
+ UMask=0077
53
+ RuntimeDirectory=empeira-management
54
+ RuntimeDirectoryMode=0700
55
+ ExecStart=/usr/local/libexec/empeira-management-python #{AGENT} #{DEVICE} #{token} #{extra_devices.join(' ')}
56
+ StandardOutput=null
57
+ Restart=on-failure
58
+ RestartSec=2
59
+ [Install]
60
+ WantedBy=multi-user.target
61
+ UNIT
62
+ end
63
+
64
+ def self.resource(name)
65
+ Pathname(__dir__).join('../../../resources/nodes/management', name)
66
+ end
67
+
68
+ def self.extra_devices
69
+ (1...SLOTS).map { |slot| "/dev/virtio-ports/org.empeira.management.#{slot}" }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ class Prerequisites
6
+ def initialize(engine:, platform:)
7
+ @engine = engine
8
+ @platform = platform
9
+ end
10
+
11
+ def verify!(progress: Progress.new, seeds: true)
12
+ progress.stage(5, 'Checking VM host prerequisites...')
13
+ results = @engine.required_tools(seeds: seeds).to_h do |tool|
14
+ [tool, @engine.find(tool) ? 'available' : 'missing']
15
+ end
16
+ progress.stage(10, 'Checking hardware acceleration and firmware...')
17
+ accelerator = check(results, 'Accelerator') { @engine.accelerator }
18
+ check(results, 'ARM64 firmware') { @engine.firmware || 'not required' }
19
+ return accelerator if results.values.all? { |value| value == 'available' }
20
+
21
+ raise UnavailableFeature, failure_report(results)
22
+ end
23
+
24
+ private
25
+
26
+ def failure_report(results)
27
+ report = results.map { |name, status| " #{name.ljust(23)} #{status}" }.join("\n")
28
+ "VM prerequisites are not satisfied (#{@platform.os}/#{@platform.architecture}):\n\n" \
29
+ "#{report}\n\n#{@platform.vm_install_hint}"
30
+ end
31
+
32
+ def check(results, name)
33
+ value = yield
34
+ results[name] = 'available'
35
+ value
36
+ rescue Error => e
37
+ results[name] = e.message
38
+ nil
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Empeira
6
+ module VM
7
+ # Host capability detection belongs to the VM engine, not the public node provider.
8
+ class Qemu < Interface
9
+ EXECUTABLES = { amd64: 'qemu-system-x86_64', arm64: 'qemu-system-aarch64' }.freeze
10
+
11
+ def initialize(context:, runner:, executable_path: ENV.fetch('PATH', ''))
12
+ super(name: 'qemu', context: context, runner: runner)
13
+ @executable_path = executable_path
14
+ end
15
+
16
+ def executable
17
+ name = EXECUTABLES.fetch(context.platform.architecture)
18
+ find(name) || raise(UnavailableFeature, "#{name} is required for VM nodes; install QEMU")
19
+ end
20
+
21
+ def image_tool
22
+ find('qemu-img') || raise(UnavailableFeature, 'qemu-img is required for VM disks; install QEMU')
23
+ end
24
+
25
+ def accelerator
26
+ desired = desired_accelerator
27
+ Platform::Kvm.new(platform: context.platform).verify! if desired == 'kvm'
28
+ result = runner.run(executable, arguments: ['-accel', 'help'], timeout: 10)
29
+ return desired if result.success? && result.stdout.lines.any? { |line| line.strip == desired }
30
+
31
+ raise UnavailableFeature, "QEMU does not advertise #{desired.upcase} acceleration; no automatic TCG fallback"
32
+ end
33
+
34
+ def available?
35
+ executable && image_tool && accelerator
36
+ true
37
+ rescue UnavailableFeature
38
+ false
39
+ end
40
+
41
+ private
42
+
43
+ def desired_accelerator
44
+ case context.platform.os
45
+ when :linux, :wsl then 'kvm'
46
+ when :macos then 'hvf'
47
+ else raise UnsupportedPlatform, 'QEMU VM nodes require Linux or macOS'
48
+ end
49
+ end
50
+
51
+ public
52
+
53
+ def required_tools(seeds: true)
54
+ [EXECUTABLES.fetch(context.platform.architecture), 'qemu-img', 'ssh',
55
+ *(seeds ? %w[xorriso ssh-keygen] : [])]
56
+ end
57
+
58
+ def preflight!(progress: Progress.new, seeds: true)
59
+ Prerequisites.new(engine: self, platform: context.platform).verify!(progress: progress, seeds: seeds)
60
+ end
61
+
62
+ def firmware
63
+ return unless context.platform.architecture == :arm64
64
+
65
+ firmware_candidates.find { |path| path.file? && path.readable? } ||
66
+ raise(UnavailableFeature, 'AArch64 UEFI firmware is required with QEMU')
67
+ end
68
+
69
+ def firmware_candidates
70
+ root = Pathname(executable).realpath.dirname.parent.join('share')
71
+ [root.join('qemu/edk2-aarch64-code.fd'), root.join('qemu/QEMU_EFI.fd'),
72
+ root.join('qemu-efi-aarch64/QEMU_EFI.fd')]
73
+ end
74
+
75
+ def find(name)
76
+ @executable_path.split(File::PATH_SEPARATOR).filter_map do |directory|
77
+ path = Pathname(directory).join(name)
78
+ path.to_s if path.file? && path.executable?
79
+ end.first
80
+ end
81
+ end
82
+ end
83
+ end