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,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ class Progress
5
+ Event = Data.define(:percent, :message, :state)
6
+
7
+ def initialize(listener: nil)
8
+ @listener = listener
9
+ @percent = 0
10
+ @message = ''
11
+ end
12
+
13
+ def run(message)
14
+ @active = true
15
+ @percent = 0
16
+ emit(message, :active)
17
+ result = yield
18
+ @percent = 100
19
+ emit('Completed.', :complete)
20
+ result
21
+ rescue StandardError, Interrupt
22
+ emit("Failed: #{@message}", :failed)
23
+ raise
24
+ ensure
25
+ @active = false
26
+ end
27
+
28
+ def stage(percent, message)
29
+ return unless @active
30
+
31
+ raise ArgumentError, 'Progress stages must increase and remain below 100' unless percent.between?(@percent, 99)
32
+
33
+ @percent = percent
34
+ emit(message, :active)
35
+ end
36
+
37
+ def heartbeat(message = @message)
38
+ emit(message, :active) if @active && !@streaming
39
+ end
40
+
41
+ def count(completed, total, message)
42
+ return unless @active
43
+ raise ArgumentError, 'Invalid progress counts' unless completed.between?(0, total)
44
+
45
+ @percent = completed == total ? 100 : [(100.0 * completed / total).round, 99].min
46
+ emit(message, :active)
47
+ end
48
+
49
+ def warning(message)
50
+ @listener&.call(Event.new(percent: @percent, message: message, state: :warning))
51
+ end
52
+
53
+ def streaming
54
+ @streaming = true
55
+ emit(@message, :suspended) if @active
56
+ result = yield
57
+ emit(@message, :resumed) if @active
58
+ result
59
+ ensure
60
+ @streaming = false
61
+ end
62
+
63
+ private
64
+
65
+ def emit(message, state)
66
+ @message = message
67
+ @listener&.call(Event.new(percent: @percent, message: message, state: state))
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'shellwords'
5
+
6
+ module Empeira
7
+ class ProjectRoot
8
+ attr_reader :path
9
+
10
+ def self.resolve(value, runner: nil, recovery: false)
11
+ value.is_a?(self) ? value : new(value, runner: runner || Execution::Runner.new, recovery: recovery)
12
+ end
13
+
14
+ def initialize(path, runner: Execution::Runner.new, recovery: false)
15
+ directory = Pathname(path).realpath
16
+ raise ConfigurationError, 'project path must be an existing directory' unless directory.directory?
17
+
18
+ @path = git_root(directory, runner).freeze
19
+ require_marker! unless recovery
20
+ freeze
21
+ rescue ExecutionError
22
+ raise ConfigurationError, 'An Empeira Git project is required; ensure Git is installed and accessible.',
23
+ cause: nil
24
+ rescue SystemCallError
25
+ raise ConfigurationError, 'project path must be an existing directory accessible to the current user', cause: nil
26
+ end
27
+
28
+ private
29
+
30
+ def git_root(directory, runner)
31
+ result = runner.run('git', arguments: %w[rev-parse --show-toplevel], directory: directory, timeout: 10)
32
+ unless result.success? && !result.stdout.strip.empty?
33
+ raise ConfigurationError, 'An Empeira Git project is required. Enter a Git repository with .empeira.yaml.'
34
+ end
35
+
36
+ Pathname(result.stdout.chomp).realpath
37
+ end
38
+
39
+ def require_marker!
40
+ marker = @path.join('.empeira.yaml')
41
+ return if marker.file?
42
+
43
+ raise ConfigurationError, "Empeira project #{@path} is missing marker #{marker}. " \
44
+ "Opt in with: touch #{Shellwords.escape(marker.to_s)}"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Lint/UnusedMethodArgument -- Preserve explicit adapter keyword contracts.
4
+
5
+ module Empeira
6
+ module Providers
7
+ # Adapters implement the protected operations. Public semantics stay shared.
8
+ class Lifecycle
9
+ attr_reader :name, :context, :runner
10
+
11
+ def initialize(name:, context:, runner:)
12
+ @name = name.dup.freeze
13
+ @context = context
14
+ @runner = runner
15
+ end
16
+
17
+ def available?
18
+ false
19
+ end
20
+
21
+ def inspect_resource(name:)
22
+ resource = lookup(name: name)
23
+ return if resource.nil?
24
+
25
+ verify_resource!(resource, name)
26
+ end
27
+
28
+ def run(name:, request:)
29
+ raise AlreadyExists, 'Resource already exists' if inspect_resource(name: name)
30
+
31
+ resource = verify_resource!(create(name: name, request: request), name)
32
+ raise ExecutionError, 'Provider did not start the resource' unless resource.state == :running
33
+
34
+ Result.new(resource: resource, changed: true)
35
+ end
36
+
37
+ def start(name:)
38
+ change_state(name, :running)
39
+ end
40
+
41
+ def stop(name:)
42
+ change_state(name, :stopped)
43
+ end
44
+
45
+ def destroy(name:)
46
+ resource = inspect_resource(name: name)
47
+ return Result.new(resource: nil, changed: false) unless resource
48
+
49
+ remove(resource: resource)
50
+ Result.new(resource: nil, changed: true)
51
+ end
52
+
53
+ protected
54
+
55
+ def lookup(name:)
56
+ raise UnavailableFeature, 'This command is not implemented yet.'
57
+ end
58
+
59
+ def create(name:, request:)
60
+ raise UnavailableFeature, 'This command is not implemented yet.'
61
+ end
62
+
63
+ def transition(resource:, state:)
64
+ raise UnavailableFeature, 'This command is not implemented yet.'
65
+ end
66
+
67
+ def remove(resource:)
68
+ raise UnavailableFeature, 'This command is not implemented yet.'
69
+ end
70
+
71
+ private
72
+
73
+ def verify_resource!(resource, expected_name)
74
+ unless resource.is_a?(Resource) && resource.name == expected_name
75
+ raise ExecutionError, 'Provider returned an invalid resource'
76
+ end
77
+ raise OwnershipError, 'Resource belongs to another workspace' unless resource.owner == context.workspace.id
78
+
79
+ resource
80
+ end
81
+
82
+ def change_state(name, state)
83
+ resource = inspect_resource(name: name)
84
+ raise NotFound, 'Resource does not exist' unless resource
85
+ return Result.new(resource: resource, changed: false) if resource.state == state
86
+
87
+ updated = verify_resource!(transition(resource: resource, state: state), name)
88
+ raise ExecutionError, 'Provider did not reach the requested state' unless updated.state == state
89
+
90
+ Result.new(resource: updated, changed: true)
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ # rubocop:enable Lint/UnusedMethodArgument
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Providers
5
+ class Registry
6
+ def initialize(factories)
7
+ @factories = factories.transform_keys(&:to_s).transform_values(&:freeze).freeze
8
+ @names = @factories.keys.freeze
9
+ end
10
+
11
+ attr_reader :names
12
+
13
+ def build(name, **dependencies)
14
+ factory = @factories.fetch(name.to_s) do
15
+ raise Error, "Unknown provider. Choose one of: #{names.join(', ')}"
16
+ end
17
+ factory.call(**dependencies)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Providers
5
+ class NotFound < Error; end
6
+ class AlreadyExists < Error; end
7
+ class OwnershipError < Error; end
8
+ class ExecutionError < Error; end
9
+
10
+ Resource = Data.define(:name, :owner, :state) do
11
+ def initialize(name:, owner:, state:)
12
+ unless [name, owner].all? { |value| value.is_a?(String) && !value.empty? }
13
+ raise Error, 'Resource name and owner must be non-empty strings'
14
+ end
15
+ raise Error, 'Resource state must be running or stopped' unless %i[running stopped].include?(state)
16
+
17
+ super(name: name.dup.freeze, owner: owner.dup.freeze, state: state)
18
+ end
19
+ end
20
+
21
+ Result = Data.define(:resource, :changed) do
22
+ def initialize(resource:, changed:)
23
+ unless resource.nil? || resource.is_a?(Resource) || resource.is_a?(Network::Resource)
24
+ raise Error, 'Result resource must be a resource or null'
25
+ end
26
+ raise Error, 'Result changed must be boolean' unless [true, false].include?(changed)
27
+
28
+ super
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Lint/UnusedBlockArgument -- Factories share explicit composition dependencies.
4
+
5
+ module Empeira
6
+ module Runtime
7
+ CATALOG = Providers::Registry.new(
8
+ 'podman' => ->(**dependencies) { Podman.new(**dependencies) },
9
+ 'docker' => ->(**dependencies) { Docker.new(**dependencies) }
10
+ ).freeze
11
+
12
+ def self.registry
13
+ CATALOG
14
+ end
15
+ end
16
+
17
+ module VM
18
+ CATALOG = Providers::Registry.new(
19
+ 'qemu' => ->(**dependencies) { Qemu.new(**dependencies) }
20
+ ).freeze
21
+
22
+ def self.registry
23
+ CATALOG
24
+ end
25
+ end
26
+
27
+ module Node
28
+ CATALOG = Providers::Registry.new(
29
+ 'container' => lambda { |context:, runner:, runtimes:, engines:, build_info:, progress:|
30
+ runtime = runtimes.build(context.container_engine, context: context, runner: runner)
31
+ Container.new(context: context, runner: runner, backend: runtime, build_info: build_info, progress: progress)
32
+ },
33
+ 'vm' => lambda { |context:, runner:, runtimes:, engines:, build_info:, progress:|
34
+ engine = engines.build('qemu', context: context, runner: runner)
35
+ runtime = runtimes.build(context.container_engine, context: context, runner: runner)
36
+ VM.new(context: context, runner: runner, backend: engine, runtime: runtime,
37
+ build_info: build_info, progress: progress)
38
+ }
39
+ ).freeze
40
+
41
+ def self.registry
42
+ CATALOG
43
+ end
44
+ end
45
+
46
+ module Images
47
+ CATALOG = Providers::Registry.new('upstream' => -> { Source.new }, 'custom' => -> { Source.new }).freeze
48
+
49
+ def self.registry
50
+ CATALOG
51
+ end
52
+ end
53
+ end
54
+
55
+ # rubocop:enable Lint/UnusedBlockArgument
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ # Reuses helper ownership and observed default-network verification, without host bind mounts.
6
+ module AgentHelper
7
+ def with_agent_helper(image:)
8
+ identity = Empeira::Services::Definition.new(key: "agent-#{SecureRandom.hex(16)}", workspace: context.workspace)
9
+ update_command(agent_helper_arguments(identity, image), operation: 'agent helper creation')
10
+ resource = inspect_service(identity)
11
+ raise Providers::ExecutionError, 'Agent helper creation could not be verified' unless resource
12
+
13
+ update_command(['start', resource.fetch('id')], operation: 'agent helper start')
14
+ verify_helper_network!(identity, resource.fetch('id'))
15
+ yield resource
16
+ ensure
17
+ remove_update_helper(identity, resource&.fetch('id')) if identity
18
+ end
19
+
20
+ private
21
+
22
+ def agent_helper_arguments(identity, image)
23
+ ['create', '--name', identity.name, '--network', update_helper_network,
24
+ '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
25
+ *%w[CHOWN DAC_OVERRIDE FOWNER SETUID SETGID].flat_map { |capability| ['--cap-add', capability] },
26
+ *label_arguments(identity),
27
+ *UpdateHelper::PROXY_VARIABLES.select { |key| ENV.key?(key) }.flat_map { |key| ['--env', key] },
28
+ '--entrypoint', 'sleep', image, 'infinity']
29
+ end
30
+ end
31
+
32
+ Container.include(AgentHelper)
33
+ end
34
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Empeira
6
+ module Runtime
7
+ class Unavailable < Providers::ExecutionError; end
8
+ class UnsupportedCapability < Providers::ExecutionError; end
9
+
10
+ # Keep the common network lifecycle and its failure semantics in one adapter boundary.
11
+ # rubocop:disable-next Metrics/ClassLength
12
+ class Container < Interface
13
+ def available?
14
+ check_available!
15
+ true
16
+ rescue Unavailable, UnsupportedCapability
17
+ false
18
+ end
19
+
20
+ def check_available!
21
+ @engine_info = json_command(info_arguments, operation: 'engine detection')
22
+ validate_info!(@engine_info)
23
+ true
24
+ rescue Providers::ExecutionError => e
25
+ raise if e.is_a?(UnsupportedCapability)
26
+
27
+ raise Unavailable, "#{name} is unavailable. Install its CLI and start/reach its engine. #{e.message}",
28
+ cause: nil
29
+ end
30
+
31
+ def architecture
32
+ check_available! unless @engine_info
33
+ cpu = @engine_info['Architecture'] || @engine_info.dig('host', 'arch')
34
+ Platform::Facts.new(host_os: 'linux', host_cpu: cpu.to_s).architecture.to_s
35
+ end
36
+
37
+ def require_systemd!
38
+ check_available! unless @engine_info
39
+ return if name == 'podman' && @engine_info.dig('host', 'cgroupVersion') == 'v2'
40
+
41
+ raise UnsupportedCapability, 'systemd nodes require Podman with cgroup v2; select node_defaults.init=process'
42
+ end
43
+
44
+ def node_ssh_publication?
45
+ false
46
+ end
47
+
48
+ def capabilities
49
+ { isolated: true, controlled_egress: false }
50
+ end
51
+
52
+ def inspect_network(identifier:)
53
+ entry = network_entries.find { |item| [item.fetch('id'), item.fetch('name')].include?(identifier) }
54
+ return unless entry
55
+
56
+ inspect_entry(entry)
57
+ end
58
+
59
+ # rubocop:disable-next Naming/PredicateMethod -- Verification raises on failure.
60
+ def verify_isolated_network(definition, expected_id:)
61
+ data = json_command(['network', 'inspect', definition.backend_name],
62
+ operation: 'node network verification')
63
+ malformed! unless data.is_a?(Array) && data.size == 1 && data.first.is_a?(Hash)
64
+ binding = normalize_network_binding(data.first)
65
+ definition.verify_ownership!(binding, expected_id: expected_id)
66
+ definition.verify_definition!(binding)
67
+ definition.verify_isolation!(binding)
68
+ true
69
+ end
70
+
71
+ def create_network(definition:)
72
+ definition.policy.require_support!(**capabilities)
73
+ existing = inspect_network(identifier: definition.backend_name)
74
+ if existing
75
+ definition.verify_ownership!(existing)
76
+ definition.verify_definition!(existing)
77
+ definition.verify_isolation!(existing)
78
+ return Providers::Result.new(resource: existing, changed: false)
79
+ end
80
+
81
+ mutate_network(definition, 'create', create_arguments(definition))
82
+ resource = inspect_network(identifier: definition.backend_name)
83
+ definition.verify_ownership!(resource)
84
+ definition.verify_isolation!(resource)
85
+ Providers::Result.new(resource: resource, changed: true)
86
+ end
87
+
88
+ def remove_network(definition:, expected_id: nil)
89
+ resource = inspect_network(identifier: definition.backend_name)
90
+ definition.verify_ownership!(resource, expected_id: expected_id)
91
+ return Providers::Result.new(resource: nil, changed: false) unless resource
92
+
93
+ raise Providers::ExecutionError, "#{name} network still has attached resources; detach them before down" unless
94
+ resource.attachment_count.zero?
95
+
96
+ mutate_network(definition, 'remove', ['network', 'rm', resource.id])
97
+ if inspect_network(identifier: definition.backend_name)
98
+ raise Providers::ExecutionError, "#{name} network removal could not be verified"
99
+ end
100
+
101
+ Providers::Result.new(resource: nil, changed: true)
102
+ end
103
+
104
+ def network_subnets
105
+ network_entries.flat_map do |entry|
106
+ data = json_command(['network', 'inspect', entry.fetch('id')], operation: 'subnet inventory').first
107
+ subnets = data['subnets'] || data.dig('IPAM', 'Config') || []
108
+ subnets.filter_map { |item| item['subnet'] || item['Subnet'] }
109
+ end
110
+ end
111
+
112
+ def network_details(identifier)
113
+ json_command(['network', 'inspect', identifier], operation: 'peer network inspection').first
114
+ end
115
+
116
+ protected
117
+
118
+ def command(arguments, operation:)
119
+ result = runner.run(name, arguments: arguments, timeout: 30)
120
+ return result if result.success?
121
+
122
+ outcome = result.timed_out ? 'timed out' : "exit #{result.exit_status.inspect}"
123
+ raise Providers::ExecutionError,
124
+ "#{name} #{operation} failed (#{outcome}) for workspace #{context.workspace.id}"
125
+ rescue Empeira::ExecutionError
126
+ raise Providers::ExecutionError,
127
+ "#{name} #{operation} could not execute. Check CLI installation and permissions.",
128
+ cause: nil
129
+ end
130
+
131
+ def json_command(arguments, operation:)
132
+ parse_json(command(arguments, operation: operation).stdout)
133
+ end
134
+
135
+ def parse_json(output)
136
+ JSON.parse(output)
137
+ rescue JSON::ParserError
138
+ malformed!
139
+ end
140
+
141
+ def malformed!
142
+ raise Providers::ExecutionError, "#{name} returned malformed network/engine data", cause: nil
143
+ end
144
+
145
+ def label_arguments(definition)
146
+ definition.labels.flat_map { |key, value| ['--label', "#{key}=#{value}"] }
147
+ end
148
+
149
+ def validate_entries(entries)
150
+ unless entries.is_a?(Array) && entries.all? do |item|
151
+ item.is_a?(Hash) && %w[id name].all? do |key|
152
+ item[key].is_a?(String) && item[key].match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z/)
153
+ end
154
+ end
155
+ malformed!
156
+ end
157
+ entries
158
+ end
159
+
160
+ private
161
+
162
+ def inspect_entry(entry)
163
+ data = json_command(['network', 'inspect', entry.fetch('id')], operation: 'network inspection')
164
+ malformed! unless data.is_a?(Array) && data.size == 1 && data.first.is_a?(Hash)
165
+ resource = normalize_network(data.first)
166
+ malformed! unless resource.id == entry.fetch('id') && resource.name == entry.fetch('name')
167
+ resource
168
+ end
169
+
170
+ def mutate_network(definition, operation, arguments)
171
+ command(arguments, operation: "network #{operation}")
172
+ rescue Providers::ExecutionError, Interrupt => e
173
+ # A failed command may have committed. Inspect once, never blindly retry.
174
+ observed = inspect_network(identifier: definition.backend_name)
175
+ definition.verify_ownership!(observed)
176
+ raise e if e.is_a?(Interrupt)
177
+
178
+ outcome = observed ? 'present' : 'absent'
179
+ raise Providers::ExecutionError, "#{e.message}. Network observed #{outcome}; run status before retrying.",
180
+ cause: nil
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ module DiagnosticExecution
6
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- Stream, cancellation receipt and pipe closure share one transaction.
7
+ def diagnostic_exec(resource, arguments, timeout: nil, output: $stdout, error: $stderr)
8
+ token = SecureRandom.hex(16)
9
+ code = Pathname(__dir__).join('../../../resources/nodes/diagnostic_exec.rb').read
10
+ IO.pipe do |input, control|
11
+ native = runner.controlled_stream(name, arguments: ['exec', '--interactive', '--user', '0',
12
+ resource.fetch('id'), ControlPlane::Health::RUBY,
13
+ '-e', code, '--', 'run', token,
14
+ timeout ? timeout.to_s : 'none', *arguments],
15
+ input: input, control: control, output: output, error: error)
16
+ diagnostic_completion(resource, code, token, native)
17
+ rescue SignalException
18
+ native = Execution::Result.new(stdout: '', stderr: '', exit_status: 130, timed_out: false)
19
+ diagnostic_completion(resource, code, token, native)
20
+ raise
21
+ ensure
22
+ control.close unless control.closed?
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def valid_diagnostic_completion?(data)
29
+ data.is_a?(Hash) && [true, false].include?(data['timed_out']) &&
30
+ ((data['status'].is_a?(Integer) && data['status'].between?(0, 255)) ||
31
+ (data['status'].nil? && data['timed_out']))
32
+ end
33
+
34
+ def diagnostic_completion(resource, code, token, native)
35
+ result = runner.run(name, arguments: ['exec', '--user', '0', resource.fetch('id'),
36
+ ControlPlane::Health::RUBY, '-e', code, '--', 'read', token], timeout: 10)
37
+ data = JSON.parse(result.stdout) if result.success?
38
+ if valid_diagnostic_completion?(data)
39
+ return Execution::Result.new(stdout: '', stderr: '', exit_status: data['status'],
40
+ timed_out: data['timed_out'])
41
+ end
42
+
43
+ details = Execution::Diagnostics.command(native, operation: 'Container diagnostic transport', tool: name)
44
+ raise Error, "Container command completion or cancellation is unconfirmed; node retained\n#{details}"
45
+ rescue JSON::ParserError
46
+ raise Error, 'Container command completion is malformed; node retained', cause: nil
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ Empeira::Runtime::Container.include(Empeira::Runtime::DiagnosticExecution)
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ class Docker < Container
6
+ def initialize(context:, runner:)
7
+ super(name: 'docker', context: context, runner: runner)
8
+ end
9
+
10
+ protected
11
+
12
+ def remote_descriptors(image)
13
+ result = metadata_command(['manifest', 'inspect', '--verbose', image], image: image)
14
+ data = parse_json(result.stdout)
15
+ entries = data.is_a?(Array) ? data : [data]
16
+ entries.map { |entry| entry.is_a?(Hash) ? entry['Descriptor'] : nil }
17
+ end
18
+
19
+ def info_arguments
20
+ ['info', '--format', '{{json .}}']
21
+ end
22
+
23
+ def validate_info!(info)
24
+ malformed! unless info.is_a?(Hash) && info['ServerVersion'].is_a?(String)
25
+ version = info['ServerVersion'][/\A\d+/].to_i
26
+ return if version >= 28 && bridge_available?(info['Plugins']) && info['OSType'] == 'linux'
27
+
28
+ raise UnsupportedCapability, 'Docker requires a Linux engine >= 28 with bridge and isolated gateway support'
29
+ end
30
+
31
+ def bridge_available?(plugins)
32
+ plugins.is_a?(Hash) && plugins['Network'].is_a?(Array) && plugins['Network'].include?('bridge')
33
+ end
34
+
35
+ def network_entries
36
+ output = command(['network', 'ls', '--no-trunc', '--format', '{{json .}}'],
37
+ operation: 'network inventory').stdout
38
+ entries = output.lines.reject { |line| line.strip.empty? }.map do |line|
39
+ item = parse_json(line)
40
+ malformed! unless item.is_a?(Hash)
41
+ { 'id' => item['ID'], 'name' => item['Name'] }
42
+ end
43
+ validate_entries(entries)
44
+ end
45
+
46
+ def create_arguments(definition)
47
+ if definition.is_a?(Network::Egress)
48
+ return ['network', 'create', '--driver', 'bridge', *label_arguments(definition), definition.backend_name]
49
+ end
50
+
51
+ ['network', 'create', '--driver', 'bridge', '--internal',
52
+ '--opt', 'com.docker.network.bridge.gateway_mode_ipv4=isolated',
53
+ '--opt', 'com.docker.network.bridge.gateway_mode_ipv6=isolated',
54
+ *peer_network_options(definition), *label_arguments(definition), definition.backend_name]
55
+ end
56
+
57
+ def peer_network_options(definition)
58
+ return [] unless definition.allocation
59
+
60
+ layout = Network::Peer::Layout.new(definition.allocation)
61
+ ['--subnet', layout.subnet, '--ip-range', layout.container_pool,
62
+ '--opt', "com.docker.network.bridge.name=ep#{context.workspace.id[0, 10]}"]
63
+ end
64
+
65
+ def isolated_gateways?(options)
66
+ %w[ipv4 ipv6].all? { |family| options["com.docker.network.bridge.gateway_mode_#{family}"] == 'isolated' }
67
+ end
68
+
69
+ def isolated?(data)
70
+ data['Internal'] && data['Driver'] == 'bridge' && isolated_gateways?(data['Options'])
71
+ end
72
+
73
+ def normalize_network_binding(data)
74
+ malformed! unless data['Options'].is_a?(Hash) && [true, false].include?(data['Internal'])
75
+ Network::Binding.new(id: data['Id'], name: data['Name'], labels: data['Labels'] || {},
76
+ isolated: isolated?(data))
77
+ end
78
+
79
+ def normalize_network(data)
80
+ binding = normalize_network_binding(data)
81
+ containers = data['Containers']
82
+ malformed! unless containers.is_a?(Hash)
83
+ Network::Resource.new(**binding.to_h, attachment_count: containers.size)
84
+ end
85
+ end
86
+ end
87
+ end