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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Execution
5
+ class LaunchError < ExecutionError
6
+ attr_reader :exit_status
7
+
8
+ def initialize(message, exit_status:)
9
+ @exit_status = exit_status
10
+ super(message)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Execution
5
+ Result = Data.define(:stdout, :stderr, :exit_status, :timed_out) do
6
+ def success?
7
+ !timed_out && exit_status.is_a?(Integer) && exit_status.zero?
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'timeout'
5
+ require_relative 'launch_error'
6
+
7
+ module Empeira
8
+ module Execution
9
+ # rubocop:disable-next Metrics/ClassLength -- Capture, process streams and direct consoles share one execution boundary.
10
+ class Runner
11
+ def initialize(platform: Platform::Facts.new, logger: nil)
12
+ @platform = platform
13
+ @logger = logger
14
+ end
15
+
16
+ def run(executable, arguments: [], environment: {}, directory: nil, timeout: nil, on_stdout: nil)
17
+ validate!(executable, arguments, environment, timeout)
18
+ log(arguments, environment)
19
+ options = { pgroup: true }
20
+ options[:chdir] = directory.to_s if directory
21
+ result = capture(environment, executable, arguments, options, timeout, on_stdout)
22
+ @logger&.debug({ operation: 'execute', exit_status: result.exit_status, timed_out: result.timed_out })
23
+ result
24
+ rescue SystemCallError => e
25
+ # OS exception messages can include command arguments and credentials.
26
+ raise ExecutionError, "Process could not be executed (#{e.class.name})", cause: nil
27
+ end
28
+
29
+ # Inherit caller streams for terminals and unbounded logs; never buffer them.
30
+ # Explicit stream handles avoid hidden globals in process-execution tests.
31
+ # rubocop:disable-next Metrics/ParameterLists
32
+ def stream(executable, arguments: [], environment: {}, directory: nil,
33
+ input: $stdin, output: $stdout, error: $stderr)
34
+ validate!(executable, arguments, environment, nil)
35
+ log(arguments, environment)
36
+ options = { in: input, out: output, err: error }
37
+ options[:chdir] = directory.to_s if directory
38
+ pid = Process.spawn(environment, [executable, executable], *arguments, **options)
39
+ _, status = Process.wait2(pid)
40
+ pid = nil
41
+ Result.new(stdout: '', stderr: '', exit_status: status.exitstatus || (128 + status.termsig), timed_out: false)
42
+ rescue SystemCallError => e
43
+ raise ExecutionError, "Streaming process could not execute (#{e.class.name})", cause: nil
44
+ ensure
45
+ reap_stream(pid) if pid
46
+ end
47
+
48
+ def console(path, input: $stdin, output: $stdout, guidance: nil)
49
+ Console.new.attach(path, input: input, output: output, guidance: guidance)
50
+ end
51
+
52
+ # A terminal-preserving handoff: signals, process identity and status belong
53
+ # directly to the standalone program, with no forwarding proxy or shell.
54
+ def replace(executable, arguments: [], environment: {})
55
+ validate!(executable, arguments, environment, nil)
56
+ explicit_format!(executable)
57
+ log(arguments, environment)
58
+ Kernel.exec(environment, [executable, executable], *arguments)
59
+ rescue Errno::ENOENT
60
+ raise LaunchError.new('executable or its interpreter was not found', exit_status: 127), cause: nil
61
+ rescue Errno::E2BIG
62
+ raise LaunchError.new('environment and arguments exceed the operating system size limit', exit_status: 126),
63
+ cause: nil
64
+ rescue SystemCallError
65
+ raise LaunchError.new('executable could not be started', exit_status: 126), cause: nil
66
+ end
67
+
68
+ # Inherited output streams immediately; closing control cancels the owned guest session.
69
+ def controlled_stream(executable, arguments:, input:, control:, output: $stdout, error: $stderr)
70
+ validate!(executable, arguments, {}, nil)
71
+ log(arguments, {})
72
+ pid = Process.spawn([executable, executable], *arguments, in: input, out: output, err: error, pgroup: true)
73
+ _, status = Process.wait2(pid)
74
+ pid = nil
75
+ Result.new(stdout: '', stderr: '', exit_status: status.exitstatus || (128 + status.termsig), timed_out: false)
76
+ rescue SystemCallError => e
77
+ raise ExecutionError, "Cannot start diagnostic transport (#{e.class.name})", cause: nil
78
+ ensure
79
+ control.close unless control.closed?
80
+ reap_controlled_stream(pid) if pid
81
+ end
82
+
83
+ def debug(message)
84
+ @logger&.debug(message)
85
+ end
86
+
87
+ # Keep the caller's input and foreground terminal for authentication, but capture tool output.
88
+ # rubocop:disable-next Metrics/AbcSize -- Paired pipes and reader cleanup bracket one foreground process.
89
+ def buffered(executable, arguments: [], environment: {}, directory: nil, input: $stdin)
90
+ IO.pipe do |stdout, output|
91
+ IO.pipe do |stderr, error|
92
+ readers = [stdout, stderr].map { |io| Thread.new { io.binmode.read } }
93
+ result = stream(executable, arguments: arguments, environment: environment, directory: directory,
94
+ input: input, output: output, error: error)
95
+ output.close
96
+ error.close
97
+ Result.new(stdout: readers[0].value, stderr: readers[1].value,
98
+ exit_status: result.exit_status, timed_out: result.timed_out)
99
+ ensure
100
+ readers&.each { |thread| thread.kill.join }
101
+ end
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ # Ruby otherwise silently interprets plain executable text with /bin/sh.
108
+ # Native ELF/Mach-O binaries and scripts with an explicit interpreter are
109
+ # the executable formats supported by Linux, WSL2 and macOS.
110
+ def explicit_format!(executable)
111
+ header = File.binread(executable, 4)
112
+ native = ["\x7fELF".b, "\xfe\xed\xfa\xce".b, "\xce\xfa\xed\xfe".b,
113
+ "\xfe\xed\xfa\xcf".b, "\xcf\xfa\xed\xfe".b, "\xca\xfe\xba\xbe".b,
114
+ "\xbe\xba\xfe\xca".b, "\xca\xfe\xba\xbf".b, "\xbf\xba\xfe\xca".b]
115
+ return if header.start_with?('#!') || native.include?(header)
116
+
117
+ raise LaunchError.new('entrypoint must be a native executable or a script with a shebang', exit_status: 126)
118
+ end
119
+
120
+ def reap_controlled_stream(pid)
121
+ Timeout.timeout(4) { Process.wait(pid) }
122
+ rescue Timeout::Error
123
+ terminate(pid)
124
+ Process.wait(pid)
125
+ rescue Errno::ECHILD
126
+ nil
127
+ end
128
+
129
+ def reap_stream(pid)
130
+ Process.kill('TERM', pid)
131
+ Timeout.timeout(2) { Process.wait(pid) }
132
+ rescue Timeout::Error
133
+ Process.kill('KILL', pid)
134
+ Process.wait(pid)
135
+ rescue Errno::ESRCH, Errno::ECHILD
136
+ nil
137
+ end
138
+
139
+ def validate!(executable, arguments, environment, timeout)
140
+ unless command_string?(executable) && !executable.empty? &&
141
+ arguments.is_a?(Array) && arguments.all? { |arg| command_string?(arg) }
142
+ raise ExecutionError, 'Executable and arguments must be strings without null bytes'
143
+ end
144
+
145
+ validate_environment!(environment)
146
+ validate_timeout!(timeout)
147
+ end
148
+
149
+ def command_string?(value)
150
+ value.is_a?(String) && !value.include?("\0")
151
+ end
152
+
153
+ def validate_environment!(environment)
154
+ return if environment.is_a?(Hash) && environment.all? { |key, value| environment_entry?(key, value) }
155
+
156
+ raise ExecutionError, 'Environment must map valid variable names to strings or null'
157
+ end
158
+
159
+ def environment_entry?(key, value)
160
+ command_string?(key) && !key.empty? && !key.include?('=') && (value.nil? || command_string?(value))
161
+ end
162
+
163
+ def validate_timeout!(timeout)
164
+ return if timeout.nil? || (timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?)
165
+
166
+ raise ExecutionError, 'Timeout must be a positive finite number'
167
+ end
168
+
169
+ def log(arguments, environment)
170
+ @logger&.debug({ operation: 'execute', arguments: arguments.map { '[REDACTED]' },
171
+ environment: environment.transform_values { '[REDACTED]' } })
172
+ end
173
+
174
+ def capture(environment, executable, arguments, options, timeout, on_stdout)
175
+ # The two-element executable form prevents Ruby's single-string shell fallback.
176
+ Open3.popen3(environment, [executable, executable], *arguments, **options) do |stdin, stdout, stderr, process|
177
+ completed = false
178
+ stdin.close
179
+ streams = [Thread.new { read_output(stdout, on_stdout) }, Thread.new { stderr.binmode.read }]
180
+ result = collect(process, streams, timeout)
181
+ completed = true
182
+ result
183
+ ensure
184
+ cleanup(process, streams, completed)
185
+ end
186
+ end
187
+
188
+ def read_output(io, observer)
189
+ return io.binmode.read unless observer
190
+
191
+ buffer = +''.b
192
+ loop do
193
+ chunk = io.readpartial(16_384)
194
+ buffer << chunk
195
+ observer.call(chunk)
196
+ end
197
+ rescue EOFError
198
+ buffer
199
+ end
200
+
201
+ def collect(process, streams, timeout)
202
+ Timeout.timeout(timeout) do
203
+ status = process.value
204
+ Result.new(stdout: streams[0].value.freeze, stderr: streams[1].value.freeze,
205
+ exit_status: status.exitstatus, timed_out: false)
206
+ end
207
+ rescue Timeout::Error
208
+ timed_out(process, streams)
209
+ end
210
+
211
+ def timed_out(process, streams)
212
+ terminate(process.pid)
213
+ process.join
214
+ output = streams.map { |stream| stream.join(1) ? stream.value : '' }
215
+ Result.new(stdout: output[0], stderr: output[1], exit_status: nil, timed_out: true)
216
+ end
217
+
218
+ def cleanup(process, streams, completed)
219
+ unless completed
220
+ # A reaped group leader does not mean its descendants have exited.
221
+ terminate(process.pid)
222
+ process.join
223
+ end
224
+ ensure
225
+ streams&.each { |thread| thread.kill.join }
226
+ end
227
+
228
+ def terminate(pid)
229
+ Process.kill('KILL', -pid)
230
+ rescue Errno::ESRCH
231
+ nil
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Execution
5
+ module Terminal
6
+ CLEAN_EXIT_SIGNALS = %w[QUIT TSTP].map { |signal| Signal.list.fetch(signal) }.freeze
7
+
8
+ # rubocop:disable-next Metrics/AbcSize -- Signal status and restored handlers share the session cleanup boundary.
9
+ def self.with_interrupts
10
+ return yield unless Thread.current == Thread.main
11
+
12
+ previous = %w[TERM HUP QUIT TSTP].to_h do |signal|
13
+ [signal, Signal.trap(signal) { raise SignalException, signal }]
14
+ end
15
+ yield
16
+ rescue SignalException => e
17
+ raise unless CLEAN_EXIT_SIGNALS.include?(e.signo)
18
+
19
+ # Preserve the signal status after cleanup without a core dump or a suspended raw terminal.
20
+ raise SystemExit, 128 + e.signo
21
+ ensure
22
+ previous&.each { |signal, handler| Signal.trap(signal, handler) }
23
+ end
24
+
25
+ def self.interactive?(output: $stdout, error: $stderr, environment: ENV)
26
+ [output, error].all? { |io| io.respond_to?(:tty?) && io.tty? } &&
27
+ environment['TERM'].to_s.downcase != 'dumb' && environment['CI'].to_s.empty?
28
+ end
29
+
30
+ def self.color?(**options)
31
+ interactive?(**options) && options.fetch(:environment, ENV)['NO_COLOR'].to_s.empty?
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ # Full-hostname glob semantics shared by independent hostname policies.
5
+ module HostnamePattern
6
+ def self.valid?(value)
7
+ value.is_a?(String) && value.size.between?(1, 253) && value.match?(/\A[a-z0-9*?][a-z0-9.*?-]*\z/i)
8
+ end
9
+
10
+ def self.matches?(pattern, hostname)
11
+ File.fnmatch?(pattern.downcase, hostname.downcase)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Images
5
+ module Configuration
6
+ REPOSITORY = %r{\A[a-z0-9]+(?:[._:/-][a-z0-9]+)*\z}
7
+ TAG = /\A\w[\w.-]{0,127}\z/
8
+ DIGEST = /\Asha256:[a-f0-9]{64}\z/
9
+ ROOT = File.expand_path('../../../resources', __dir__)
10
+
11
+ def self.reference(data, registry: nil)
12
+ return data.fetch('reference') if data['reference']
13
+
14
+ repository = Reference.repository(data.fetch('repository'), registry: registry)
15
+ return "#{repository}@#{data['digest']}" if data['digest']
16
+
17
+ tag = data.fetch('tag')
18
+ "#{repository}:#{tag}"
19
+ end
20
+
21
+ def self.recipe(data, registry: nil)
22
+ relative = data.fetch('build')
23
+ path = Pathname.new(ROOT).join(relative).realpath
24
+ unless path.to_s.start_with?("#{ROOT}/")
25
+ raise ConfigurationError,
26
+ 'images build resource must be packaged with Empeira'
27
+ end
28
+
29
+ Recipe.render(File.read(path), registry: registry)
30
+ rescue SystemCallError
31
+ raise ConfigurationError, 'images build resource is unavailable', cause: nil
32
+ end
33
+
34
+ def self.artifact(data, purpose: 'squid', registry: nil)
35
+ recipe = self.recipe(data, registry: registry) if data['build'] && !data['reference']
36
+ { image: recipe ? local_image(recipe, purpose: purpose) : reference(data, registry: registry), recipe: recipe }
37
+ end
38
+
39
+ def self.local_image(recipe, purpose: 'squid')
40
+ "localhost/empeira-#{purpose}:#{Digest::SHA256.hexdigest(recipe)}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Empeira
7
+ module Images
8
+ class Identity
9
+ attr_reader :cache_key, :distribution, :version, :architecture, :source, :revision, :checksum
10
+
11
+ def to_h
12
+ { 'distribution' => distribution, 'version' => version, 'architecture' => architecture,
13
+ 'source' => source, 'revision' => revision, 'checksum' => checksum }
14
+ end
15
+
16
+ # rubocop:disable-next Metrics/AbcSize -- All fields participate in the immutable identity.
17
+ def initialize(distribution:, version:, architecture:, source:, revision:, checksum:)
18
+ fields = [distribution, version, architecture, source, revision, checksum]
19
+ unless fields.all? { |value| value.is_a?(String) && !value.strip.empty? }
20
+ raise Error, 'Image identity fields must be non-empty strings'
21
+ end
22
+ raise Error, 'Image checksum must be a SHA-256 hex digest' unless checksum.match?(/\A[0-9a-f]{64}\z/i)
23
+
24
+ @distribution, @version, @architecture, @source, @revision, @checksum = fields
25
+ @cache_key = Digest::SHA256.hexdigest(JSON.generate(fields[0...-1] + [checksum.downcase])).freeze
26
+ freeze
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Images
5
+ # Only reviewed EMPEIRA_BASE_* ARG declarations are dependencies. This is not a Dockerfile parser.
6
+ module Recipe
7
+ BASE = /^ARG (EMPEIRA_BASE_[A-Z0-9_]+)=([^\s]+)$/
8
+
9
+ def self.render(source, registry: nil)
10
+ source.gsub(BASE) do
11
+ name, value = Regexp.last_match.captures
12
+ value = Reference.base(value, registry: registry) unless value.start_with?('@') || value == 'scratch'
13
+ "ARG #{name}=#{value}"
14
+ end
15
+ end
16
+
17
+ def self.bases(recipe)
18
+ recipe.scan(BASE).to_h.reject { |_, value| value == 'scratch' }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Images
5
+ # Registry selection applies to repository fields, never exact reference overrides.
6
+ module Reference
7
+ HOST = /\A(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?::[0-9]+)?\z/
8
+
9
+ def self.hosted?(repository)
10
+ first = repository.split('/').first
11
+ first.include?('.') || first.include?(':') || first == 'localhost'
12
+ end
13
+
14
+ def self.registry?(value)
15
+ return true if value.nil?
16
+ return false unless value.is_a?(String) && value.match?(HOST) && hosted?(value)
17
+
18
+ port = value.split(':', 2)[1]
19
+ port.nil? || (1..65_535).cover?(port.to_i)
20
+ end
21
+
22
+ def self.repository(value, registry: nil)
23
+ hosted?(value) ? value : "#{registry || 'docker.io'}/#{value}"
24
+ end
25
+
26
+ # Reserved recipe base declarations contain a tag or digest, not a YAML reference override.
27
+ def self.base(value, registry: nil)
28
+ path = value.split('@', 2).first.sub(%r{:[^/:]+\z}, '')
29
+ repository(path, registry: registry) + value.delete_prefix(path)
30
+ end
31
+
32
+ def self.pinned(reference, digest)
33
+ repository = reference.split('@').first.sub(%r{:[^/:]+\z}, '')
34
+ "#{repository}@#{digest}"
35
+ end
36
+
37
+ def self.host(reference)
38
+ first, path = reference.split('/', 2)
39
+ path && registry?(first) ? first : 'docker.io'
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Lint/UnusedMethodArgument -- Preserve explicit adapter keyword contracts.
4
+
5
+ module Empeira
6
+ module Images
7
+ class Source
8
+ def resolve(identity:)
9
+ raise UnavailableFeature, 'This command is not implemented yet.'
10
+ end
11
+
12
+ def fetch(identity:)
13
+ raise UnavailableFeature, 'This command is not implemented yet.'
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ # rubocop:enable Lint/UnusedMethodArgument
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Immutable
5
+ module_function
6
+
7
+ def deep_freeze(value)
8
+ case value
9
+ when Hash
10
+ value.each do |key, child|
11
+ deep_freeze(key)
12
+ deep_freeze(child)
13
+ end
14
+ when Array then value.each { |child| deep_freeze(child) }
15
+ end
16
+ value.freeze
17
+ end
18
+ end
19
+ end