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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+
6
+ module Empeira
7
+ module VM
8
+ # Private NoCloud material is removed after initial guest configuration.
9
+ # rubocop:disable-next Metrics/ClassLength -- Seed, key and bootstrap material are one guest boundary.
10
+ class CloudInit
11
+ USER = 'empeira'
12
+
13
+ def initialize(context:, runner:, executable_path: ENV.fetch('PATH', ''))
14
+ @context = context
15
+ @runner = runner
16
+ @executable_path = executable_path
17
+ end
18
+
19
+ def prepare(record)
20
+ Management.validate!(record)
21
+ directory = node_directory(record.fetch('hostname'))
22
+ FileUtils.mkdir_p(directory, mode: 0o700)
23
+ credentials = Node::SSHCredentials.new(context: @context, runner: @runner, provider: 'vm',
24
+ hostname: record.fetch('hostname'))
25
+ credentials.prepare
26
+ key = credentials.key_path
27
+ files = seed_files(directory, record, File.read("#{key}.pub").strip)
28
+ create_iso(directory, files)
29
+ end
30
+
31
+ def finish(record, guest:)
32
+ directory = node_directory(record.fetch('hostname'))
33
+ files = seed_files(directory, record, File.read("#{key_path(record.fetch('hostname'))}.pub").strip,
34
+ console: false)
35
+ create_iso(directory, files)
36
+ paths = %w[user-data.txt user-data.txt.i cloud-config.txt obj.pkl].map do |name|
37
+ "/var/lib/cloud/instance/#{name}"
38
+ end
39
+ result = guest.run(record, ['rm', '-f', *paths])
40
+ return if result.success?
41
+
42
+ details = Execution::Diagnostics.command(result, operation: 'Guest cloud-init credential cleanup', tool: 'rm')
43
+ raise Error, "Cannot remove temporary guest cloud-init credentials\n#{details}", cause: nil
44
+ end
45
+
46
+ def key_path(hostname)
47
+ node_directory(hostname).join('id_ed25519')
48
+ end
49
+
50
+ def validate!
51
+ bootstrap_files
52
+ end
53
+
54
+ private
55
+
56
+ def create_iso(directory, files)
57
+ output = directory.join('seed.iso')
58
+ tool = locate('xorriso') || raise(UnavailableFeature, 'xorriso is required for VM cloud-init seeds')
59
+ Tempfile.create(['seed-', '.iso'], directory) do |temporary|
60
+ result = @runner.run(tool, arguments: ['-as', 'mkisofs', '-quiet', '-volid', 'cidata',
61
+ '-output', temporary.path, *files.map(&:to_s)], timeout: 30)
62
+ raise Error, 'Cannot create VM cloud-init seed' unless result.success?
63
+
64
+ File.chmod(0o600, temporary.path)
65
+ File.rename(temporary.path, output)
66
+ end
67
+ output
68
+ ensure
69
+ files&.each { |path| FileUtils.rm_f(path) }
70
+ end
71
+
72
+ def seed_files(directory, record, public_key, console: true)
73
+ data = cloud_config(record, public_key, console: console)
74
+ metadata = { 'instance-id' => "#{@context.workspace.id}-#{record.fetch('hostname')}",
75
+ 'local-hostname' => record.fetch('hostname') }
76
+ network = network_config(record)
77
+ { 'user-data' => "#cloud-config\n#{YAML.dump(data)}", 'meta-data' => YAML.dump(metadata),
78
+ 'network-config' => YAML.dump(network) }.map do |name, content|
79
+ path = directory.join(name)
80
+ File.write(path, content, mode: 'w', perm: 0o600)
81
+ File.chmod(0o600, path)
82
+ path
83
+ end
84
+ end
85
+
86
+ def network_config(record)
87
+ { 'version' => 2, 'ethernets' => {
88
+ 'peer' => { 'match' => { 'macaddress' => record.fetch('mac_address') }, 'set-name' => 'eth0',
89
+ 'addresses' => ["#{record.fetch('peer').fetch('ip')}/24"], 'dhcp4' => false, 'dhcp6' => false,
90
+ 'routes' => [{ 'to' => '0.0.0.0/0', 'via' => record.fetch('peer').fetch('gateway') }],
91
+ 'link-local' => [], 'nameservers' => { 'addresses' => [record.fetch('peer').fetch('dns')] } }
92
+ } }
93
+ end
94
+
95
+ def cloud_config(record, public_key, console: true)
96
+ scripts = bootstrap_files
97
+ { 'hostname' => record.fetch('hostname'), 'prefer_fqdn_over_hostname' => true,
98
+ 'fqdn' => record.fetch('hostname'), 'manage_etc_hosts' => true,
99
+ 'users' => [system_user],
100
+ 'ssh_pwauth' => false, 'disable_root' => true,
101
+ 'write_files' => cloud_files(scripts) + management_files(record, public_key),
102
+ 'runcmd' => cloud_commands(scripts),
103
+ **(console ? console_password : {}) }
104
+ end
105
+
106
+ def console_password
107
+ password = @context.configuration.dig('vm', 'console', 'root_password')
108
+ return {} unless password
109
+
110
+ { 'chpasswd' => { 'expire' => false,
111
+ 'users' => [{ 'name' => 'root', 'password' => password, 'type' => 'text' }] } }
112
+ end
113
+
114
+ def cloud_files(scripts)
115
+ Node::Bootstrap.new(provider: 'vm').files.map do |file|
116
+ { 'path' => file.path, 'permissions' => format('%04o', file.mode), 'content' => file.content }
117
+ end + [{ 'path' => '/etc/cloud/cloud.cfg.d/99-empeira-hostname.cfg', 'permissions' => '0644',
118
+ 'content' => "preserve_hostname: true\n" },
119
+ { 'path' => '/etc/sysctl.d/90-empeira-ipv4.conf', 'permissions' => '0644',
120
+ 'content' => "net.ipv6.conf.all.disable_ipv6=1\nnet.ipv6.conf.default.disable_ipv6=1\n" }] + scripts
121
+ end
122
+
123
+ def cloud_commands(scripts)
124
+ management = [[Management::SETUP]]
125
+ [['sysctl', '--system'], *management, *scripts.map { |entry| [entry.fetch('path')] }]
126
+ end
127
+
128
+ def system_user
129
+ { 'name' => USER, 'system' => true, 'homedir' => '/var/lib/empeira',
130
+ 'lock_passwd' => true, 'shell' => '/bin/bash' }
131
+ end
132
+
133
+ def management_files(record, public_key)
134
+ Management.files(record, system_key: public_key)
135
+ end
136
+
137
+ def bootstrap_files
138
+ config = @context.configuration.fetch('bootstrap')
139
+ return [] unless config.fetch('enabled')
140
+
141
+ config.fetch('scripts').each_with_index.map { |relative, index| bootstrap_entry(relative, index) }
142
+ end
143
+
144
+ def bootstrap_entry(relative, index)
145
+ source = @context.project.path.join(relative).realpath
146
+ unless source.file? && source.to_s.start_with?("#{@context.project.path}/") && source.size <= 1_000_000
147
+ raise ConfigurationError, "bootstrap.scripts.#{index} must be a project-local file below 1 MB"
148
+ end
149
+
150
+ content = File.read(source, encoding: 'UTF-8')
151
+ raise ConfigurationError, "bootstrap.scripts.#{index} must be UTF-8" unless content.valid_encoding?
152
+
153
+ { 'path' => "/usr/local/libexec/empeira-bootstrap-#{index}", 'permissions' => '0700',
154
+ 'content' => content }
155
+ rescue SystemCallError
156
+ raise ConfigurationError, "bootstrap.scripts.#{index} must resolve to a project-local file", cause: nil
157
+ end
158
+
159
+ def node_directory(hostname)
160
+ @context.locations.workspace(@context.workspace).join('vms', hostname)
161
+ end
162
+
163
+ def locate(name)
164
+ @executable_path.split(File::PATH_SEPARATOR).filter_map do |directory|
165
+ path = Pathname(directory).join(name)
166
+ path.to_s if path.file? && path.executable?
167
+ end.first
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'tempfile'
6
+
7
+ module Empeira
8
+ module VM
9
+ # One writable QCOW2 overlay per node; the shared base remains read-only.
10
+ class Disk
11
+ GIB = 1024**3
12
+
13
+ def initialize(engine:, runner:, workspace_directory:)
14
+ @engine = engine
15
+ @runner = runner
16
+ @workspace_directory = workspace_directory
17
+ end
18
+
19
+ def create(hostname:, base:, size_gib:)
20
+ require_base!(base)
21
+ verify_capacity!(base, size_gib)
22
+ directory = node_directory(hostname)
23
+ FileUtils.mkdir_p(directory, mode: 0o700)
24
+ overlay = directory.join('disk.qcow2')
25
+ raise Providers::AlreadyExists, 'VM overlay already exists' if overlay.exist? || overlay.symlink?
26
+
27
+ Tempfile.create(['disk-', '.qcow2'], directory) do |temporary|
28
+ staging = Pathname(temporary.path)
29
+ prepare_overlay!(base, staging, size_gib)
30
+ File.link(staging, overlay)
31
+ end
32
+ overlay
33
+ end
34
+
35
+ def verify!(hostname:, base:)
36
+ overlay = node_directory(hostname).join('disk.qcow2')
37
+ raise Error, 'VM overlay is missing or unsafe' unless overlay.file? && !overlay.symlink?
38
+
39
+ verify_backing!(overlay_metadata(overlay), base)
40
+ overlay
41
+ end
42
+
43
+ def remove(hostname:, base:)
44
+ overlay = verify!(hostname: hostname, base: base)
45
+ File.unlink(overlay)
46
+ overlay
47
+ end
48
+
49
+ private
50
+
51
+ def prepare_overlay!(base, overlay, size_gib)
52
+ create_overlay!(base, overlay)
53
+ resize_overlay!(overlay, size_gib)
54
+ data = overlay_metadata(overlay)
55
+ verify_backing!(data, base)
56
+ raise Error, 'VM overlay capacity differs from vm.disk' unless data['virtual-size'] == size_gib * GIB
57
+ end
58
+
59
+ def verify_backing!(data, base)
60
+ return if data['format'] == 'qcow2' && data['backing-filename'] == base.to_s &&
61
+ data['backing-filename-format'] == 'qcow2'
62
+
63
+ raise Providers::OwnershipError, 'VM overlay backing image differs from recorded base'
64
+ end
65
+
66
+ def verify_capacity!(base, size_gib)
67
+ raise ConfigurationError, 'vm.disk must be an integer from 1 to 2048 GiB' unless
68
+ size_gib.is_a?(Integer) && (1..2048).cover?(size_gib)
69
+
70
+ capacity = verify_base!(base).fetch('virtual-size', nil)
71
+ raise Error, 'VM base image virtual capacity is missing or invalid' unless
72
+ capacity.is_a?(Integer) && capacity.positive?
73
+ return if size_gib * GIB >= capacity
74
+
75
+ raise ConfigurationError, "vm.disk (#{size_gib} GiB) is smaller than the base image (#{capacity} bytes)"
76
+ end
77
+
78
+ def require_base!(base)
79
+ raise Error, 'Verified VM base image is missing' unless base.file? && !base.symlink?
80
+ end
81
+
82
+ def verify_base!(base)
83
+ result = @runner.run(@engine.image_tool, arguments: ['info', '--output=json', base.to_s], timeout: 10)
84
+ verify_result!(result, 'Cannot inspect verified VM base image')
85
+
86
+ metadata = JSON.parse(result.stdout)
87
+ return metadata if metadata.is_a?(Hash) && metadata['format'] == 'qcow2' && !metadata.key?('backing-filename')
88
+
89
+ raise Error, 'VM base image must be a standalone QCOW2 image'
90
+ rescue JSON::ParserError
91
+ raise Error, 'Malformed QEMU base-image metadata', cause: nil
92
+ end
93
+
94
+ def create_overlay!(base, overlay)
95
+ result = @runner.run(@engine.image_tool, arguments: ['create', '-f', 'qcow2', '-F', 'qcow2',
96
+ '-b', base.to_s, overlay.to_s], timeout: 30)
97
+ verify_result!(result, 'QEMU overlay creation failed')
98
+
99
+ File.chmod(0o600, overlay)
100
+ end
101
+
102
+ def resize_overlay!(overlay, size_gib)
103
+ result = @runner.run(@engine.image_tool,
104
+ arguments: ['resize', '--preallocation=off', '-f', 'qcow2', overlay.to_s, "#{size_gib}G"],
105
+ timeout: 30)
106
+ verify_result!(result, 'QEMU overlay sizing failed for vm.disk')
107
+ end
108
+
109
+ def verify_result!(result, operation)
110
+ return if result.success?
111
+
112
+ details = Execution::Diagnostics.command(result, operation: operation, tool: 'qemu-img')
113
+ raise Error, "#{operation}\n#{details}", cause: nil
114
+ end
115
+
116
+ def overlay_metadata(overlay)
117
+ result = @runner.run(@engine.image_tool, arguments: ['info', '--output=json', overlay.to_s], timeout: 10)
118
+ verify_result!(result, 'Cannot inspect VM overlay')
119
+
120
+ data = JSON.parse(result.stdout)
121
+ raise Error, 'Malformed QEMU overlay metadata' unless data.is_a?(Hash)
122
+
123
+ data
124
+ rescue JSON::ParserError
125
+ raise Error, 'Malformed QEMU overlay metadata', cause: nil
126
+ end
127
+
128
+ def node_directory(hostname)
129
+ raise Error, 'Invalid VM hostname' unless Node::Inventory.valid_name?(hostname) &&
130
+ hostname == hostname.downcase
131
+
132
+ @workspace_directory.join('vms', hostname)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ # Shared guest execution/upload/readiness API; no host or guest SSH credentials.
6
+ class Guest
7
+ class TransportError < Error
8
+ attr_reader :timed_out
9
+
10
+ def initialize(message, transient: false, timed_out: false)
11
+ @timed_out = timed_out
12
+ @transient = transient
13
+ super(message)
14
+ end
15
+
16
+ def transient? = @transient
17
+
18
+ def with_context(message)
19
+ self.class.new("#{message}\n#{self.message}", transient: transient?, timed_out: timed_out)
20
+ end
21
+ end
22
+
23
+ def initialize(context:, runner:, qemu:)
24
+ @context = context
25
+ @runner = runner
26
+ @qemu = qemu
27
+ end
28
+
29
+ def run(record, arguments, timeout: 30)
30
+ execute(record, arguments, timeout: timeout)
31
+ end
32
+
33
+ def stream(record, arguments, timeout: 3600, output: $stdout, error: $stderr)
34
+ execute(record, arguments, timeout: timeout, output: output, error: error)
35
+ end
36
+
37
+ def shell(record, input: $stdin, output: $stdout)
38
+ raise Error, 'Node shell requires a local terminal' unless input.tty? && output.tty?
39
+
40
+ transfer(record) { |channel| GuestShell.new(input: input, output: output).consume(channel) }
41
+ end
42
+
43
+ def copy_to(record, source, destination, mode: '0644')
44
+ File.open(source, 'rb') do |file|
45
+ transfer(record) { |channel| GuestUpload.new(channel).write(file, destination, mode: mode) }
46
+ end
47
+ end
48
+
49
+ # rubocop:disable-next Metrics/MethodLength -- Readiness alone retries harmless status probes before bootstrap.
50
+ def wait(record, progress: nil, seconds: 300)
51
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
52
+ loop do
53
+ begin
54
+ result = run(record, ['cloud-init', 'status', '--wait', '--long'], timeout: 20)
55
+ return if result.success?
56
+
57
+ details = Execution::Diagnostics.command(result, operation: 'VM cloud-init readiness',
58
+ tool: 'VirtIO management')
59
+ rescue TransportError => e
60
+ details = e.message
61
+ end
62
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
63
+ raise Error,
64
+ "VM management or cloud-init did not become ready; inspect node shell and serial log\n#{details}"
65
+ end
66
+
67
+ progress&.heartbeat('Waiting for VM management and cloud-init...')
68
+ sleep 2
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def execute(record, arguments, timeout:, output: nil, error: nil)
75
+ validate_command!(arguments, timeout)
76
+
77
+ transfer(record) do |channel|
78
+ channel.send_frame('type' => 'exec', 'argv' => arguments, 'timeout' => timeout)
79
+ GuestOutput.new(output: output, error: error).consume(channel, timeout: timeout && (timeout + 5))
80
+ end
81
+ end
82
+
83
+ def validate_command!(arguments, timeout)
84
+ unless arguments.is_a?(Array) && !arguments.empty? && arguments.all? do |arg|
85
+ arg.is_a?(String) && !arg.include?("\0")
86
+ end
87
+ raise ExecutionError, 'Guest command arguments must be nonempty strings without null bytes'
88
+ end
89
+
90
+ validate_timeout!(timeout)
91
+ end
92
+
93
+ def validate_timeout!(timeout)
94
+ return if timeout.nil?
95
+
96
+ return if timeout.is_a?(Numeric) && timeout.finite? && timeout.positive? && timeout <= 86_400
97
+
98
+ raise ExecutionError, 'Guest timeout must be positive and at most one day'
99
+ end
100
+
101
+ # rubocop:disable-next Metrics/MethodLength -- Completion and cancellation must share the same ensure boundary.
102
+ def transfer(record)
103
+ Management.validate!(record)
104
+ GuestChannel.new(record: record, qemu: @qemu).open do |channel|
105
+ completed = false
106
+ begin
107
+ value = yield channel
108
+ completed = true
109
+ value
110
+ ensure
111
+ channel.cancel unless completed
112
+ end
113
+ end
114
+ rescue SystemCallError, IOError, Timeout::Error => e
115
+ raise TransportError.new("VM VirtIO management transport failed (#{e.class.name}); " \
116
+ 'guest completion is unknown; ' \
117
+ 'VM retained. Inspect node shell, serial log and the management service journal.',
118
+ transient: e.is_a?(Errno::ENOENT) || e.is_a?(Errno::ECONNREFUSED),
119
+ timed_out: e.is_a?(Timeout::Error)), cause: nil
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'securerandom'
5
+ require 'io/wait'
6
+
7
+ module Empeira
8
+ module VM
9
+ # One private, instance-bound VirtIO-Serial session; never logs protocol payloads.
10
+ class GuestChannel
11
+ LIMIT = 131_072
12
+ CHUNK = 16_384
13
+
14
+ def initialize(record:, qemu:)
15
+ @record = record
16
+ @qemu = qemu
17
+ @id = SecureRandom.hex(16)
18
+ @buffer = +''.b
19
+ end
20
+
21
+ def open
22
+ @qemu.with_management_socket(@record) do |path, expected|
23
+ connect(path, expected) { yield self }
24
+ end
25
+ end
26
+
27
+ def connect(path, expected)
28
+ original = path.lstat
29
+ @socket = UNIXSocket.new(path)
30
+ current = path.lstat
31
+ unless [original.dev, original.ino] == [current.dev, current.ino] &&
32
+ expected == { 'device' => current.dev, 'inode' => current.ino }
33
+ raise Providers::OwnershipError, 'VM management socket changed while connecting'
34
+ end
35
+
36
+ handshake!
37
+
38
+ yield self
39
+ ensure
40
+ @socket&.close
41
+ end
42
+
43
+ def readable?
44
+ @buffer.include?("\n")
45
+ end
46
+
47
+ def io
48
+ @socket
49
+ end
50
+
51
+ def handshake!
52
+ send_frame('type' => 'hello', 'token' => @record.fetch('peer').fetch('token'))
53
+ response = receive(timeout: 5)
54
+ unless response['type'] == 'hello' && response['version'] == Management::VERSION &&
55
+ response['token'] == @record.fetch('peer').fetch('token')
56
+ raise Providers::OwnershipError, 'VM management identity mismatch; operation refused'
57
+ end
58
+ end
59
+
60
+ def send_frame(frame)
61
+ message = "#{JSON.generate(frame.merge('id' => @id))}\n"
62
+ raise Error, 'VM management request is too large' if message.bytesize > LIMIT
63
+
64
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
65
+ until message.empty?
66
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ raise Timeout::Error if remaining <= 0 || !@socket.wait_writable(remaining)
68
+
69
+ written = @socket.write_nonblock(message, exception: false)
70
+ message = message.byteslice(written..) unless written == :wait_writable
71
+ end
72
+ end
73
+
74
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- Frame size, deadline and session identity are checked together.
75
+ def receive(timeout:)
76
+ deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
77
+ until @buffer.include?("\n")
78
+ remaining = deadline && (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC))
79
+ raise Timeout::Error if (remaining && remaining <= 0) || !@socket.wait_readable(remaining)
80
+
81
+ @buffer << @socket.readpartial(CHUNK)
82
+ raise Error, 'VM management response is oversized' if @buffer.bytesize > LIMIT
83
+ end
84
+ line, @buffer = @buffer.split("\n", 2)
85
+ result = JSON.parse(line)
86
+ unless result.is_a?(Hash) && result['id'] == @id
87
+ raise Providers::OwnershipError, 'VM management response belongs to another operation'
88
+ end
89
+
90
+ result
91
+ rescue JSON::ParserError
92
+ raise Error, 'VM management returned malformed protocol data', cause: nil
93
+ end
94
+
95
+ def cancel
96
+ send_frame('type' => 'cancel')
97
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 3
98
+ loop do
99
+ frame = receive(timeout: deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC))
100
+ next unless frame['type'] == 'exit'
101
+
102
+ GuestOutput.new.consume_completion(frame)
103
+ break
104
+ end
105
+ rescue StandardError
106
+ raise Guest::TransportError, 'VM guest session cancellation is unconfirmed; node retained', cause: nil
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Empeira
6
+ module VM
7
+ # All observations and mutations use the existing guest management transport.
8
+ class GuestNetwork
9
+ attr_reader :links, :addresses, :routes
10
+
11
+ def initialize(guest:, record:)
12
+ @guest = guest
13
+ @record = record
14
+ end
15
+
16
+ def refresh
17
+ @links = read(%w[ip -j -d link show]).to_h { |link| [link.fetch('ifname'), link] }
18
+ @addresses = read(%w[ip -j address show]).to_h { |link| [link.fetch('ifname'), link.fetch('addr_info')] }
19
+ @routes = read(%w[ip -j -4 route show table all])
20
+ self
21
+ rescue KeyError, TypeError
22
+ raise Error, "Host #{@record.fetch('hostname')}, vm.interfaces: incomplete guest network observation",
23
+ cause: nil
24
+ end
25
+
26
+ def load_modules(vlan:)
27
+ # Prevent module autoload from creating an unowned default dummy0.
28
+ command(%w[modprobe dummy numdummies=0]) unless @guest.run(@record, %w[test -d /sys/module/dummy]).success?
29
+ command(%w[modprobe 8021q]) if vlan && !@guest.run(@record, %w[test -d /sys/module/8021q]).success?
30
+ end
31
+
32
+ def command(arguments)
33
+ result = @guest.run(@record, arguments)
34
+ return result if result.success?
35
+
36
+ details = Execution::Diagnostics.command(result, operation: operation(arguments), tool: arguments.first)
37
+ raise Error, "Cannot reconcile VM interfaces; guest operation failed\n#{details}", cause: nil
38
+ rescue Guest::TransportError => e
39
+ raise e.with_context(operation(arguments)), cause: nil
40
+ end
41
+
42
+ def operation(arguments)
43
+ position = arguments.index('dev') || arguments.index('name')
44
+ interface = position ? ", interface #{arguments[position + 1]}" : ''
45
+ "Host #{@record.fetch('hostname')}#{interface}, vm.interfaces: #{arguments.first}"
46
+ end
47
+
48
+ def read(arguments)
49
+ value = JSON.parse(command(arguments).stdout)
50
+ return value if value.is_a?(Array) && value.all?(Hash)
51
+
52
+ raise TypeError
53
+ rescue JSON::ParserError, TypeError
54
+ raise Error, "Host #{@record.fetch('hostname')}, vm.interfaces: cannot verify guest #{arguments.first} JSON",
55
+ cause: nil
56
+ end
57
+
58
+ def ipv4(name)
59
+ addresses.fetch(name, []).select { |address| address['family'] == 'inet' }
60
+ end
61
+
62
+ def parent_name(link)
63
+ return link['link'] if link['link'].is_a?(String)
64
+ return unless link['link_index'].is_a?(Integer)
65
+
66
+ links.values.find { |candidate| candidate['ifindex'] == link['link_index'] }&.fetch('ifname')
67
+ end
68
+
69
+ def defaults
70
+ routes.select { |route| %w[default 0.0.0.0/0].include?(route['dst']) }
71
+ end
72
+
73
+ def verify_facter!(desired)
74
+ result = command(['/opt/puppetlabs/bin/facter', 'networking', '--json'])
75
+ facts = JSON.parse(result.stdout).fetch('networking').fetch('interfaces')
76
+ desired.each do |name, definition|
77
+ binding = facts.dig(name, 'bindings')&.first
78
+ next if correct_binding?(binding, definition.fetch('network'))
79
+
80
+ raise Error, "Host #{@record.fetch('hostname')}, interface #{name}, vm.interfaces: " \
81
+ 'regular Facter networking.interfaces bindings do not match; Puppet was not run'
82
+ end
83
+ rescue JSON::ParserError, KeyError, TypeError, NoMethodError
84
+ raise Error, "Host #{@record.fetch('hostname')}, vm.interfaces: cannot verify regular Facter networking JSON",
85
+ cause: nil
86
+ end
87
+
88
+ def correct_binding?(binding, network)
89
+ binding.is_a?(Hash) && binding['address'] == network.split('/').first &&
90
+ binding['netmask'] == IPAddr.new(network).netmask.to_s
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module VM
5
+ # Frame output is drained without truncation; streaming sinks receive it before completion.
6
+ class GuestOutput
7
+ def initialize(output: nil, error: nil)
8
+ @stdout = +''.b
9
+ @stderr = +''.b
10
+ @output = output || @stdout
11
+ @error = error || @stderr
12
+ end
13
+
14
+ def consume(channel, timeout:)
15
+ deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
16
+ loop do
17
+ frame = channel.receive(timeout: deadline && (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)))
18
+ case frame['type']
19
+ when 'started' then next
20
+ when 'stdout', 'stderr' then emit(frame)
21
+ when 'exit' then return completion(frame)
22
+ else raise Error, 'VM management returned an unexpected protocol message'
23
+ end
24
+ end
25
+ rescue ArgumentError, KeyError
26
+ raise Error, 'VM management returned malformed output data', cause: nil
27
+ end
28
+
29
+ def consume_completion(frame)
30
+ completion(frame)
31
+ end
32
+
33
+ private
34
+
35
+ def emit(frame)
36
+ sink = frame['type'] == 'stdout' ? @output : @error
37
+ sink << Base64.strict_decode64(frame.fetch('data'))
38
+ sink.flush if sink.respond_to?(:flush)
39
+ end
40
+
41
+ def completion(frame)
42
+ status, timed_out = frame.values_at('status', 'timed_out')
43
+ unless [true, false].include?(timed_out) &&
44
+ ((status.is_a?(Integer) && status.between?(0, 255)) || (status.nil? && timed_out))
45
+ raise Error, 'VM management returned invalid guest completion status'
46
+ end
47
+
48
+ Execution::Result.new(stdout: @stdout, stderr: @stderr, exit_status: status, timed_out: timed_out)
49
+ end
50
+ end
51
+ end
52
+ end