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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ # Native CLIs own registry authentication, manifest negotiation and transport.
6
+ module ImageMetadata
7
+ def with_image_updates
8
+ previous = @image_metadata
9
+ @image_metadata = {}
10
+ yield
11
+ ensure
12
+ @image_metadata = previous
13
+ end
14
+
15
+ def remote_digest(image)
16
+ return @image_metadata.fetch(image) if @image_metadata&.key?(image)
17
+
18
+ digest = platform_digest(remote_descriptors(image))
19
+ @image_metadata[image] = digest if @image_metadata
20
+ digest
21
+ rescue Error => e
22
+ raise Providers::ExecutionError, "Image metadata check failed for #{image}: #{e.message}", cause: nil
23
+ end
24
+
25
+ private
26
+
27
+ # rubocop:disable-next Metrics/AbcSize -- Validate descriptor shape, platform and unambiguous digest together.
28
+ def platform_digest(descriptors)
29
+ unless descriptors.is_a?(Array) && descriptors.all?(Hash)
30
+ raise Providers::ExecutionError, 'Runtime returned malformed image descriptors'
31
+ end
32
+
33
+ selected = descriptors.select { |entry| matching_platform?(entry['platform']) }
34
+ digests = selected.map { |entry| entry['digest'] }.uniq
35
+ unless digests.size == 1 && valid_digest?(digests.first)
36
+ raise Providers::ExecutionError, "No unambiguous Linux/#{architecture} manifest digest in runtime metadata"
37
+ end
38
+
39
+ digests.first
40
+ end
41
+
42
+ def matching_platform?(platform)
43
+ return false unless platform.is_a?(Hash)
44
+
45
+ platform['os'] == 'linux' && platform['architecture'] == architecture &&
46
+ (platform['variant'].to_s.empty? || (architecture == 'arm64' && platform['variant'] == 'v8'))
47
+ end
48
+
49
+ def valid_digest?(value)
50
+ value.is_a?(String) && value.match?(Images::Configuration::DIGEST)
51
+ end
52
+
53
+ def metadata_command(arguments, image:, timeout: 30)
54
+ update_command(arguments, operation: 'remote image metadata', timeout: timeout, registry: image)
55
+ end
56
+
57
+ def local_image(image)
58
+ result = local_image_inspection(image)
59
+ return unless result.success?
60
+
61
+ data = parse_json(result.stdout)
62
+ malformed! unless data.is_a?(Array) && data.size == 1 && data.first.is_a?(Hash)
63
+ data.first
64
+ end
65
+
66
+ def local_digests(data)
67
+ [data['Digest'], *Array(data['RepoDigests']).map { |value| value.to_s.split('@', 2)[1] }]
68
+ .select { |digest| valid_digest?(digest) }.uniq
69
+ end
70
+
71
+ def same_remote_image?(image, local, remote)
72
+ return false unless local && local['Architecture'] == architecture && local['Os'] == 'linux'
73
+
74
+ digests = local_digests(local)
75
+ return true if digests.include?(remote)
76
+
77
+ # Docker's classic store can record an index digest. Resolve that immutable
78
+ # historical reference natively before comparing platform manifests.
79
+ digests.any? { |digest| remote_digest(Images::Reference.pinned(image, digest)) == remote }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ class Interface < Providers::Lifecycle
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ # Prove that an engine sees this client's canonical directory, including VM-backed engines.
6
+ class MountProbe
7
+ def initialize(runtime:)
8
+ @runtime = runtime
9
+ end
10
+
11
+ def verify(resource, mounts)
12
+ mounts.each do |mount|
13
+ fields = mount.split(',').select { |field| field.include?('=') }.to_h { |field| field.split('=', 2) }
14
+ if File.file?(fields.fetch('src'))
15
+ verify_file(resource, fields.fetch('src'), fields.fetch('dst'))
16
+ else
17
+ verify_directory(resource, fields.fetch('src'), fields.fetch('dst'))
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def verify_file(resource, source, destination)
25
+ code = 'exit(File.file?(ARGV[0]) && File.readable?(ARGV[0]) ? 0 : 1)'
26
+ result = @runtime.service_exec(resource, [ControlPlane::Health::RUBY, '-e', code, destination])
27
+ return if result.success?
28
+
29
+ failed_probe!(result, source, destination, 'Cannot verify the configured file mount (EYAML or server.mounts)')
30
+ end
31
+
32
+ def verify_directory(resource, source, destination)
33
+ code = 'require "digest"; print Digest::SHA256.hexdigest(Dir.children(ARGV.fetch(0)).sort.join("\0"))'
34
+ expected = Digest::SHA256.hexdigest(Dir.children(source).sort.join("\0"))
35
+ result = @runtime.service_exec(resource, [ControlPlane::Health::RUBY, '-e', code, destination])
36
+ return if result.success? && result.stdout == expected
37
+
38
+ reason = if result.success?
39
+ 'Container directory listing differs from the local mount source'
40
+ else
41
+ 'Cannot execute the container directory mount probe'
42
+ end
43
+ failed_probe!(result, source, destination, reason)
44
+ rescue SystemCallError
45
+ location = Execution::Diagnostics.safe_text(source.to_s)
46
+ raise Error, "Cannot read local mount directory to verify the mount: #{location}", cause: nil
47
+ end
48
+
49
+ def failed_probe!(result, source, destination, reason)
50
+ paths = Execution::Diagnostics.safe_text("Source: #{source}\nDestination: #{destination}")
51
+ details = Execution::Diagnostics.command(result, operation: 'Verify server mount visibility',
52
+ tool: ControlPlane::Health::RUBY)
53
+ raise Error, "#{reason}\n#{paths}\n#{details}", cause: nil
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ class Podman < Container
6
+ def node_ssh_publication?
7
+ true
8
+ end
9
+
10
+ def initialize(context:, runner:)
11
+ super(name: 'podman', context: context, runner: runner)
12
+ end
13
+
14
+ protected
15
+
16
+ # A disposable manifest forces registry access even if the tag exists locally.
17
+ # Podman resolves both single manifests and indexes without downloading layers.
18
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- Keep manifest ownership, deadline and cleanup in one transaction.
19
+ def remote_descriptors(image)
20
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30
21
+ temporary = "localhost/empeira-metadata-#{SecureRandom.hex(16)}"
22
+ result = update_command(['manifest', 'create', temporary], operation: 'temporary metadata creation', timeout: 5)
23
+ identifier = result.stdout.strip
24
+ unless identifier.match?(/\A[0-9a-f]{64}\z/)
25
+ raise Providers::ExecutionError,
26
+ 'Invalid temporary manifest identity'
27
+ end
28
+
29
+ metadata_command(['manifest', 'add', '--all', identifier, "docker://#{image}"],
30
+ image: image, timeout: metadata_remaining(deadline))
31
+ result = metadata_command(['manifest', 'inspect', identifier], image: image,
32
+ timeout: metadata_remaining(deadline))
33
+ data = parse_json(result.stdout)
34
+ malformed! unless data.is_a?(Hash) && data['manifests'].is_a?(Array)
35
+ data['manifests']
36
+ ensure
37
+ if identifier&.match?(/\A[0-9a-f]{64}\z/)
38
+ update_command(['manifest', 'rm', identifier], operation: 'temporary metadata cleanup', timeout: 5)
39
+ end
40
+ end
41
+
42
+ def metadata_remaining(deadline)
43
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
+ raise Providers::ExecutionError, 'Remote metadata timeout' unless remaining.positive?
45
+
46
+ remaining
47
+ end
48
+
49
+ def update_helper_user
50
+ ['--userns', 'keep-id', *super]
51
+ end
52
+
53
+ def update_helper_network
54
+ 'podman'
55
+ end
56
+
57
+ def info_arguments
58
+ ['info', '--format', 'json']
59
+ end
60
+
61
+ def validate_info!(info)
62
+ malformed! unless info.is_a?(Hash) && info['host'].is_a?(Hash)
63
+ return if info.dig('host', 'networkBackend') == 'netavark' &&
64
+ bridge_available?(info['plugins'])
65
+
66
+ raise UnsupportedCapability, 'Podman requires the Netavark network backend with bridge support'
67
+ end
68
+
69
+ def bridge_available?(plugins)
70
+ plugins.is_a?(Hash) && plugins['network'].is_a?(Array) && plugins['network'].include?('bridge')
71
+ end
72
+
73
+ def network_entries
74
+ entries = json_command(['network', 'ls', '--format', 'json'], operation: 'network inventory')
75
+ malformed! unless entries.is_a?(Array) && entries.all?(Hash)
76
+ validate_entries(entries.map { |item| { 'id' => item['id'], 'name' => item['name'] } })
77
+ end
78
+
79
+ def create_arguments(definition)
80
+ if definition.is_a?(Network::Egress)
81
+ return ['network', 'create', '--driver', 'bridge', *label_arguments(definition), definition.backend_name]
82
+ end
83
+
84
+ ['network', 'create', '--driver', 'bridge', '--internal', '--disable-dns',
85
+ '--opt', 'isolate=true', *peer_network_options(definition), *label_arguments(definition),
86
+ definition.backend_name]
87
+ end
88
+
89
+ def attachment_count(identifier)
90
+ entries = json_command(['ps', '--all', '--filter', "network=#{identifier}", '--format', 'json'],
91
+ operation: 'network membership')
92
+ malformed! unless entries.is_a?(Array) && entries.all?(Hash)
93
+ entries.size
94
+ end
95
+
96
+ def peer_network_options(definition)
97
+ return [] unless definition.allocation
98
+
99
+ layout = Network::Peer::Layout.new(definition.allocation)
100
+ ['--subnet', layout.subnet, '--ip-range', layout.container_pool,
101
+ '--interface-name', "ep#{context.workspace.id[0, 10]}"]
102
+ end
103
+
104
+ def isolated?(data)
105
+ data['internal'] && data['driver'] == 'bridge' && data['dns_enabled'] == false &&
106
+ data['options']['isolate'] == 'true'
107
+ end
108
+
109
+ def normalize_network_binding(data)
110
+ data = data.merge('options' => data['options'] || {})
111
+ malformed! unless [true, false].include?(data['internal']) && data['options'].is_a?(Hash)
112
+ Network::Binding.new(id: data['id'], name: data['name'], labels: data['labels'] || {},
113
+ isolated: isolated?(data))
114
+ end
115
+
116
+ def normalize_network(data)
117
+ binding = normalize_network_binding(data)
118
+ Network::Resource.new(**binding.to_h, attachment_count: attachment_count(binding.id))
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ module RecipeBuild
6
+ private
7
+
8
+ def base_state(recipe, refresh:)
9
+ Images::Recipe.bases(recipe).sort.to_h.transform_values do |reference|
10
+ digest = refresh ? remote_digest(reference) : available_base_digest(reference)
11
+ { 'reference' => reference, 'digest' => digest }
12
+ end
13
+ end
14
+
15
+ def available_base_digest(reference)
16
+ ensure_image(reference)
17
+ data = local_image(reference)
18
+ unless data && data['Architecture'] == architecture && data['Os'] == 'linux'
19
+ raise Providers::ExecutionError, "Build base has an incompatible platform: #{reference}"
20
+ end
21
+
22
+ digest = local_digests(data).first
23
+ raise Providers::ExecutionError, "Build base has no registry digest: #{reference}" unless digest
24
+
25
+ digest
26
+ end
27
+
28
+ def bases_current?(labels, expected)
29
+ text = labels['io.empeira.base-inputs']
30
+ return false unless text
31
+
32
+ previous = recorded_bases(text, labels['io.empeira.base-fingerprint'])
33
+ return false unless previous.keys.sort == expected.keys.sort
34
+
35
+ normalized = previous.to_h do |key, base|
36
+ value = normalized_base(base, expected.fetch(key))
37
+ return false unless value
38
+
39
+ [key, value]
40
+ end
41
+ Infrastructure::Definition.fingerprint(normalized) == Infrastructure::Definition.fingerprint(expected)
42
+ end
43
+
44
+ def recorded_bases(text, fingerprint)
45
+ previous = JSON.parse(text)
46
+ unless previous.is_a?(Hash) && Infrastructure::Definition.fingerprint(previous) == fingerprint
47
+ raise Providers::OwnershipError, 'Invalid local build base metadata'
48
+ end
49
+
50
+ previous
51
+ rescue JSON::ParserError
52
+ raise Providers::OwnershipError, 'Unreadable local build base metadata', cause: nil
53
+ end
54
+
55
+ def normalized_base(base, current)
56
+ return unless base.is_a?(Hash) && base['reference'] == current['reference'] && valid_digest?(base['digest'])
57
+
58
+ digest = base['digest']
59
+ digest = remote_digest(Images::Reference.pinned(base['reference'], digest)) if digest != current['digest']
60
+ base.merge('digest' => digest)
61
+ end
62
+
63
+ def build_image(image, recipe, files, refresh: false, bases: nil)
64
+ @local_image_inspections&.delete(image)
65
+ bases ||= base_state(recipe, refresh: refresh)
66
+ Dir.mktmpdir('empeira-image-') do |directory|
67
+ write_build_context(directory, recipe, files)
68
+ labels = build_labels(recipe, files, bases)
69
+ arguments = ['build', *(refresh ? ['--pull'] : []), '--tag', image,
70
+ *labels.flat_map { |key, value| ['--label', "#{key}=#{value}"] },
71
+ *base_arguments(bases),
72
+ '--file', File.join(directory, 'Containerfile'), directory]
73
+ update_command(arguments, operation: 'managed image build', timeout: 600)
74
+ end
75
+ end
76
+
77
+ def build_labels(recipe, files, bases)
78
+ { 'io.empeira.recipe' => Digest::SHA256.hexdigest(recipe),
79
+ 'io.empeira.build-inputs' => Infrastructure::Definition.fingerprint(files),
80
+ 'io.empeira.base-inputs' => JSON.generate(bases),
81
+ 'io.empeira.base-fingerprint' => Infrastructure::Definition.fingerprint(bases) }
82
+ end
83
+
84
+ def base_arguments(bases)
85
+ bases.flat_map do |key, base|
86
+ reference = Images::Reference.pinned(base.fetch('reference'), base.fetch('digest'))
87
+ ['--build-arg', "#{key}=#{reference}"]
88
+ end
89
+ end
90
+
91
+ def write_build_context(directory, recipe, files)
92
+ files.each do |name, content|
93
+ unless name.match?(/\A[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*\z/) && content.is_a?(String)
94
+ raise ConfigurationError, 'Image context must contain named reviewed resource files'
95
+ end
96
+
97
+ File.write(File.join(directory, name), content)
98
+ end
99
+ File.write(File.join(directory, 'Containerfile'), recipe)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ module RegistryDiagnostics
6
+ private
7
+
8
+ def registry_failure_hint(image, diagnostic, timed_out:)
9
+ host = Images::Reference.host(image)
10
+ case Execution::Diagnostics.registry_failure(diagnostic, timed_out: timed_out)
11
+ when :auth
12
+ "Registry authentication or authorization failed for #{host}. " \
13
+ "Authenticate with the selected container runtime, check repository permissions and retry:\n " \
14
+ "#{name} login #{host}"
15
+ when :missing then "Registry image or tag is unavailable: #{image}. Check the repository and tag."
16
+ when :network then "Registry network access failed for #{host}. Check connectivity and DNS."
17
+ when :tls then "Registry TLS verification failed for #{host}. Check the selected runtime's CA trust."
18
+ else 'Check the selected runtime registry access and diagnostic below.'
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ class ServiceArguments
6
+ SCALARS = { 'dns' => '--dns', 'user' => '--user', 'entrypoint' => '--entrypoint',
7
+ 'env_file' => '--env-file', 'shm_size' => '--shm-size', 'mac_address' => '--mac-address',
8
+ 'ip' => '--ip', 'runtime_ip' => '--ip' }.freeze
9
+
10
+ def initialize(definition)
11
+ @definition = definition
12
+ @options = definition.options
13
+ end
14
+
15
+ # One declarative argument vector makes runtime options auditable.
16
+ # rubocop:disable-next Metrics/AbcSize
17
+ def build
18
+ ['create', '--pull=never', '--name', @definition.name,
19
+ '--hostname', @options.fetch('hostname', @definition.key),
20
+ '--network', @options.fetch('network'), '--memory', "#{@options.fetch('memory')}m",
21
+ '--cpus', @options.fetch('cpus', 1).to_s, '--no-healthcheck',
22
+ *init_options, *security_options, *pairs('--label', @definition.labels), *scalar_options,
23
+ *pairs('--env', @options.fetch('environment', {}).merge(@options.fetch('runtime_environment', {}))),
24
+ *pairs('--sysctl', @options.fetch('sysctls', {})),
25
+ *@options.fetch('mounts', []).flat_map { |mount| ['--mount', mount] },
26
+ *@options.fetch('ports', []).flat_map { |port| ['--publish', port] },
27
+ @options.fetch('image'), *@options.fetch('command', [])]
28
+ end
29
+
30
+ private
31
+
32
+ def security_options
33
+ values = { 'cap_drop' => '--cap-drop', 'cap_add' => '--cap-add', 'devices' => '--device',
34
+ 'security_options' => '--security-opt' }
35
+ [*(@options['read_only'] ? ['--read-only'] : []),
36
+ *values.flat_map { |key, flag| @options.fetch(key, []).flat_map { |value| [flag, value] } }]
37
+ end
38
+
39
+ def init_options
40
+ return ['--init'] if @options['init'] == 'process'
41
+ return [] unless @options['init'] == 'systemd'
42
+
43
+ ['--systemd=always', '--cgroupns=private', '--tmpfs', '/run', '--tmpfs', '/tmp']
44
+ end
45
+
46
+ def scalar_options
47
+ SCALARS.flat_map { |key, option| @options[key] ? [option, @options[key]] : [] }
48
+ end
49
+
50
+ def pairs(option, values)
51
+ values.flat_map { |key, value| [option, "#{key}=#{value}"] }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ module ServiceImages
6
+ def with_local_images
7
+ return yield if @local_image_inspections
8
+
9
+ @local_image_inspections = {}
10
+ begin
11
+ yield
12
+ ensure
13
+ @local_image_inspections = nil
14
+ end
15
+ end
16
+
17
+ def ensure_image(image, recipe: nil, files: {})
18
+ result = local_image_inspection(image)
19
+ return verify_recipe!(result, recipe) if result.success?
20
+
21
+ @local_image_inspections&.delete(image)
22
+ return build_image(image, recipe, files) if recipe
23
+
24
+ update_command(['pull', image], operation: "image pull #{image}", registry: image)
25
+ end
26
+
27
+ def refresh_image(image, recipe: nil, files: {}, &progress)
28
+ unless @image_metadata
29
+ return with_image_updates { refresh_image(image, recipe: recipe, files: files, &progress) }
30
+ end
31
+
32
+ observed = local_image(image)
33
+ return refresh_recipe(image, recipe, files, observed, &progress) if recipe
34
+
35
+ digest = remote_digest(image)
36
+ return :unchanged if same_remote_image?(image, observed, digest)
37
+
38
+ progress&.call(:updating)
39
+ @local_image_inspections&.delete(image)
40
+ update_command(['pull', image], operation: "image pull #{image}", registry: image)
41
+ :updated
42
+ end
43
+
44
+ def image_id(image)
45
+ result = required_image_inspection(image, operation: 'image identity')
46
+ data = parse_json(result.stdout)
47
+ unless data.is_a?(Array) && data.size == 1 && data.first.is_a?(Hash) && data.first['Id'].is_a?(String)
48
+ malformed!
49
+ end
50
+ data.first.fetch('Id')
51
+ end
52
+
53
+ def image_architecture(image)
54
+ result = required_image_inspection(image, operation: 'node image architecture')
55
+ data = parse_json(result.stdout)
56
+ malformed! unless data.is_a?(Array) && data.size == 1 && data.first.is_a?(Hash)
57
+ architecture = data.first['Architecture']
58
+ malformed! unless %w[amd64 arm64].include?(architecture)
59
+ architecture
60
+ end
61
+
62
+ private
63
+
64
+ def local_image_inspection(image)
65
+ return @local_image_inspections.fetch(image) if @local_image_inspections&.key?(image)
66
+
67
+ result = runner.run(name, arguments: ['image', 'inspect', image], timeout: 30)
68
+ @local_image_inspections[image] = result if @local_image_inspections
69
+ result
70
+ end
71
+
72
+ def required_image_inspection(image, operation:)
73
+ result = local_image_inspection(image)
74
+ return result if result.success?
75
+
76
+ raise Providers::ExecutionError,
77
+ "#{name} #{operation} failed; run empeira status\n" \
78
+ "#{Execution::Diagnostics.native(result, operation: operation, tool: name)}", cause: nil
79
+ end
80
+
81
+ # rubocop:disable-next Metrics/AbcSize -- Compare all three build inputs before deciding whether to rebuild.
82
+ def refresh_recipe(image, recipe, files, observed)
83
+ labels = observed.to_h.fetch('Config', {}).to_h.fetch('Labels', {}).to_h
84
+ if observed && labels['io.empeira.recipe'] != Digest::SHA256.hexdigest(recipe)
85
+ raise Providers::OwnershipError, 'Local service image does not match the recorded build recipe'
86
+ end
87
+
88
+ bases = base_state(recipe, refresh: true)
89
+ if observed && labels['io.empeira.build-inputs'] == Infrastructure::Definition.fingerprint(files) &&
90
+ bases_current?(labels, bases)
91
+ return :unchanged
92
+ end
93
+
94
+ yield :updating if block_given?
95
+ build_image(image, recipe, files, refresh: true, bases: bases)
96
+ :updated
97
+ end
98
+
99
+ def verify_recipe!(result, recipe)
100
+ return unless recipe
101
+
102
+ return if image_labels(result.stdout)['io.empeira.recipe'] == Digest::SHA256.hexdigest(recipe)
103
+
104
+ raise Providers::OwnershipError, 'Local service image does not match the recorded build recipe'
105
+ end
106
+
107
+ def image_labels(stdout)
108
+ data = parse_json(stdout)
109
+ malformed! unless data.is_a?(Array) && data.size == 1 && data.first.is_a?(Hash)
110
+ config = data.first.fetch('Config', {})
111
+ malformed! unless config.is_a?(Hash)
112
+ labels = config.fetch('Labels', {})
113
+ malformed! unless labels.is_a?(Hash)
114
+ labels
115
+ end
116
+ end
117
+ end
118
+ end