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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # rubocop:disable-next Metrics/ModuleLength -- Provider inventories share one explicit ownership envelope.
6
+ module Inventory
7
+ def self.valid?(nodes)
8
+ return false unless nodes.is_a?(Hash)
9
+
10
+ nodes.all? { |name, record| valid_record?(name, record) }
11
+ end
12
+
13
+ def self.valid_record?(name, record)
14
+ return false unless record.is_a?(Hash) && record['hostname'] == name && valid_name?(name)
15
+ return false unless valid_guest_settings?(record)
16
+
17
+ case record['provider']
18
+ when 'container' then valid_container?(record)
19
+ when 'vm' then valid_vm?(name, record)
20
+ else false
21
+ end
22
+ end
23
+
24
+ def self.valid_guest_settings?(record)
25
+ CommandMocks.valid_inventory?(record.fetch('command_mocks', {})) &&
26
+ (!record.key?('runtime_proxy') || RuntimeProxy.valid_inventory?(record['runtime_proxy'])) &&
27
+ (!record.key?('interactive_tools') || InteractiveTools.valid_inventory?(record['interactive_tools'])) &&
28
+ valid_fact_revision?(record)
29
+ end
30
+
31
+ def self.valid_fact_revision?(record)
32
+ return true unless record.key?('empeira_fact')
33
+
34
+ record['empeira_fact'].is_a?(String) && record['empeira_fact'].match?(/\A[0-9a-f]{64}\z/)
35
+ end
36
+
37
+ def self.valid_container?(record)
38
+ Runtime.registry.names.include?(record['runtime']) &&
39
+ [true, false].include?(record['provisioned']) &&
40
+ valid_definition?(record) && valid_metadata?(record) && valid_ssh?(record)
41
+ end
42
+
43
+ def self.valid_ssh?(record)
44
+ return true unless record.key?('ssh_host') || record.key?('ssh_port')
45
+
46
+ record['ssh_host'] == '127.0.0.1' && %w[loopback tunnel].include?(record['ssh_transport']) &&
47
+ (record['ssh_port'].nil? || (record['ssh_port'].is_a?(Integer) && record['ssh_port'].between?(1, 65_535)))
48
+ end
49
+
50
+ def self.valid_vm?(name, record)
51
+ valid_vm_image?(record['base_image']) && valid_vm_process?(record) &&
52
+ valid_vm_network?(name, record) && valid_vm_metadata?(record) &&
53
+ valid_vm_management_layout?(record) &&
54
+ (!record.key?('network_interfaces') || ::Empeira::VM::Interfaces.valid_inventory?(record['network_interfaces']))
55
+ end
56
+
57
+ def self.valid_vm_management_layout?(record)
58
+ record['management_layout'] == ::Empeira::VM::Management::VERSION && valid_management_socket?(record)
59
+ end
60
+
61
+ def self.valid_management_socket?(record)
62
+ valid_socket_identity?(record['management_socket']) && valid_management_slots?(record)
63
+ end
64
+
65
+ # rubocop:disable-next Metrics/CyclomaticComplexity -- Match all slot identities and the original primary endpoint.
66
+ def self.valid_management_slots?(record)
67
+ return true unless record.key?('management_sockets')
68
+
69
+ slots = record['management_sockets']
70
+ slots.is_a?(Hash) && slots.keys.sort == (0...::Empeira::VM::Management::SLOTS).map(&:to_s) &&
71
+ slots.values.all? { |value| !value.nil? && valid_socket_identity?(value) } &&
72
+ slots['0'] == record['management_socket']
73
+ end
74
+
75
+ def self.valid_socket_identity?(socket)
76
+ socket.nil? || (socket.is_a?(Hash) && socket.keys.sort == %w[device inode] &&
77
+ socket.values.all? { |value| value.is_a?(Integer) && value >= 0 })
78
+ end
79
+
80
+ def self.valid_vm_image?(image)
81
+ return false unless image.is_a?(Hash)
82
+
83
+ required = %w[distribution version architecture source revision checksum]
84
+ required.all? { |key| image[key].is_a?(String) && !image[key].empty? } &&
85
+ image['source'].start_with?('https://') &&
86
+ image['checksum'].match?(/\A[0-9a-f]{64}\z/)
87
+ end
88
+
89
+ def self.valid_vm_process?(record)
90
+ record['engine'] == 'qemu' && %w[kvm hvf].include?(record['accelerator']) &&
91
+ %w[amd64 arm64].include?(record['architecture']) &&
92
+ valid_vm_resources?(record) && valid_vm_identity?(record)
93
+ end
94
+
95
+ def self.valid_vm_resources?(record)
96
+ %w[memory cpus].all? { |key| record[key].is_a?(Integer) && record[key].positive? }
97
+ end
98
+
99
+ def self.valid_vm_identity?(record)
100
+ (record['pid'].nil? || (record['pid'].is_a?(Integer) && record['pid'].positive?)) &&
101
+ %w[preparing running stopped].include?(record['state'])
102
+ end
103
+
104
+ def self.valid_vm_network?(name, record)
105
+ record['overlay'] == "vms/#{name}/disk.qcow2" &&
106
+ record['network'].is_a?(String) && record['network'].match?(/\A[a-f0-9]{24}:environment\z/) &&
107
+ record['mac_address'].is_a?(String) &&
108
+ record['mac_address'].match?(/\A52:54:(?:[0-9a-f]{2}:){3}[0-9a-f]{2}\z/)
109
+ end
110
+
111
+ def self.valid_vm_metadata?(record)
112
+ valid_certificate?(record['certificate_key']) && [true, false].include?(record['provisioned']) &&
113
+ %w[os version created_at].all? { |key| record[key].is_a?(String) && !record[key].empty? }
114
+ end
115
+
116
+ def self.valid_definition?(record)
117
+ definition = record['definition']
118
+ definition.is_a?(Hash) && definition['hostname'] == record['hostname'] &&
119
+ definition['image'] == record['image'] && valid_id?(record['id'])
120
+ end
121
+
122
+ def self.valid_metadata?(record)
123
+ %w[amd64 arm64].include?(record['architecture']) && valid_certificate?(record['certificate_key']) &&
124
+ %w[os version image created_at].all? do |key|
125
+ record[key].is_a?(String) && !record[key].empty?
126
+ end
127
+ end
128
+
129
+ def self.valid_certificate?(key)
130
+ key.nil? || (key.is_a?(String) && key.match?(/\A[a-f0-9]{64}\z/))
131
+ end
132
+
133
+ def self.valid_id?(id)
134
+ id.nil? || (id.is_a?(String) && id.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z/))
135
+ end
136
+
137
+ def self.valid_name?(name)
138
+ name.is_a?(String) && name.size <= 253 &&
139
+ name.split('.', -1).all? { |label| label.match?(/\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/) }
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ class PackageBootstrap
6
+ FAMILIES = {
7
+ 'ubuntu' => 'debian', 'debian' => 'debian',
8
+ 'rhel' => 'redhat', 'rocky' => 'redhat', 'almalinux' => 'redhat', 'oraclelinux' => 'redhat'
9
+ }.freeze
10
+ ACTIONS = %w[install remove].freeze
11
+ GROUPS = %w[default debian redhat].freeze
12
+
13
+ # rubocop:disable-next Metrics/CyclomaticComplexity -- Validate the two-level public package matrix in place.
14
+ def self.validate!(value, path = 'bootstrap.packages', effective: false)
15
+ unless valid_keys?(value, ACTIONS, effective)
16
+ raise ConfigurationError, "#{path} must contain only install and remove mappings"
17
+ end
18
+
19
+ value.each do |action, groups|
20
+ unless valid_keys?(groups, GROUPS, effective)
21
+ raise ConfigurationError, "#{path}.#{action} must contain only default, debian and redhat arrays"
22
+ end
23
+
24
+ groups.each do |family, packages|
25
+ next if packages.is_a?(Array) && packages.all? { |package| valid_package?(package) }
26
+
27
+ raise ConfigurationError, "#{path}.#{action}.#{family} must contain valid nonempty package names"
28
+ end
29
+ end
30
+ end
31
+
32
+ def self.valid_keys?(value, permitted, effective)
33
+ value.is_a?(Hash) && value.keys.all? { |key| permitted.include?(key) } &&
34
+ (!effective || value.keys.sort == permitted.sort)
35
+ end
36
+
37
+ def self.valid_package?(package)
38
+ package.is_a?(String) && package == package.strip && !package.empty? &&
39
+ !package.start_with?('-') && !package.match?(/[\x00-\x20\x7f]/)
40
+ end
41
+
42
+ def initialize(config:, os:, execute:, copy: nil, progress: nil, rpm_options: [])
43
+ @config = config
44
+ @os = os
45
+ @execute = execute
46
+ @copy = copy
47
+ @progress = progress || ->(_message) {}
48
+ @rpm_options = rpm_options
49
+ end
50
+
51
+ def required?
52
+ ACTIONS.any? { |action| packages(action).any? }
53
+ end
54
+
55
+ def run(proxy_url:)
56
+ return unless required?
57
+
58
+ @proxy_url = proxy_url
59
+ @package_proxy = PackageProxy.new(family: family, execute: @execute, copy: @copy)
60
+ @package_proxy.prepare(proxy_url)
61
+ execute('remove', packages('remove'))
62
+ execute('update', []) if packages('install').any?
63
+ execute('install', packages('install'))
64
+ ensure
65
+ @package_proxy&.cleanup
66
+ end
67
+
68
+ private
69
+
70
+ def family
71
+ FAMILIES.fetch(@os) { raise ConfigurationError, "Unsupported package-bootstrap distribution: #{@os}" }
72
+ end
73
+
74
+ def packages(action)
75
+ values = @config.fetch(action)
76
+ (values.fetch('default') + values.fetch(family)).uniq
77
+ end
78
+
79
+ def execute(action, packages)
80
+ return if action != 'update' && packages.empty?
81
+
82
+ @progress.call(progress_message(action, packages.size))
83
+ arguments = command(action, packages)
84
+ result = @execute.call(arguments)
85
+ return if result.success?
86
+
87
+ raise_failure(action, result)
88
+ end
89
+
90
+ def raise_failure(action, result)
91
+ manager = family == 'debian' ? 'apt-get' : 'dnf'
92
+ text = PackageProxy.redact("#{result.stderr}\n#{result.stdout}", @proxy_url)
93
+ detail = Execution::Diagnostics.native(result.with(stderr: text, stdout: ''),
94
+ operation: "Package bootstrap #{action}", tool: manager)
95
+ raise Error, "Package bootstrap #{action} failed with #{manager} " \
96
+ "(exit=#{result.exit_status}, timeout=#{result.timed_out}); " \
97
+ "node retained and Puppet was not run\n#{detail}", cause: nil
98
+ end
99
+
100
+ def progress_message(action, count)
101
+ return 'Refreshing package metadata...' if action == 'update'
102
+
103
+ verb = action == 'remove' ? 'Removing' : 'Installing'
104
+ "#{verb} #{count} package#{'s' unless count == 1}..."
105
+ end
106
+
107
+ def command(action, packages)
108
+ return apt_command(action, packages) if family == 'debian'
109
+
110
+ dnf_command(action, packages)
111
+ end
112
+
113
+ def apt_command(action, packages)
114
+ if action == 'update'
115
+ return ['apt-get', *@package_proxy.arguments, '-o', 'APT::Update::Error-Mode=any',
116
+ 'update']
117
+ end
118
+
119
+ ['apt-get', *@package_proxy.arguments, action, '--yes', '--', *packages]
120
+ end
121
+
122
+ def dnf_command(action, packages)
123
+ return ['dnf', *@package_proxy.arguments, *@rpm_options, 'makecache'] if action == 'update'
124
+
125
+ ['dnf', *@package_proxy.arguments, *@rpm_options, action, '--assumeyes', '--', *packages]
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'uri'
5
+
6
+ module Empeira
7
+ module Node
8
+ # One private guest configuration for APT/DNF proxy credentials; never put them in argv.
9
+ class PackageProxy
10
+ APT_PATH = '/etc/apt/apt.conf.d/00-empeira-agent-proxy'
11
+ DNF_PATH = '/var/tmp/empeira-agent-dnf.conf'
12
+
13
+ def initialize(family:, execute:, copy:)
14
+ @family = family
15
+ @execute = execute
16
+ @copy = copy
17
+ end
18
+
19
+ def self.copy_text(copy, content, destination, mode: '0600')
20
+ Tempfile.create('empeira-agent-') do |file|
21
+ file.chmod(0o600)
22
+ file.write(content)
23
+ file.flush
24
+ copy.call(file.path, destination, mode)
25
+ end
26
+ end
27
+
28
+ def self.redact(diagnostic, proxy_url)
29
+ values = URI(proxy_url).userinfo.to_s.split(':') if proxy_url
30
+ values = [*values, ENV.fetch('EMPEIRA_AGENT_REPO_USERNAME', nil), ENV.fetch('EMPEIRA_AGENT_REPO_PASSWORD', nil)]
31
+ values.compact.reject(&:empty?).sort_by { |value| -value.length }
32
+ .reduce(diagnostic) { |text, value| text.gsub(value, '[REDACTED]') }
33
+ end
34
+
35
+ def prepare(url)
36
+ content = if @family == 'debian'
37
+ "Acquire::http::Proxy #{url.dump};\nAcquire::https::Proxy #{url.dump};\n"
38
+ else
39
+ dnf_configuration(url)
40
+ end
41
+ self.class.copy_text(@copy, content, path)
42
+ end
43
+
44
+ def arguments
45
+ @family == 'debian' ? ['-c', path] : ["--config=#{path}"]
46
+ end
47
+
48
+ def path
49
+ @family == 'debian' ? APT_PATH : DNF_PATH
50
+ end
51
+
52
+ def cleanup
53
+ result = @execute.call(['rm', '-f', '--', path])
54
+ return if result.success?
55
+
56
+ diagnostic = Execution::Diagnostics.command(result, operation: 'Temporary package proxy cleanup', tool: 'rm')
57
+ raise Error, "Cannot remove temporary package proxy configuration; Puppet was not run\n#{diagnostic}",
58
+ cause: nil
59
+ end
60
+
61
+ private
62
+
63
+ # rubocop:disable-next Metrics/AbcSize -- Preserve native sections while enforcing private, verified bootstrap settings.
64
+ def dnf_configuration(url)
65
+ lines = original_dnf_lines
66
+ main = lines.index { |line| line.strip == '[main]' }
67
+ raise Error, 'Original DNF configuration has no main section; Puppet was not run' unless main
68
+
69
+ finish = ((main + 1)...lines.size).find { |index| lines[index].lstrip.start_with?('[') } || lines.size
70
+ settings = lines[(main + 1)...finish].grep_v(/\A\s*(?:proxy(?:_[a-z_]+)?|gpgcheck|sslverify|logfilelevel)\s*=/i)
71
+ lines[main] = "[main]\n"
72
+ lines[(main + 1)...finish] = [*dnf_proxy_settings(url), *settings]
73
+ lines.join
74
+ end
75
+
76
+ def dnf_proxy_settings(url)
77
+ endpoint = URI(url)
78
+ username = endpoint.user
79
+ password = endpoint.password
80
+ endpoint.user = nil
81
+ endpoint.password = nil
82
+ settings = ["proxy=#{endpoint}\n", "gpgcheck=1\n", "sslverify=1\n", "logfilelevel=0\n"]
83
+ if username || password
84
+ settings.push("proxy_username=#{URI::DEFAULT_PARSER.unescape(username.to_s)}\n",
85
+ "proxy_password=#{URI::DEFAULT_PARSER.unescape(password.to_s)}\n",
86
+ "proxy_auth_method=basic\n")
87
+ end
88
+ settings
89
+ end
90
+
91
+ def original_dnf_lines
92
+ result = @execute.call(['cat', '/etc/dnf/dnf.conf'])
93
+ unless result.success?
94
+ diagnostic = Execution::Diagnostics.command(result, operation: 'Original DNF configuration', tool: 'cat')
95
+ raise Error, "Cannot read original DNF configuration; Puppet was not run\n#{diagnostic}", cause: nil
96
+ end
97
+
98
+ result.stdout.lines
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ module PackageSources
6
+ def self.verify!(execute:)
7
+ script = Pathname(__dir__).join('../../../resources/nodes/apt_sources.rb').read
8
+ result = execute.call([Certificates::RUBY, '-e', script])
9
+ return if result.success?
10
+
11
+ details = Execution::Diagnostics.command(result, operation: 'Active distribution APT source verification',
12
+ tool: 'ruby')
13
+ raise Error, 'Cannot verify an active Ubuntu/Debian distribution APT source before the first catalog. ' \
14
+ 'Check /etc/apt/sources.list and sources.list.d/*.list or *.sources in the image/bootstrap; ' \
15
+ "Puppet was not run. Node retained for diagnosis.\n#{details}", cause: nil
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ module PuppetCommand
6
+ def self.arguments
7
+ [Certificates::PUPPET, 'agent', '--test', '--detailed-exitcodes', '--waitforcert', '0',
8
+ '--color', Execution::Terminal.color? ? 'ansi' : 'false']
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Empeira
6
+ module Node
7
+ # Restore native DNF repository configuration after the temporary agent source.
8
+ class RpmConfiguration
9
+ DISTRIBUTIONS = %w[rocky almalinux oraclelinux rhel].freeze
10
+ BACKUP = '/root/.empeira-dnf-before-bootstrap.tar'
11
+
12
+ def initialize(os:, execute:, root: '/', backup: BACKUP)
13
+ @os = os
14
+ @execute = execute
15
+ @root = Pathname(root).cleanpath
16
+ @backup = Pathname(backup).cleanpath
17
+ end
18
+
19
+ def preserve
20
+ return yield unless DISTRIBUTIONS.include?(@os)
21
+
22
+ capture!
23
+ begin
24
+ yield
25
+ ensure
26
+ restore!
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def capture!
33
+ execute!(['install', '--mode', '0600', '/dev/null', @backup.to_s], 'Cannot prepare DNF configuration backup')
34
+ execute!(['tar', '--create', '--file', @backup.to_s, '--directory', @root.to_s,
35
+ 'etc/yum.repos.d', 'etc/dnf'],
36
+ 'Cannot preserve base image DNF configuration')
37
+ rescue StandardError
38
+ @execute.call(['rm', '--force', '--', @backup.to_s])
39
+ raise
40
+ end
41
+
42
+ def restore!
43
+ message = 'Cannot restore and verify base image DNF configuration; ' \
44
+ 'Puppet was not run. Node retained for diagnosis'
45
+ execute!(['tar', '--list', '--file', @backup.to_s], message)
46
+ execute!(['rm', '--recursive', '--force', '--', @root.join('etc/yum.repos.d').to_s,
47
+ @root.join('etc/dnf').to_s], message)
48
+ execute!(['tar', '--extract', '--file', @backup.to_s, '--directory', @root.to_s], message)
49
+ execute!(['tar', '--compare', '--file', @backup.to_s, '--directory', @root.to_s], message)
50
+ execute!(['rm', '--force', '--', @backup.to_s], message)
51
+ end
52
+
53
+ def execute!(arguments, message)
54
+ result = @execute.call(arguments)
55
+ return if result.success?
56
+
57
+ details = Execution::Diagnostics.command(result, operation: message, tool: arguments.first)
58
+ raise Error, "#{message}\n#{details}", cause: nil
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ RunRequest = Data.define(:hostname, :provider, :os, :version, :memory, :cpus) do
6
+ def self.from_config(hostname:, provider:, config:)
7
+ defaults = config.fetch('node_defaults').slice('os', 'version', 'memory', 'cpus')
8
+ new(hostname: hostname, provider: provider, **defaults.transform_keys(&:to_sym))
9
+ end
10
+
11
+ def initialize(**attributes)
12
+ validate_hostname!(attributes.fetch(:hostname))
13
+ unless Node.registry.names.include?(attributes.fetch(:provider))
14
+ raise Error, "provider must be one of: #{Node.registry.names.join(', ')}"
15
+ end
16
+
17
+ validate_resources!(attributes)
18
+ validate_os!(attributes)
19
+ super(**attributes.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value })
20
+ end
21
+
22
+ private
23
+
24
+ def validate_resources!(attributes)
25
+ %i[memory cpus].each do |key|
26
+ value = attributes.fetch(key)
27
+ next if value.nil? || (value.is_a?(Integer) && value.positive?)
28
+
29
+ raise Error, "#{key} must be a positive integer or null"
30
+ end
31
+ end
32
+
33
+ def validate_os!(attributes)
34
+ %i[os version].each do |key|
35
+ value = attributes.fetch(key)
36
+ next if value.nil? || (value.is_a?(String) && !value.strip.empty? && value == value.strip)
37
+
38
+ raise Error, "#{key} must be a non-empty string without surrounding whitespace or null"
39
+ end
40
+ end
41
+
42
+ def validate_hostname!(hostname)
43
+ label = /\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\z/
44
+ return if hostname.is_a?(String) && !hostname.empty? && hostname.length <= 64 &&
45
+ hostname.split('.', -1).all? { |part| part.match?(label) }
46
+
47
+ raise Error, 'hostname must contain DNS labels of 1 to 63 letters, digits or internal hyphens ' \
48
+ 'and fit the 64-byte guest kernel hostname limit'
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # Normal, credential-free guest policy, after verified bootstrap cleanup.
6
+ class RuntimeProxy
7
+ VARIABLES = %w[HTTP_PROXY HTTPS_PROXY http_proxy https_proxy NO_PROXY no_proxy ALL_PROXY all_proxy].freeze
8
+
9
+ def initialize(context:, record:, execute:, persist:)
10
+ @context = context
11
+ @record = record
12
+ @execute = execute
13
+ @persist = persist
14
+ end
15
+
16
+ def self.command(context, arguments)
17
+ environment = ControlPlane::Plan.new(context: context).node_proxy_environment
18
+ ['env', *VARIABLES.map { |key| "#{key}=#{environment.fetch(key, '')}" }, *arguments]
19
+ end
20
+
21
+ def self.valid_inventory?(entry)
22
+ entry.is_a?(Hash) && (entry.keys - %w[current previous]).empty? && entry.key?('current') &&
23
+ [entry['current'], entry['previous']].compact.all? { |definition| valid_definition?(definition) }
24
+ end
25
+
26
+ def self.valid_definition?(definition)
27
+ definition.is_a?(Hash) && definition.keys.sort == %w[direct no_proxy url] &&
28
+ definition['url'] == 'http://proxy.empeira.internal:3128' &&
29
+ definition['no_proxy'].is_a?(String) && definition['no_proxy'].match?(/\A[a-zA-Z0-9.,:-]+\z/) &&
30
+ valid_direct?(definition['direct'])
31
+ end
32
+
33
+ def self.valid_direct?(hosts)
34
+ hosts.is_a?(Array) && hosts.all? do |host|
35
+ host.is_a?(String) && host.match?(/\A[a-zA-Z0-9][a-zA-Z0-9.-]*\z/)
36
+ end
37
+ end
38
+
39
+ def reconcile
40
+ desired = definition
41
+ entry = @record['runtime_proxy']
42
+ return false unless desired || entry
43
+
44
+ recovered = recover(entry) if entry&.key?('previous')
45
+ previous = [entry&.fetch('current')].compact
46
+ @record['runtime_proxy'] = { 'current' => desired, 'previous' => previous.first }
47
+ @persist.call
48
+ changed = execute(desired, previous)
49
+ @record['runtime_proxy'] = { 'current' => desired }
50
+ @persist.call
51
+ changed || recovered || false
52
+ end
53
+
54
+ private
55
+
56
+ def recover(entry)
57
+ execute(entry.fetch('current'), [entry['current'], entry['previous']].compact)
58
+ end
59
+
60
+ def definition
61
+ environment = ControlPlane::Plan.new(context: @context).node_proxy_environment
62
+ return if environment.empty?
63
+
64
+ naming = Network::Naming.new
65
+ internal = naming.reserved(@context.configuration).flat_map { |name| naming.aliases(name) }
66
+ { 'url' => environment.fetch('http_proxy'), 'no_proxy' => environment.fetch('no_proxy'),
67
+ 'direct' => ['localhost', '127.0.0.1', *internal,
68
+ *Network::DirectEgress.destinations(@context.configuration.dig('network', 'egress'))].uniq }
69
+ end
70
+
71
+ def execute(desired, previous)
72
+ helper = Pathname(__dir__).join('../../../resources/nodes/runtime_proxy.rb').read
73
+ request = JSON.generate('desired' => desired, 'accepted' => [desired, *previous].compact)
74
+ result = @execute.call([Certificates::RUBY, '-e', helper, request])
75
+ unless result.success?
76
+ details = Execution::Diagnostics.command(result, operation: 'Normal guest proxy reconciliation', tool: 'ruby')
77
+ raise Error, "Cannot reconcile normal guest proxy; node retained for diagnosis\n#{details}", cause: nil
78
+ end
79
+
80
+ JSON.parse(result.stdout).fetch('changed')
81
+ rescue JSON::ParserError, KeyError
82
+ raise Error, 'Cannot verify normal guest proxy reconciliation; node retained for diagnosis', cause: nil
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ class Service
6
+ # Preserve explicit injection at the existing provider composition boundary.
7
+ # rubocop:disable-next Metrics/ParameterLists
8
+ def initialize(context:, runner:, providers: Node.registry, runtimes: Runtime.registry, engines: ::Empeira::VM.registry,
9
+ build_info: BuildInfo.load, progress: Progress.new, ssh_preferences: {})
10
+ @context = context
11
+ @runner = runner
12
+ @providers = providers
13
+ @ssh_preferences = Configuration::SSHPreferences.new(ssh_preferences)
14
+ @dependencies = { context: context, runner: runner, runtimes: runtimes, engines: engines,
15
+ build_info: build_info, progress: progress }
16
+ end
17
+
18
+ def run(request)
19
+ provider(request.provider).run(request)
20
+ end
21
+
22
+ %i[start stop inspect_resource shell console exec puppet logs].each do |operation|
23
+ define_method(operation) do |name:, **options|
24
+ record = Infrastructure::Store.new(context: @context).load&.dig('nodes', name.downcase)
25
+ selected = record&.fetch('provider') || 'container'
26
+ provider(selected).public_send(operation, name: name, **options)
27
+ end
28
+ end
29
+
30
+ def destroy(name:)
31
+ Infrastructure::Cleanup.new(context: @context, runner: @runner,
32
+ runtimes: @dependencies.fetch(:runtimes),
33
+ progress: @dependencies.fetch(:progress)).node(name)
34
+ end
35
+
36
+ def ssh(name:, user: nil, identity: nil, port: nil)
37
+ record = Infrastructure::Store.new(context: @context).load&.dig('nodes', name.downcase)
38
+ options = @ssh_preferences.resolve(hostname: name, user: user, identity: identity, port: port)
39
+ provider(record&.fetch('provider') || 'container').ssh(name: name, **options)
40
+ end
41
+
42
+ def list
43
+ provider('container').list + provider('vm').list
44
+ end
45
+
46
+ def names
47
+ Infrastructure::Store.new(context: @context).load&.fetch('nodes', {})&.keys || []
48
+ end
49
+
50
+ # The infrastructure caller already holds the workspace mutation lock.
51
+ # rubocop:disable-next Naming/PredicateMethod -- Reconciliation mutates guest resources.
52
+ def reconcile(state:)
53
+ return false unless state
54
+
55
+ records = state.fetch('nodes', {}).values
56
+ reconciliation_providers(records).map do |name|
57
+ provider(name).reconcile_all(state: state)
58
+ end.any?
59
+ end
60
+
61
+ private
62
+
63
+ def reconciliation_providers(records)
64
+ records.map { |record| record.fetch('provider') }.uniq
65
+ end
66
+
67
+ def provider(name)
68
+ @providers.build(name, **@dependencies)
69
+ end
70
+ end
71
+ end
72
+ end