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,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Empeira
6
+ module Node
7
+ class SSHClient
8
+ def initialize(credentials:, proxy_command: nil)
9
+ @credentials = credentials
10
+ @proxy_command = proxy_command
11
+ end
12
+
13
+ def proxy_options
14
+ return [] unless @proxy_command
15
+
16
+ ['-o', "ProxyCommand=#{Shellwords.join(@proxy_command).gsub('%', '%%')}"]
17
+ end
18
+
19
+ def known_hosts_option
20
+ path = @credentials.known_hosts.to_s.gsub(/["\\]/) { |character| "\\#{character}" }.gsub('%', '%%')
21
+ "UserKnownHostsFile=\"#{path}\""
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Empeira
6
+ module Node
7
+ # Per-node credentials and known hosts stay in private workspace storage.
8
+ class SSHCredentials
9
+ def initialize(context:, runner:, provider:, hostname:, purpose: nil)
10
+ @runner = runner
11
+ raise ArgumentError, 'Unknown SSH credential scope' unless [nil, :system].include?(purpose)
12
+
13
+ @purpose = purpose
14
+ parent = provider == 'vm' ? 'vms' : 'containers'
15
+ @directory = context.locations.workspace(context.workspace).join(parent, hostname)
16
+ end
17
+
18
+ attr_reader :directory
19
+
20
+ def key_path
21
+ directory.join('id_ed25519')
22
+ end
23
+
24
+ def public_path
25
+ Pathname("#{key_path}.pub")
26
+ end
27
+
28
+ def known_hosts
29
+ directory.join(@purpose ? "#{@purpose}_known_hosts" : 'known_hosts')
30
+ end
31
+
32
+ def prepare
33
+ prepare_hosts
34
+ generate unless [key_path, public_path].any? { |path| path.exist? || path.symlink? }
35
+ verify!
36
+ public_path
37
+ end
38
+
39
+ def prepare_hosts
40
+ raise Error, 'Managed SSH directory must not be a symlink' if directory.symlink?
41
+
42
+ FileUtils.mkdir_p(directory, mode: 0o700)
43
+ File.chmod(0o700, directory)
44
+ raise Error, 'Managed SSH known_hosts is unsafe' if known_hosts.symlink? ||
45
+ (known_hosts.exist? && !known_hosts.file?)
46
+ end
47
+
48
+ def verify!
49
+ raise Error, 'Managed SSH directory must not be a symlink' if directory.symlink?
50
+
51
+ raise Error, 'Managed SSH key material is missing or unsafe; destroy and recreate the node' unless safe_keys?
52
+ raise Error, 'Managed SSH known_hosts must not be a symlink' if known_hosts.symlink?
53
+
54
+ File.chmod(0o600, key_path)
55
+ File.chmod(0o600, known_hosts) if known_hosts.exist?
56
+ end
57
+
58
+ def cleanup
59
+ raise Error, 'Managed SSH directory must not be a symlink' if directory.symlink?
60
+
61
+ FileUtils.remove_entry_secure(directory) if directory.exist?
62
+ end
63
+
64
+ private
65
+
66
+ def safe_keys?
67
+ [key_path, public_path].all? { |path| path.file? && !path.symlink? && path.readable? }
68
+ end
69
+
70
+ def generate
71
+ result = @runner.run('ssh-keygen', arguments: ['-q', '-t', 'ed25519', '-C', 'empeira-managed',
72
+ '-N', '', '-f', key_path.to_s],
73
+ timeout: 15)
74
+ raise Error, 'Cannot create managed SSH key; install openssh-client on the CLI host' unless result.success?
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # The sole allowed container-node publication is managed SSH on host loopback.
6
+ class SSHEndpoint
7
+ def self.valid?(resource, record)
8
+ return resource.fetch('ports').empty? unless record['ssh_host']
9
+ return false unless record['ssh_host'] == '127.0.0.1'
10
+ return tunnel?(resource, record) if record['ssh_transport'] == 'tunnel'
11
+
12
+ loopback?(resource, record)
13
+ end
14
+
15
+ def self.tunnel?(resource, record)
16
+ resource.fetch('ports').empty? && resource.fetch('published_ports', {}).values.all?(&:nil?) &&
17
+ record.dig('definition', 'ports') == [] && record['ssh_port'] == 22
18
+ end
19
+
20
+ # rubocop:disable-next Metrics/AbcSize -- Compare configured and observed ports against the recorded endpoint.
21
+ def self.loopback?(resource, record)
22
+ return false unless managed?(record) && resource.fetch('ports').keys == ['22/tcp']
23
+
24
+ bindings = resource.fetch('ports')['22/tcp']
25
+ return false unless binding?(bindings, record['ssh_port'], pending: record['ssh_port'].nil?)
26
+ return true unless resource['state'] == 'running'
27
+
28
+ publications = resource.fetch('published_ports', {})
29
+ publications.keys == ['22/tcp'] && binding?(publications['22/tcp'], record['ssh_port'])
30
+ end
31
+
32
+ def self.managed?(record)
33
+ record['ssh_host'] == '127.0.0.1' && record.dig('definition', 'ports') == ['127.0.0.1::22/tcp']
34
+ end
35
+
36
+ def self.binding?(bindings, port, pending: false)
37
+ return false unless loopback_binding?(bindings)
38
+
39
+ value = bindings[0]['HostPort']
40
+ return true if pending && value == ''
41
+
42
+ valid_port?(value) && (port.nil? || value.to_i == port)
43
+ end
44
+
45
+ def self.loopback_binding?(bindings)
46
+ bindings.is_a?(Array) && bindings.size == 1 && bindings[0]['HostIp'] == '127.0.0.1'
47
+ end
48
+
49
+ def self.valid_port?(value)
50
+ value.is_a?(String) && value.match?(/\A[0-9]+\z/) && value.to_i.between?(1, 65_535)
51
+ end
52
+
53
+ def self.port(resource)
54
+ Integer(resource.fetch('published_ports').fetch('22/tcp').first.fetch('HostPort'), 10)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Empeira
6
+ module Node
7
+ # Validate external private-key paths without reading or copying their contents.
8
+ class SSHIdentity
9
+ def initialize(home:)
10
+ @home = Pathname(home)
11
+ end
12
+
13
+ def resolve(value)
14
+ path = path_for(value)
15
+ stat = path.lstat
16
+ raise ConfigurationError, 'SSH identity must be a readable regular file, without a symlink' unless
17
+ stat.file? && path.readable?
18
+ raise ConfigurationError, 'SSH identity permissions must exclude group and other access (use chmod 600)' unless
19
+ stat.mode.nobits?(0o077)
20
+
21
+ path.to_s
22
+ rescue SystemCallError
23
+ raise ConfigurationError, 'SSH identity is missing or unreadable', cause: nil
24
+ end
25
+
26
+ private
27
+
28
+ def path_for(value)
29
+ unless value.is_a?(String) && !value.empty? && !value.match?(/[\p{Cc}\p{Cf}]/)
30
+ raise ConfigurationError, 'SSH identity must be a nonempty file path without control characters'
31
+ end
32
+
33
+ value.start_with?('~/') ? @home.join(value.delete_prefix('~/')).expand_path : Pathname(value).expand_path
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'etc'
4
+
5
+ module Empeira
6
+ module Node
7
+ # Provider defaults select authentication; endpoint and host-key policy remain managed.
8
+ class UserSSH
9
+ def initialize(runner:, credentials:, proxy_command: nil, default_user: nil, managed_identity: false,
10
+ home: Dir.home)
11
+ @runner = runner
12
+ @credentials = credentials
13
+ @proxy_command = proxy_command
14
+ @default_user = default_user
15
+ @managed_identity = managed_identity
16
+ @identity = SSHIdentity.new(home: home)
17
+ end
18
+
19
+ def session(record, user: nil, identity: nil)
20
+ @credentials.prepare_hosts
21
+ username = username_for(user)
22
+ transport = SSHClient.new(credentials: @credentials, proxy_command: @proxy_command)
23
+ arguments = ['-tt', '-l', username, *options(record, transport), *authentication_options(identity)]
24
+ @runner.stream('ssh', arguments: [*arguments, record.fetch('hostname')])
25
+ end
26
+
27
+ def username_for(user)
28
+ username = user || @default_user || Etc.getpwuid(Process.uid).name
29
+ unless username.is_a?(String) && username.match?(/\A[a-zA-Z0-9_.@-]+\z/)
30
+ raise ConfigurationError, 'SSH user must be a valid login name'
31
+ end
32
+
33
+ username
34
+ end
35
+
36
+ def authentication_options(identity)
37
+ return identity ? ['-i', @identity.resolve(identity)] : [] unless @managed_identity
38
+
39
+ @credentials.verify! unless identity
40
+ key = identity ? @identity.resolve(identity) : @credentials.key_path.to_s
41
+ ['-i', key, '-o', 'IdentitiesOnly=yes', '-o', 'PasswordAuthentication=no',
42
+ '-o', 'KbdInteractiveAuthentication=no', '-o', 'ForwardAgent=no']
43
+ end
44
+
45
+ def options(record, transport)
46
+ port = record['ssh_port']
47
+ unless record.fetch('ssh_host', '127.0.0.1') == '127.0.0.1' && port.is_a?(Integer) && port.between?(1, 65_535)
48
+ raise Error, 'Managed SSH endpoint is missing or inconsistent'
49
+ end
50
+
51
+ proxy = @proxy_command ? transport.proxy_options : ['-o', 'ProxyCommand=none']
52
+ ['-p', port.to_s, '-o', 'HostName=127.0.0.1', '-o', "HostKeyAlias=#{record.fetch('hostname')}",
53
+ '-o', transport.known_hosts_option, '-o', "GlobalKnownHostsFile=#{File::NULL}",
54
+ '-o', 'StrictHostKeyChecking=accept-new', '-o', 'CanonicalizeHostname=no',
55
+ '-o', 'ControlMaster=no', '-o', 'ControlPath=none', '-o', 'ClearAllForwardings=yes',
56
+ *proxy, '-o', 'ProxyJump=none']
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # Public logical node provider; QEMU and container networking stay replaceable.
6
+ class VM < Interface
7
+ include VMState
8
+ include VMPreflight
9
+ include VMPreparation
10
+ include VMLifecycle
11
+
12
+ def initialize(context:, runner:, backend:, runtime:, build_info: BuildInfo.load, progress: Progress.new)
13
+ super(name: 'vm', context: context, runner: runner, backend: backend)
14
+ @engine = backend
15
+ @runtime = runtime
16
+ @build_info = build_info
17
+ @progress = progress
18
+ @store = Infrastructure::Store.new(context: context)
19
+ prepare_components(context, runner, backend, runtime)
20
+ end
21
+
22
+ # rubocop:disable-next Naming/PredicateMethod -- Reconciliation mutates guest resources.
23
+ def reconcile_all(state:)
24
+ load_state(state)
25
+ vm_records.values.map do |record|
26
+ record['provisioned'] && @qemu.running?(record) ? reconcile_guest(record) : false
27
+ end.any?
28
+ end
29
+
30
+ private
31
+
32
+ def validate_interfaces(record)
33
+ ::Empeira::VM::Interfaces.validate_static!(context, record.fetch('hostname'), @state)
34
+ end
35
+
36
+ def reconcile_interfaces(record)
37
+ ::Empeira::VM::Interfaces.new(context: context, record: record, state: @state, guest: @guest,
38
+ persist: method(:save)).reconcile
39
+ end
40
+
41
+ def reconcile_guest(record)
42
+ fact_changed = reconcile_provider_fact(record)
43
+ proxy_changed = reconcile_runtime_proxy(record)
44
+ tools_changed = reconcile_interactive_tools(record)
45
+ interfaces_changed = reconcile_interfaces(record)
46
+ reconcile_command_mocks(record) || interfaces_changed || proxy_changed || tools_changed || fact_changed
47
+ end
48
+
49
+ # rubocop:disable-next Naming/PredicateMethod -- Reconciliation mutates owned guest state and reports changes.
50
+ def reconcile_provider_fact(record)
51
+ digest = ExternalFact.fingerprint('vm')
52
+ return false if record['empeira_fact'] == digest
53
+
54
+ VMBootstrap.new(guest: @guest).reconcile(record: record, bootstrap: Bootstrap.new(provider: 'vm'))
55
+ record['empeira_fact'] = digest
56
+ save
57
+ true
58
+ end
59
+
60
+ def reconcile_interactive_tools(record)
61
+ InteractiveTools.new(record: record, persist: method(:save),
62
+ execute: ->(arguments) { @guest.run(record, arguments) }).reconcile
63
+ end
64
+
65
+ def reconcile_runtime_proxy(record)
66
+ RuntimeProxy.new(context: context, record: record, persist: method(:save),
67
+ execute: ->(arguments) { @guest.run(record, arguments) }).reconcile
68
+ end
69
+
70
+ def reconcile_command_mocks(record)
71
+ CommandMocks.new(context: context, record: record, persist: method(:save),
72
+ execute: ->(arguments) { @guest.run(record, arguments) }).reconcile
73
+ end
74
+
75
+ def prepare_components(context, runner, backend, runtime)
76
+ @peer = Network::Peer::Backend.build(context: context, runtime: runtime, runner: runner, store: @store)
77
+ @bootstrap_proxy = Network::BootstrapProxy.new(context: context, runtime: runtime, store: @store)
78
+ @source = ::Empeira::VM::ImageSource.new
79
+ @cache = ::Empeira::VM::ImageCache.new(locations: context.locations)
80
+ @disk = ::Empeira::VM::Disk.new(engine: backend, runner: runner,
81
+ workspace_directory: context.locations.workspace(context.workspace))
82
+ @qemu = ::Empeira::VM::QemuRuntime.new(engine: backend, runner: runner, context: context)
83
+ @cloud = ::Empeira::VM::CloudInit.new(context: context, runner: runner)
84
+ @guest = ::Empeira::VM::Guest.new(context: context, runner: runner, qemu: @qemu)
85
+ @agent = ::Empeira::VM::Agent.new(context: context, guest: @guest, runtime: runtime, progress: @progress)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # Cloud-Init installs shared files; management transport verifies them before Puppet.
6
+ class VMBootstrap
7
+ def initialize(guest:)
8
+ @guest = guest
9
+ end
10
+
11
+ def verify(record:, bootstrap:)
12
+ bootstrap.files.each { |file| verify_file!(record, file) }
13
+ end
14
+
15
+ # rubocop:disable-next Naming/PredicateMethod, Metrics/AbcSize -- Verify, atomically upload and verify the owned fact.
16
+ def reconcile(record:, bootstrap:)
17
+ bootstrap.files.map do |file|
18
+ content = @guest.run(record, ['cat', file.path])
19
+ mode = @guest.run(record, ['stat', '-c', '%a', file.path])
20
+ next false if valid_file?(content, mode, file)
21
+
22
+ Tempfile.create('empeira-fact') do |source|
23
+ source.write(file.content)
24
+ source.flush
25
+ @guest.copy_to(record, source.path, file.path, mode: format('%04o', file.mode))
26
+ end
27
+ verify_file!(record, file)
28
+ true
29
+ end.any?
30
+ end
31
+
32
+ private
33
+
34
+ def verify_file!(record, file)
35
+ content = @guest.run(record, ['cat', file.path])
36
+ mode = @guest.run(record, ['stat', '-c', '%a', file.path])
37
+ return if valid_file?(content, mode, file)
38
+
39
+ details = [file_diagnostic(content, file, 'content', 'cat'), file_diagnostic(mode, file, 'mode', 'stat')]
40
+ raise Error, "Cannot verify required Empeira VM bootstrap file #{file.path}; Puppet was not run\n" \
41
+ "#{details.join("\n")}", cause: nil
42
+ end
43
+
44
+ def file_diagnostic(result, file, field, tool)
45
+ Execution::Diagnostics.command(result, operation: "Bootstrap file #{file.path}: #{field}", tool: tool)
46
+ end
47
+
48
+ def valid_file?(content, mode, file)
49
+ content.success? && content.stdout == file.content && mode.success? && mode.stdout.strip.to_i(8) == file.mode
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Empeira
6
+ module Node
7
+ # rubocop:disable-next Metrics/ModuleLength -- Node lifecycle and cleanup share the workspace lock.
8
+ module VMLifecycle
9
+ def start(name:)
10
+ mutate do
11
+ record = fetch_vm(name)
12
+ unless record['provisioned']
13
+ raise Error, 'VM bootstrap is incomplete; inspect logs, then destroy and recreate the node'
14
+ end
15
+
16
+ validate_interfaces(record)
17
+ ready_server
18
+ return lifecycle_result(name.downcase, :running, changed: reconcile_guest(record)) if @qemu.running?(record)
19
+
20
+ @progress.stage(1, 'Checking VM control-plane readiness...')
21
+ @engine.preflight!(progress: @progress, seeds: false)
22
+ boot_existing(record)
23
+ reconcile_guest(record)
24
+ lifecycle_result(record.fetch('hostname'), :running, changed: true)
25
+ end
26
+ end
27
+
28
+ def stop(name:)
29
+ mutate do
30
+ record = fetch_vm(name)
31
+ session_lock(record).exclusive do
32
+ changed = !!@qemu.stop(record)
33
+ @peer.stop(record)
34
+ record['state'] = 'stopped'
35
+ record['pid'] = nil
36
+ save
37
+ lifecycle_result(record.fetch('hostname'), :stopped, changed: changed)
38
+ end
39
+ end
40
+ end
41
+
42
+ def destroy(name:)
43
+ Infrastructure::Cleanup.new(context: context, runner: @runner, runtime: @runtime,
44
+ progress: @progress).node(name)
45
+ end
46
+
47
+ def destroy_all(state:)
48
+ load_state(state)
49
+ @runtime.check_available!
50
+ @bootstrap_proxy.cleanup(@state)
51
+ vm_records.each_key { |name| remove_vm(name, certificates: false) }
52
+ end
53
+
54
+ def puppet(name:)
55
+ mutate(availability: false) do
56
+ record = running_vm(name)
57
+ @progress.stage(30, 'Running Puppet in VM...')
58
+ puppet_run(record)
59
+ end
60
+ end
61
+
62
+ def shell(name:)
63
+ interactive_vm(name) { |record| @guest.shell(record) }
64
+ end
65
+
66
+ def console(name:)
67
+ interactive_vm(name) { |record| @qemu.console(record) }
68
+ end
69
+
70
+ def exec(name:, arguments:, timeout: nil, output: $stdout, error: $stderr)
71
+ DiagnosticCommand.validate!(arguments, timeout)
72
+ interactive_vm(name) do |record|
73
+ @guest.stream(record, arguments, timeout: timeout, output: output, error: error)
74
+ end
75
+ end
76
+
77
+ def ssh(name:, user: nil, identity: nil, port: nil)
78
+ interactive_vm(name) do |record|
79
+ ::Empeira::VM::SystemSSH.new(context: context, runner: @runner, runtime: @runtime, qemu: @qemu)
80
+ .session(record, user: user, identity: identity, port: port)
81
+ end
82
+ end
83
+
84
+ def logs(name:)
85
+ load_state
86
+ record = fetch_vm(name)
87
+ path = @qemu.serial_log(record.fetch('hostname'))
88
+ raise Error, 'VM serial log is unavailable' unless path.file?
89
+
90
+ @runner.stream('tail', arguments: ['-n', '200', '-f', path.to_s])
91
+ end
92
+
93
+ private
94
+
95
+ def interactive_vm(name)
96
+ lock = nil
97
+ record = mutate do
98
+ current = booted_vm(name)
99
+ lock = session_lock(current).acquire(shared: true)
100
+ current
101
+ end
102
+ Execution::Terminal.with_interrupts { @progress.streaming { yield record } }
103
+ ensure
104
+ lock&.close
105
+ end
106
+
107
+ def session_lock(record)
108
+ ::Empeira::VM::SessionLock.new(context: context, record: record)
109
+ end
110
+
111
+ def boot_existing(record)
112
+ session_lock(record).exclusive { resume_vm(record) }
113
+ end
114
+
115
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- Retained VM restart has ordered, visible milestones.
116
+ def resume_vm(record)
117
+ @progress.stage(20, 'Checking retained VM storage...')
118
+ overlay, seed = existing_storage(record)
119
+ @progress.stage(30, 'Checking VM network prerequisites...')
120
+ @peer.preflight(@state)
121
+ @progress.stage(40, 'Preparing VM network...')
122
+ @peer.prepare(record, @state)
123
+ attachment = Network::Peer::Attachment.new(backend: @peer, record: record, state: @state)
124
+ begin
125
+ @progress.stage(55, 'Starting virtual machine...')
126
+ record['pid'] = @qemu.launch(record: record, overlay: overlay, seed: seed, network: attachment)
127
+ ensure
128
+ save if record['pid']
129
+ end
130
+ @progress.stage(80, 'Waiting for VM management readiness...')
131
+ @guest.wait(record, progress: @progress)
132
+ record['state'] = 'running'
133
+ save
134
+ @progress.stage(95, 'Virtual machine ready.')
135
+ end
136
+
137
+ def existing_storage(record)
138
+ ::Empeira::VM::Management.validate!(record)
139
+ base = base_for(record)
140
+ unless base.file? && !base.symlink? &&
141
+ OpenSSL::Digest::SHA256.file(base).hexdigest == record.dig('base_image', 'checksum')
142
+ raise Error, 'Pinned VM base image is missing or corrupt; preserve the overlay and restore the cache'
143
+ end
144
+
145
+ overlay = @disk.verify!(hostname: record.fetch('hostname'), base: base)
146
+ seed = vm_directory(record).join('seed.iso')
147
+ raise Error, 'VM cloud-init seed is missing; preserve the node for recovery' unless seed.file?
148
+
149
+ [overlay, seed]
150
+ end
151
+
152
+ def running_vm(name)
153
+ record = booted_vm(name)
154
+ unless record['provisioned']
155
+ raise Error,
156
+ 'VM bootstrap is incomplete; inspect logs or shell before recreating it'
157
+ end
158
+
159
+ record
160
+ end
161
+
162
+ def booted_vm(name)
163
+ record = fetch_vm(name)
164
+ unless @qemu.running?(record)
165
+ raise Error, "VM #{record.fetch('hostname')} is stopped; run empeira node start #{record.fetch('hostname')}"
166
+ end
167
+
168
+ record
169
+ end
170
+
171
+ def remove_vm(name, certificates: true)
172
+ record = vm_records[name]
173
+ return unless record
174
+
175
+ session_lock(record).exclusive { remove_vm_instance(name, record, certificates: certificates) }
176
+ end
177
+
178
+ def remove_vm_instance(name, record, certificates:)
179
+ @qemu.stop(record)
180
+ @peer.destroy(record, @state)
181
+ @qemu.cleanup(record)
182
+ clean_vm_certificate(record) if certificates && record['certificate_key']
183
+ remove_vm_disk(record)
184
+ remove_vm_directory(record)
185
+ @nodes.delete(name)
186
+ save
187
+ record
188
+ end
189
+
190
+ def clean_vm_certificate(record)
191
+ Certificates.new(runtime: @runtime, server: ready_server).clean(record)
192
+ end
193
+
194
+ def remove_vm_disk(record)
195
+ base = base_for(record)
196
+ overlay = vm_directory(record).join('disk.qcow2')
197
+ @disk.remove(hostname: record.fetch('hostname'), base: base) if overlay.exist? || overlay.symlink?
198
+ end
199
+
200
+ def remove_vm_directory(record)
201
+ directory = vm_directory(record)
202
+ return unless directory.exist? || directory.symlink?
203
+
204
+ FileUtils.remove_entry_secure(directory)
205
+ end
206
+
207
+ def vm_directory(record)
208
+ context.locations.workspace(context.workspace).join('vms', record.fetch('hostname'))
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ module VMPreflight
6
+ private
7
+
8
+ def preflight_vm(request)
9
+ reject_vm_name!(request.hostname.downcase)
10
+ ::Empeira::VM::Interfaces.validate_static!(context, request.hostname, @state)
11
+ accelerator = @engine.preflight!(progress: @progress)
12
+ @progress.stage(15, 'Validating guest image and bootstrap requirements...')
13
+ validate_bootstrap(request)
14
+ image = @source.resolve(distribution: request.os, version: request.version,
15
+ architecture: context.platform.architecture)
16
+ [image, accelerator]
17
+ end
18
+
19
+ def validate_bootstrap(request)
20
+ @cloud.validate!
21
+ packages = preflight_packages(request)
22
+ @requirements = ::Empeira::VM::BootstrapRequirements.new(context: context, os: request.os,
23
+ version: request.version,
24
+ distribution_required: packages.required?)
25
+ ::Empeira::Agent::Acquisition.new(context: context, runtime: @runtime).preflight!(@requirements)
26
+ @peer.preflight(@state)
27
+ @bootstrap_proxy.preflight!(@state) if @requirements.destinations.any?
28
+ @progress.heartbeat(@requirements.report)
29
+ end
30
+
31
+ def preflight_packages(request)
32
+ PackageBootstrap.new(config: context.configuration.dig('bootstrap', 'packages'),
33
+ os: request.os, execute: nil)
34
+ end
35
+ end
36
+ end
37
+ end