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,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ # rubocop:disable-next Metrics/ModuleLength -- Shared native ownership and snapshot validation boundary.
6
+ module ServiceInspection
7
+ def inspect_services(definitions, expected_ids: {})
8
+ selected = select_services(definitions, expected_ids)
9
+ ids = selected.values.compact.map { |entry| entry.fetch('id') }
10
+ resources = inspect_containers(ids)
11
+ definitions.to_h do |key, definition|
12
+ resource = resources[selected[key]&.fetch('id')]
13
+ definition.verify!(resource, expected_id: expected_ids[key])
14
+ [key, resource]
15
+ end
16
+ end
17
+
18
+ def inspect_volumes(definitions, expected_ids: {})
19
+ resources = inspect_volumes_by_name(select_volume_names(definitions))
20
+ definitions.to_h do |key, definition|
21
+ resource = resources[definition.name]
22
+ definition.verify!(resource, expected_id: expected_ids[key])
23
+ [key, resource]
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def select_volume_names(definitions)
30
+ names = definitions.values.map(&:name)
31
+ inventory('volume').filter_map { |entry| entry.fetch('name') if names.include?(entry.fetch('name')) }
32
+ end
33
+
34
+ def inspect_volumes_by_name(names)
35
+ return {} if names.empty?
36
+
37
+ data = parse_json(service_command(['volume', 'inspect', *names], operation: 'volume snapshot').stdout)
38
+ validate_snapshot!(data, names.size)
39
+ resources = data.map { |entry| normalize_volume(entry) }
40
+ verify_volume_names!(resources, names)
41
+ resources.to_h { |entry| [entry.fetch('name'), entry] }
42
+ rescue KeyError, TypeError, NoMethodError
43
+ malformed!
44
+ end
45
+
46
+ def verify_volume_names!(resources, names)
47
+ malformed! unless resources.map { |entry| entry.fetch('name') }.sort == names.sort
48
+ end
49
+
50
+ def select_services(definitions, expected_ids)
51
+ entries = inventory('container')
52
+ definitions.to_h do |key, definition|
53
+ entry = entries.find { |item| item.fetch('name') == definition.name }
54
+ verify_recorded_name!('container', entries, entry, expected_ids[key])
55
+ [key, entry]
56
+ end
57
+ end
58
+
59
+ def inspect_containers(ids)
60
+ return {} if ids.empty?
61
+
62
+ data = parse_json(service_command(['container', 'inspect', *ids], operation: 'service snapshot').stdout)
63
+ validate_snapshot!(data, ids.size)
64
+ resources = data.map { |entry| normalize_service('container', entry) }
65
+ verify_snapshot_ids!(resources, ids)
66
+ resources.to_h { |entry| [entry.fetch('id'), entry] }
67
+ rescue KeyError, TypeError, NoMethodError
68
+ malformed!
69
+ end
70
+
71
+ def validate_snapshot!(data, size)
72
+ malformed! unless data.is_a?(Array) && data.size == size && data.all?(Hash)
73
+ end
74
+
75
+ def verify_snapshot_ids!(resources, ids)
76
+ malformed! unless resources.map { |entry| entry['id'] }.sort == ids.sort
77
+ end
78
+
79
+ def inspect_owned(kind, definition, expected_id:)
80
+ return inspect_named(kind, definition, expected_id: expected_id) unless kind == 'container' && expected_id
81
+
82
+ result = runner.run(name, arguments: ['container', 'inspect', expected_id], timeout: 30)
83
+ return inspect_named(kind, definition, expected_id: expected_id) unless result.success?
84
+
85
+ data = parse_json(result.stdout)
86
+ validate_snapshot!(data, 1)
87
+ resource = normalize_service(kind, data.first)
88
+ definition.verify!(resource, expected_id: expected_id)
89
+ resource
90
+ rescue KeyError, TypeError, NoMethodError
91
+ malformed!
92
+ end
93
+
94
+ def inspect_named(kind, definition, expected_id:)
95
+ entries = inventory(kind)
96
+ entry = entries.find { |item| item.fetch('name') == definition.name }
97
+ verify_recorded_name!(kind, entries, entry, expected_id)
98
+ return unless entry
99
+
100
+ resource = normalize_service(kind, inspect_entry_data(kind, entry.fetch('id')))
101
+ definition.verify!(resource, expected_id: expected_id)
102
+ resource
103
+ rescue JSON::ParserError, KeyError, TypeError, NoMethodError
104
+ raise Providers::ExecutionError, 'Malformed service inventory', cause: nil
105
+ end
106
+
107
+ def verify_recorded_name!(kind, entries, entry, expected_id)
108
+ return unless kind == 'container' && !entry && expected_id
109
+ return unless entries.any? { |item| item.fetch('id') == expected_id }
110
+
111
+ raise Providers::OwnershipError, "Recorded #{kind} exists under a different name"
112
+ end
113
+
114
+ def inspect_entry_data(kind, id)
115
+ data = JSON.parse(service_command([kind, 'inspect', id], operation: "#{kind} inspection").stdout)
116
+ unless data.is_a?(Array) && data.size == 1 && data.first.is_a?(Hash)
117
+ raise Providers::ExecutionError, 'Malformed service inspection'
118
+ end
119
+
120
+ data.first
121
+ end
122
+
123
+ def inventory(kind)
124
+ args = kind == 'container' ? ['ps', '--all', '--no-trunc'] : %w[volume ls]
125
+ # Both CLIs support Go templates independently of their JSON inspection casing.
126
+ if kind == 'container'
127
+ output = service_command([*args, '--format', '{{.ID}} {{.Names}}'],
128
+ operation: 'service inventory')
129
+ end
130
+ output ||= service_command([*args, '--format', '{{.Name}}'], operation: 'volume inventory')
131
+ output.stdout.lines.reject { |line| line.strip.empty? }.map do |line|
132
+ id, item_name = line.strip.split(' ', 2)
133
+ { 'id' => id, 'name' => item_name || id }
134
+ end
135
+ end
136
+
137
+ def normalize_volume(data)
138
+ { 'id' => data.fetch('Labels', {}).fetch('io.empeira.volume-id', data.fetch('Name')),
139
+ 'name' => data.fetch('Name'), 'labels' => data['Labels'] || {} }
140
+ end
141
+
142
+ def normalize_service(kind, data)
143
+ return normalize_volume(data) if kind == 'volume'
144
+
145
+ { 'id' => data.fetch('Id'), 'name' => data.fetch('Name').delete_prefix('/'),
146
+ 'labels' => data.fetch('Config')['Labels'] || {}, 'state' => data.fetch('State').fetch('Status'),
147
+ 'networks' => data.fetch('NetworkSettings').fetch('Networks'),
148
+ 'host_config' => data['HostConfig'], 'image_id' => data['Image'],
149
+ 'mounts' => data.fetch('Mounts'), 'dns' => data.dig('HostConfig', 'Dns') || [],
150
+ **port_observations(data) }
151
+ end
152
+
153
+ def port_observations(data)
154
+ { 'ports' => data.dig('HostConfig', 'PortBindings') || {},
155
+ 'published_ports' => data.dig('NetworkSettings', 'Ports') || {} }
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+
5
+ module Empeira
6
+ module Runtime
7
+ # Shared service contract for Docker and Podman. No shell or Compose orchestration.
8
+ module Services
9
+ include ServiceInspection
10
+ include ServiceImages
11
+ include ImageMetadata
12
+ include RecipeBuild
13
+ include RegistryDiagnostics
14
+
15
+ def inspect_service(definition, expected_id: nil)
16
+ inspect_owned('container', definition, expected_id: expected_id)
17
+ end
18
+
19
+ def inspect_volume(definition, expected_id: nil)
20
+ inspect_owned('volume', definition, expected_id: expected_id)
21
+ end
22
+
23
+ def create_volume(definition)
24
+ service_command(['volume', 'create', *label_arguments(definition),
25
+ '--label', "io.empeira.volume-id=#{SecureRandom.uuid}",
26
+ definition.name], operation: 'volume creation')
27
+ inspect_volume(definition)
28
+ end
29
+
30
+ def remove_volume(definition, expected_id:)
31
+ resource = inspect_volume(definition, expected_id: expected_id)
32
+ return unless resource
33
+
34
+ service_command(['volume', 'rm', definition.name], operation: 'persistent volume removal')
35
+ raise Providers::ExecutionError, 'Volume removal could not be verified' if inspect_volume(definition)
36
+ end
37
+
38
+ def create_service(definition)
39
+ service_command(create_service_arguments(definition), operation: "#{definition.key} creation")
40
+ inspect_service(definition)
41
+ end
42
+
43
+ def start_service(resource)
44
+ service_command(['start', resource.fetch('id')], operation: 'service start')
45
+ end
46
+
47
+ # rubocop:disable-next Naming/PredicateMethod -- This mutation returns whether it changed infrastructure.
48
+ def remove_service(definition, expected_id: nil)
49
+ resource = inspect_service(definition, expected_id: expected_id)
50
+ return false unless resource
51
+
52
+ service_command(['stop', '--time', '20', resource.fetch('id')], operation: 'service stop', timeout: 40)
53
+ volumes = ['--volumes']
54
+ service_command(['rm', *volumes, resource.fetch('id')], operation: 'service removal')
55
+ raise Providers::ExecutionError, 'Service removal could not be verified' if inspect_service(definition)
56
+
57
+ true
58
+ end
59
+
60
+ def reload_service(resource, signal: 'HUP')
61
+ service_command(['kill', '--signal', signal, resource.fetch('id')], operation: 'service reload')
62
+ end
63
+
64
+ def stop_service(resource)
65
+ service_command(['stop', '--time', '20', resource.fetch('id')], operation: 'node stop', timeout: 40)
66
+ end
67
+
68
+ def ssh_proxy_command(resource, port: nil)
69
+ helper = Pathname(__dir__).join('../../../resources/nodes/ssh/proxy.rb').realpath
70
+ [RbConfig.ruby, helper.to_s, name, resource.fetch('id'), *(port ? [port.to_s] : [])]
71
+ end
72
+
73
+ def stream_service(resource, arguments, interactive: false, logs: false, user: nil)
74
+ command = if logs
75
+ ['logs', '--follow', resource.fetch('id')]
76
+ else
77
+ ['exec', *(user ? ['--user', user] : []), *(interactive ? ['--interactive'] : []),
78
+ *(interactive && $stdin.tty? && $stdout.tty? ? ['--tty'] : []), resource.fetch('id'), *arguments]
79
+ end
80
+ runner.stream(name, arguments: command)
81
+ end
82
+
83
+ def service_exec(resource, arguments, timeout: 15, on_stdout: nil)
84
+ options = on_stdout ? { on_stdout: on_stdout } : {}
85
+ runner.run(name, arguments: ['exec', resource.fetch('id'), *arguments], timeout: timeout, **options)
86
+ end
87
+
88
+ def copy_from(resource, source, destination)
89
+ service_command(['cp', "#{resource.fetch('id')}:#{source}", destination.to_s], operation: 'certificate export')
90
+ end
91
+
92
+ def copy_to(resource, source, destination)
93
+ service_command(['cp', source.to_s, "#{resource.fetch('id')}:#{destination}"],
94
+ operation: 'service configuration')
95
+ end
96
+
97
+ def attach_egress(definition, resource)
98
+ service_command(['network', 'connect', definition.backend_name, resource.fetch('id')],
99
+ operation: 'proxy egress attachment')
100
+ end
101
+
102
+ private
103
+
104
+ def service_command(arguments, operation:, timeout: 30)
105
+ result = runner.run(name, arguments: arguments, timeout: timeout)
106
+ return result if result.success?
107
+
108
+ raise Providers::ExecutionError,
109
+ "#{name} #{operation} failed; run empeira status\n" \
110
+ "#{Execution::Diagnostics.native(result, operation: operation, tool: name)}", cause: nil
111
+ end
112
+
113
+ def create_service_arguments(definition)
114
+ ServiceArguments.new(definition).build
115
+ end
116
+ end
117
+
118
+ Container.include(Services)
119
+ end
120
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ # Disposable update tooling: no service plan, workspace networking or persistent inventory.
6
+ module UpdateHelper
7
+ PROXY_VARIABLES = %w[HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy].freeze
8
+
9
+ def with_update_helper(image:, input:, output:, sources:, cache:)
10
+ # Reuse the ownership verifier, but assign a unique identity for this invocation only.
11
+ identity = Empeira::Services::Definition.new(key: "update-#{SecureRandom.hex(16)}",
12
+ workspace: context.workspace)
13
+ update_command(helper_arguments(identity, image, input, output, sources, cache), operation: 'helper creation')
14
+ resource = inspect_service(identity)
15
+ raise Providers::ExecutionError, 'Update helper creation could not be verified' unless resource
16
+
17
+ update_command(['start', resource.fetch('id')], operation: 'helper start')
18
+ verify_helper_network!(identity, resource.fetch('id'))
19
+ yield resource
20
+ ensure
21
+ remove_update_helper(identity, resource&.fetch('id')) if identity
22
+ end
23
+
24
+ def update_command(arguments, operation:, timeout: 600, registry: nil)
25
+ result = runner.run(name, arguments: arguments, timeout: timeout)
26
+ return result if result.success?
27
+
28
+ diagnostic = Execution::Diagnostics.safe_text("#{result.stderr}\n#{result.stdout}")
29
+ guidance = if registry
30
+ registry_failure_hint(registry, diagnostic, timed_out: result.timed_out)
31
+ else
32
+ 'Check your normal host/container network access.'
33
+ end
34
+ raise Providers::ExecutionError, "#{name} update #{operation} failed " \
35
+ "(#{result.timed_out ? 'timeout' : result.exit_status}). " \
36
+ "#{guidance}\n" \
37
+ "#{Execution::Diagnostics.native(result, operation: operation, tool: name)}",
38
+ cause: nil
39
+ end
40
+
41
+ private
42
+
43
+ # rubocop:disable-next Metrics/AbcSize -- Keep all explicit module-helper mounts and restrictions together.
44
+ def helper_arguments(identity, image, input, output, sources, cache)
45
+ mounts = %w[Puppetfile request.json].flat_map do |file|
46
+ ['--mount', helper_mount(File.join(input, file), "/work/#{file}", readonly: true)]
47
+ end
48
+ environment = { 'HOME' => '/work', 'GIT_TERMINAL_PROMPT' => '0', 'GIT_ALLOW_PROTOCOL' => 'file' }
49
+ ['create', '--name', identity.name, '--network', update_helper_network, *update_helper_user,
50
+ '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', *label_arguments(identity),
51
+ *PROXY_VARIABLES.select { |key| ENV.key?(key) }.flat_map { |key| ['--env', key] },
52
+ *environment.flat_map { |key, value| ['--env', "#{key}=#{value}"] },
53
+ *mounts, '--mount', helper_mount(output, '/work/modules', readonly: false),
54
+ '--mount', helper_mount(sources, sources.to_s, readonly: true),
55
+ '--mount', helper_mount(cache, cache.to_s, readonly: false), image]
56
+ end
57
+
58
+ def update_helper_user
59
+ ['--user', "#{Process.uid}:#{Process.gid}"]
60
+ end
61
+
62
+ def helper_mount(source, target, readonly:)
63
+ if source.to_s.match?(/[,\r\n]/)
64
+ raise ConfigurationError,
65
+ 'Update mount paths must not contain commas or newlines'
66
+ end
67
+
68
+ "type=bind,src=#{File.realpath(source)},dst=#{target}#{',readonly' if readonly}"
69
+ end
70
+
71
+ def verify_helper_network!(identity, id)
72
+ resource = inspect_service(identity, expected_id: id)
73
+ raise Providers::ExecutionError, 'Update helper disappeared before synchronization' unless resource
74
+
75
+ networks = resource.fetch('networks').keys
76
+ return if networks == [update_helper_network] && resource.fetch('dns').empty? &&
77
+ resource.fetch('ports').empty? && resource.fetch('published_ports', {}).values.all?(&:nil?)
78
+
79
+ raise Providers::OwnershipError, 'Update helper has unexpected network, DNS or port configuration'
80
+ end
81
+
82
+ def update_helper_network
83
+ 'bridge'
84
+ end
85
+
86
+ def remove_update_helper(identity, id)
87
+ resource = inspect_service(identity, expected_id: id)
88
+ return unless resource
89
+
90
+ update_command(['rm', '--force', '--volumes', resource.fetch('id')], operation: 'helper removal')
91
+ raise Providers::ExecutionError, 'Update helper removal could not be verified' if inspect_service(identity)
92
+ end
93
+ end
94
+
95
+ Container.include(UpdateHelper)
96
+ end
97
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Runtime
5
+ module WorkspaceNetwork
6
+ # This privileged, short-lived operation runs in the engine's Linux host
7
+ # namespace, including Docker Desktop's VM. It does not share host files,
8
+ # sockets or PID namespaces, and cannot select arbitrary firewall rules.
9
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- Verify ownership before this narrow host-namespace transaction.
10
+ def reconcile_workspace_bridge(definition:, expected_id:, image:, action:)
11
+ return unless name == 'docker'
12
+
13
+ resource = inspect_network(identifier: definition.backend_name)
14
+ definition.verify_ownership!(resource, expected_id: expected_id)
15
+ raise Providers::OwnershipError, 'Workspace network is missing' unless resource
16
+
17
+ definition.verify_isolation!(resource)
18
+ details = network_details(resource.id)
19
+ bridge = "ep#{context.workspace.id[0, 10]}"
20
+ unless details.dig('Options', 'com.docker.network.bridge.name') == bridge &&
21
+ details.fetch('IPAM').fetch('Config').any? { |item| item['Subnet'] == definition.allocation }
22
+ raise Providers::OwnershipError, 'Workspace bridge or subnet conflicts with recorded allocation'
23
+ end
24
+
25
+ helper = Empeira::Services::Definition.new(key: "bridge-#{SecureRandom.hex(16)}", workspace: context.workspace)
26
+ service_command(['run', '--rm', '--pull=never', '--name', helper.name, '--network', 'host',
27
+ '--cap-drop', 'ALL', '--cap-add', 'NET_ADMIN', '--cap-add', 'NET_RAW',
28
+ '--security-opt', 'no-new-privileges', '--read-only',
29
+ '--tmpfs', '/run:rw,noexec,nosuid,size=1m', *label_arguments(helper),
30
+ '--entrypoint', '/usr/local/libexec/empeira-bridge', image,
31
+ action, bridge, definition.allocation, resource.id],
32
+ operation: 'workspace bridge attachment', timeout: action == 'check' ? 3 : 30)
33
+ ensure
34
+ if helper && (remaining = inspect_service(helper))
35
+ service_command(['rm', '--force', remaining.fetch('id')], operation: 'bridge helper cleanup')
36
+ end
37
+ end
38
+
39
+ def detach_egress(definition, resource)
40
+ service_command(['network', 'disconnect', definition.backend_name, resource.fetch('id')],
41
+ operation: 'gateway uplink detachment')
42
+ end
43
+
44
+ # Only the target's network namespace is shared. No host namespace, Docker
45
+ # socket, target filesystem or persistent NET_ADMIN capability is exposed.
46
+ # rubocop:disable-next Metrics/AbcSize -- Bound the privilege and cleanup of a single namespace operation.
47
+ def configure_workspace_route(resource, gateway:, image:)
48
+ labels = resource.fetch('labels')
49
+ unless labels['io.empeira.managed-by'] == 'empeira' && labels['io.empeira.workspace'] == context.workspace.id
50
+ raise Providers::OwnershipError, 'Cannot configure routing on a foreign container'
51
+ end
52
+
53
+ helper = Empeira::Services::Definition.new(key: "route-#{SecureRandom.hex(16)}", workspace: context.workspace)
54
+ arguments = ['run', '--rm', '--pull=never', '--name', helper.name,
55
+ '--network', "container:#{resource.fetch('id')}", '--cap-drop', 'ALL', '--cap-add', 'NET_ADMIN',
56
+ '--security-opt', 'no-new-privileges', '--read-only',
57
+ '--tmpfs', '/run:rw,noexec,nosuid,size=1m', *label_arguments(helper),
58
+ '--entrypoint', Network::Gateway::EXECUTABLE, image, 'route', gateway]
59
+ service_command(arguments, operation: 'workspace default routing')
60
+ ensure
61
+ if helper && (remaining = inspect_service(helper))
62
+ service_command(['rm', '--force', remaining.fetch('id')], operation: 'route helper cleanup')
63
+ end
64
+ end
65
+ end
66
+
67
+ Container.include(WorkspaceNetwork)
68
+ end
69
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Server
5
+ # Apply one native Puppet setting without replacing an image's configuration.
6
+ class EnvironmentConfiguration
7
+ PUPPET = '/opt/puppetlabs/bin/puppet'
8
+ READER = '/empeira-server/environment.rb'
9
+
10
+ def initialize(runtime:, plan:)
11
+ @runtime = runtime
12
+ @plan = plan
13
+ end
14
+
15
+ # rubocop:disable-next Naming/PredicateMethod -- Reconciliation mutates the native server setting.
16
+ def reconcile(resource)
17
+ return false if current(resource) == '0'
18
+
19
+ execute(resource, [PUPPET, 'config', 'set', 'environment_timeout', '0', '--section', 'server',
20
+ '--confdir', confdir])
21
+ @runtime.stop_service(resource)
22
+ @runtime.start_service(resource)
23
+ true
24
+ end
25
+
26
+ def verify!(resource)
27
+ return if current(resource) == '0'
28
+
29
+ raise Error, 'Puppetserver environment_timeout is not 0 after startup; the custom entrypoint or ' \
30
+ 'server confdir overrides the managed setting; inspect the server runtime contract'
31
+ end
32
+
33
+ private
34
+
35
+ def current(resource)
36
+ execute(resource, [ControlPlane::Health::RUBY, READER, '--confdir', confdir]).stdout.strip
37
+ end
38
+
39
+ def execute(resource, arguments)
40
+ result = @runtime.service_exec(resource, arguments)
41
+ return result if result.success?
42
+
43
+ details = Execution::Diagnostics.command(result, operation: 'Effective Puppetserver environment timeout',
44
+ tool: arguments.first)
45
+ raise Error, "Cannot reconcile Puppetserver environment_timeout\n#{details}", cause: nil
46
+ end
47
+
48
+ def confdir
49
+ File.dirname(@plan.server_runtime.paths.fetch('puppetdb_config'))
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Server
5
+ module HTTP
6
+ def self.arguments
7
+ ['curl', '--silent', '--show-error', '--fail-with-body', '--max-time', '8', '--noproxy', '*']
8
+ end
9
+
10
+ def self.tls_arguments
11
+ ssl = '/etc/puppetlabs/puppet/ssl'
12
+ [*arguments, '--cacert', "#{ssl}/certs/ca.pem", '--cert', "#{ssl}/certs/server.empeira.internal.pem",
13
+ '--key', "#{ssl}/private_keys/server.empeira.internal.pem"]
14
+ end
15
+
16
+ def self.response(result)
17
+ text = result.stdout
18
+ status = reason = type = nil
19
+ while (match = %r{\AHTTP/\S+ (\d{3})(?: ([^\r\n]*))?\r?\n((?:[^\r\n]+\r?\n)*)\r?\n}.match(text))
20
+ status = match[1]
21
+ reason = match[2]
22
+ type = match[3][/^Content-Type:\s*([^\r\n]+)/i, 1]
23
+ text = text[match[0].length..]
24
+ end
25
+ { status: status, reason: reason, body: text, content_type: type }
26
+ end
27
+
28
+ def self.failure(result, url:, operation:)
29
+ data = response(result)
30
+ return Execution::Diagnostics.native(result, operation: operation, tool: 'curl', url: url) unless data[:status]
31
+
32
+ detail = Execution::Diagnostics.http(operation: operation, url: url, **data)
33
+ stderr = Execution::Diagnostics.bounded(Execution::Diagnostics.safe_text(result.stderr))
34
+ "#{detail}\n#{stderr}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Server
5
+ # OpenVoxDB process layout; custom images may override only these runtime details.
6
+ class PuppetDBRuntime
7
+ PATHS = %w[jar config bootstrap_config data].freeze
8
+ FIELDS = %w[entrypoint user java_arguments main_arguments paths].freeze
9
+
10
+ def self.validate!(value, path = 'puppetdb.runtime', effective: false)
11
+ raise ConfigurationError, "#{path} must be a mapping" unless value.is_a?(Hash)
12
+
13
+ value.each do |key, item|
14
+ validate_entry!(key, item, path)
15
+ end
16
+ return unless effective
17
+
18
+ missing = FIELDS - value.keys
19
+ raise ConfigurationError, "#{path}.#{missing.first} is required" unless missing.empty?
20
+ end
21
+
22
+ def self.validate_entry!(key, item, path)
23
+ raise ConfigurationError, "#{path}.#{key} is not supported" unless FIELDS.include?(key)
24
+
25
+ if key == 'paths'
26
+ validate_mapping!(item, PATHS, "#{path}.#{key}")
27
+ elsif !valid_item?(key, item)
28
+ raise ConfigurationError, "#{path}.#{key} is invalid"
29
+ end
30
+ end
31
+
32
+ def self.validate_mapping!(value, allowed, path)
33
+ raise ConfigurationError, "#{path} must be a mapping" unless value.is_a?(Hash)
34
+
35
+ value.each do |key, item|
36
+ raise ConfigurationError, "#{path}.#{key} is not supported" unless allowed.include?(key)
37
+
38
+ valid = Runtime.safe_path?(item)
39
+ raise ConfigurationError, "#{path}.#{key} is invalid" unless valid
40
+ end
41
+ end
42
+
43
+ def self.valid_item?(key, item)
44
+ case key
45
+ when 'entrypoint' then Runtime.safe_path?(item)
46
+ when 'user' then item.is_a?(String) && item.match?(/\A[0-9]+\z/)
47
+ else Runtime.valid_arguments?(item)
48
+ end
49
+ end
50
+
51
+ def self.resolve(config)
52
+ definition = config.fetch('puppetdb').fetch('runtime')
53
+ validate!(definition, effective: true)
54
+ new(definition)
55
+ end
56
+
57
+ attr_reader :entrypoint, :user, :paths
58
+
59
+ def initialize(definition)
60
+ @entrypoint = definition.fetch('entrypoint')
61
+ @user = definition.fetch('user')
62
+ @paths = definition.fetch('paths')
63
+ @java_arguments = definition.fetch('java_arguments')
64
+ @main_arguments = definition.fetch('main_arguments')
65
+ end
66
+
67
+ def command
68
+ [*@java_arguments, '-cp', paths.fetch('jar'), *@main_arguments,
69
+ '--config', paths.fetch('config'), '--bootstrap-config', paths.fetch('bootstrap_config'),
70
+ '--restart-file', "#{paths.fetch('data')}/restartcounter"]
71
+ end
72
+ end
73
+ end
74
+ end