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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'digest'
5
+ require 'fileutils'
6
+ require 'pathname'
7
+ require 'tmpdir'
8
+ require 'tempfile'
9
+
10
+ module Empeira
11
+ module Agent
12
+ class CacheCorruption < Error; end
13
+
14
+ class Cache
15
+ include CacheStorage
16
+
17
+ def initialize(root:, temporary: Dir.tmpdir)
18
+ @root = Pathname(root)
19
+ @temporary = temporary
20
+ end
21
+
22
+ def with_artifact(request, enabled:, acquire:, pin: nil, &use)
23
+ return temporary(request, pin, acquire, &use) unless enabled
24
+
25
+ prepare
26
+ key = fingerprint(request)
27
+ lock(key) do
28
+ artifact = load(key, request, pin) || acquire_cached(key, request, pin, acquire)
29
+ use.call(artifact)
30
+ end
31
+ rescue SystemCallError, IOError
32
+ raise Error, 'Cannot read or write the private agent cache; check ownership and permissions', cause: nil
33
+ end
34
+
35
+ def self.fingerprint(value)
36
+ Digest::SHA256.hexdigest(JSON.generate(canonical(value)))
37
+ end
38
+
39
+ def self.canonical(value)
40
+ return value.sort.to_h.transform_values { |child| canonical(child) } if value.is_a?(Hash)
41
+ return value.map { |child| canonical(child) } if value.is_a?(Array)
42
+
43
+ value
44
+ end
45
+
46
+ private
47
+
48
+ def fingerprint(value)
49
+ self.class.fingerprint(value)
50
+ end
51
+
52
+ def temporary(request, pin, acquire)
53
+ Dir.mktmpdir('empeira-agent-', @temporary) do |directory|
54
+ artifact = acquire.call(directory)
55
+ validate!(artifact, request, pin)
56
+ yield artifact
57
+ end
58
+ end
59
+
60
+ def acquire_cached(key, request, pin, acquire)
61
+ Dir.mktmpdir('.download-', @root.join('entries')) do |directory|
62
+ artifact = acquire.call(directory)
63
+ validate!(artifact, request, pin)
64
+ publish(key, artifact, request)
65
+ end
66
+ end
67
+
68
+ def load(key, request, pin)
69
+ reference = @root.join('refs', "#{key}.json")
70
+ return unless reference.exist? || reference.symlink?
71
+
72
+ owned!(reference)
73
+ artifact = read_artifact(reference, request)
74
+ validate!(artifact, request, pin)
75
+ artifact
76
+ rescue JSON::ParserError, KeyError, Errno::ENOENT, CacheCorruption
77
+ reference.delete if reference.exist?
78
+ nil
79
+ end
80
+
81
+ def read_artifact(reference, request)
82
+ entry = read_entry(reference)
83
+ directory = @root.join('entries', entry)
84
+ owned!(directory, directory: true)
85
+ metadata = directory.join('metadata.json')
86
+ owned!(metadata)
87
+ path = directory.join("package.#{request.fetch('format')}")
88
+ owned!(path)
89
+ Artifact.new(path: path, metadata: JSON.parse(metadata.read))
90
+ end
91
+
92
+ def read_entry(reference)
93
+ data = JSON.parse(reference.read)
94
+ raise CacheCorruption, 'Invalid agent cache reference' unless data.is_a?(Hash)
95
+
96
+ entry = data.fetch('entry')
97
+ unless entry.is_a?(String) && entry.match?(/\A[a-f0-9]{64}\z/)
98
+ raise CacheCorruption,
99
+ 'Invalid agent cache reference'
100
+ end
101
+
102
+ entry
103
+ end
104
+
105
+ def validate!(artifact, request, pin)
106
+ metadata = artifact.metadata
107
+ valid = artifact.valid_identity?(request) && valid_file?(artifact, request.fetch('format'))
108
+ valid &&= metadata['sha256'] == pin if pin
109
+ raise CacheCorruption, 'Agent cache artifact or package identity is damaged' unless valid
110
+ end
111
+
112
+ def valid_file?(artifact, format)
113
+ path = artifact.path
114
+ return false unless path.file? && path.size.positive? && path.size == artifact.metadata['size']
115
+ return false unless Digest::SHA256.file(path).hexdigest == artifact.metadata['sha256']
116
+
117
+ magic = File.binread(path, 8)
118
+ format == 'deb' ? magic == "!<arch>\n" : magic.start_with?("\xed\xab\xee\xdb".b)
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Agent
5
+ # Filesystem ownership, stable locks and atomic publication for the package cache.
6
+ module CacheStorage
7
+ private
8
+
9
+ def prepare
10
+ FileUtils.mkdir_p(@root.dirname, mode: 0o700)
11
+ [@root, @root.join('entries'), @root.join('refs'), @root.join('locks')].each do |path|
12
+ begin
13
+ Dir.mkdir(path, 0o700)
14
+ rescue Errno::EEXIST
15
+ # Concurrent creation is allowed only after ownership verification.
16
+ end
17
+ owned!(path, directory: true)
18
+ end
19
+ end
20
+
21
+ def owned!(path, directory: false)
22
+ stat = path.lstat
23
+ valid = stat.uid == Process.uid && !stat.symlink? && stat.mode.nobits?(0o077)
24
+ valid &&= directory ? stat.directory? : stat.file? && stat.nlink == 1
25
+ raise Error, 'Agent cache contains an unsafe or foreign entry; refusing access' unless valid
26
+ end
27
+
28
+ def lock(key)
29
+ path = @root.join('locks', key)
30
+ File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |file|
31
+ owned!(path)
32
+ file.flock(File::LOCK_EX)
33
+ yield
34
+ ensure
35
+ file.flock(File::LOCK_UN)
36
+ end
37
+ end
38
+
39
+ def publish(key, artifact, request)
40
+ entry = fingerprint(request.merge('native_version' => artifact.metadata.fetch('version')))
41
+ metadata = artifact.metadata.merge('request' => request)
42
+ final = commit_entry(entry, artifact, metadata, request)
43
+ write_reference(key, entry)
44
+ Artifact.new(path: final.join("package.#{request.fetch('format')}"), metadata: metadata)
45
+ end
46
+
47
+ def commit_entry(entry, artifact, metadata, request)
48
+ staging = stage_entry(artifact, metadata, request)
49
+ final = @root.join('entries', entry)
50
+ remove_owned_entry(final) if final.exist?
51
+ File.rename(staging, final)
52
+ final
53
+ end
54
+
55
+ def stage_entry(artifact, metadata, request)
56
+ staging = Pathname(File.dirname(artifact.path)).join('entry')
57
+ Dir.mkdir(staging, 0o700)
58
+ File.rename(artifact.path, staging.join("package.#{request.fetch('format')}"))
59
+ File.write(staging.join('metadata.json'), JSON.generate(metadata), perm: 0o600)
60
+ staging.children.each { |path| File.open(path, 'r+', &:fsync) }
61
+ staging
62
+ end
63
+
64
+ def remove_owned_entry(directory)
65
+ owned!(directory, directory: true)
66
+ directory.children.each { |child| owned!(child) }
67
+ FileUtils.remove_entry(directory)
68
+ end
69
+
70
+ def write_reference(key, entry)
71
+ reference = @root.join('refs', "#{key}.json")
72
+ Tempfile.create(['.ref-', '.json'], reference.dirname) do |file|
73
+ file.chmod(0o600)
74
+ file.write(JSON.generate('entry' => entry))
75
+ file.flush
76
+ file.fsync
77
+ File.rename(file.path, reference)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'digest'
6
+ require 'timeout'
7
+
8
+ module Empeira
9
+ module Agent
10
+ # Host HTTPS transport with bounded diagnostics and acquisition-scoped authentication.
11
+ class Download
12
+ HTTP_OPTIONS = { use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_PEER,
13
+ open_timeout: 10, read_timeout: 30, write_timeout: 30 }.freeze
14
+ FAILURE_HINTS = { '403' => 'access denied', '407' => 'Host proxy authentication failed',
15
+ '404' => 'artifact unavailable' }.freeze
16
+ def initialize(authentication:)
17
+ @authentication = authentication
18
+ end
19
+
20
+ def fetch(url, path, sha256: nil, operation: 'Download agent package')
21
+ @authentication.attempt do
22
+ File.open(path, 'wb', 0o600) do |file|
23
+ request(url, operation) do |response|
24
+ write_response(file, response)
25
+ end
26
+ file.flush
27
+ file.fsync
28
+ end
29
+ end
30
+ raise Error, 'Agent artifact download is empty' if File.empty?(path)
31
+ raise Error, 'Agent artifact checksum mismatch' if sha256 && Digest::SHA256.file(path).hexdigest != sha256
32
+ rescue StandardError, Interrupt
33
+ FileUtils.rm_f(path)
34
+ raise
35
+ end
36
+
37
+ private
38
+
39
+ def write_response(file, response)
40
+ response.read_body { |part| file.write(part) }
41
+ length = response['Content-Length']
42
+ raise Error, 'Agent artifact download is incomplete' if length && file.size != Integer(length)
43
+ end
44
+
45
+ def request(url, operation)
46
+ received = nil
47
+ uri = URI(url)
48
+ Configuration::AgentSchema.https_url!(url, 'agent artifact URL')
49
+ Timeout.timeout(180) do
50
+ Net::HTTP.start(uri.host, uri.port, **HTTP_OPTIONS) do |http|
51
+ http.request(http_request(uri)) do |response|
52
+ received = response
53
+ yield validate_response!(response, url, operation)
54
+ end
55
+ end
56
+ end
57
+ rescue SocketError, OpenSSL::SSL::SSLError, IOError, SystemCallError, Timeout::Error, Net::ProtocolError => e
58
+ detail = Execution::Diagnostics.transport(operation: operation, url: url, error: e,
59
+ response: received, secrets: @authentication.credentials || [])
60
+ raise Error, "Agent HTTPS download failed\n#{detail}", cause: nil
61
+ end
62
+
63
+ def http_request(uri)
64
+ request = Net::HTTP::Get.new(uri.request_uri)
65
+ request['Accept-Encoding'] = 'identity'
66
+ credentials = @authentication.credentials(uri.to_s)
67
+ request.basic_auth(*credentials) if credentials
68
+ request
69
+ end
70
+
71
+ def validate_response!(response, url, operation)
72
+ return response if response.code == '200'
73
+
74
+ detail = Execution::Diagnostics.http_response(response, operation: operation, url: url,
75
+ secrets: @authentication.credentials || [])
76
+ raise AuthenticationRequired.new(detail, url: url) if response.code == '401'
77
+
78
+ fallback = response.code.start_with?('3') ? 'redirects are not followed' : 'HTTP error response'
79
+ hint = FAILURE_HINTS.fetch(response.code, fallback)
80
+ raise Error, "Agent HTTPS request failed: #{hint}\n#{detail}", cause: nil
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Agent
5
+ # Shared helper execution, scoped authentication, artifact delivery and release ownership.
6
+ module NativeCommands
7
+ private
8
+
9
+ def download_artifact(artifact, filename)
10
+ path = File.join(@directory, filename)
11
+ operation = filename == 'signing-key' ? 'Download agent signing key' : 'Download agent release package'
12
+ @download.fetch(artifact.fetch('url'), path, sha256: artifact['sha256'], operation: operation)
13
+ path
14
+ end
15
+
16
+ def install_auth
17
+ credentials = @authentication.credentials
18
+ return unless credentials
19
+
20
+ uri = URI(@source.fetch('url'))
21
+ endpoint = "https://#{uri.host}#{":#{uri.port}" unless uri.port == 443}"
22
+ # APT's netrc parser treats quotes and backslashes as literal credential bytes.
23
+ content = "machine #{endpoint}\nlogin #{credentials.first}\npassword #{credentials.last}\n"
24
+ copy_text(content, self.class::AUTH_PATH, mode: '0600')
25
+ end
26
+
27
+ def native!(arguments, operation)
28
+ @authentication.attempt do
29
+ install_auth
30
+ execute!(arguments, operation)
31
+ end
32
+ end
33
+
34
+ def execute!(arguments, operation)
35
+ result = @execute.call(arguments)
36
+ return result if result.success?
37
+
38
+ text = @authentication.redact("#{result.stderr}\n#{result.stdout}")
39
+ diagnostic = Execution::Diagnostics.native(result.with(stderr: text, stdout: ''),
40
+ operation: "Agent #{operation}", tool: arguments.first)
41
+ case text
42
+ when /407\s+Proxy Authentication Required|(?:HTTP|Status code|HTTP code)\s*:?\s*407\b/i
43
+ raise Error, "Host helper proxy authentication failed\n#{diagnostic}", cause: nil
44
+ when /401\s+Unauthorized|(?:HTTP|Status code|HTTP code)\s*:?\s*401\b/i
45
+ raise Agent::AuthenticationRequired, diagnostic, cause: nil
46
+ when /403\s+Forbidden|(?:HTTP|Status code|HTTP code)\s*:?\s*403\b/i
47
+ raise Error, "Agent repository access denied\n#{diagnostic}", cause: nil
48
+ end
49
+ raise Error, "Agent #{operation} failed\n#{diagnostic}", cause: nil
50
+ end
51
+
52
+ def copy_text(content, destination, mode:)
53
+ Node::PackageProxy.copy_text(@copy, content, destination, mode: mode)
54
+ end
55
+
56
+ def require_absent_release!(name)
57
+ Configuration::AgentSchema.package_token!(name, 'Agent release package name')
58
+ return unless installed_release_packages.include?(name)
59
+
60
+ raise Error, 'Agent release package already exists in the helper; refusing to replace or remove it'
61
+ end
62
+
63
+ def installed_release_packages
64
+ execute!(release_inventory_arguments, 'release-package ownership verification').stdout.lines.map(&:strip)
65
+ end
66
+
67
+ def release_inventory_arguments
68
+ ['dpkg-query', '-W', '-f=${Package}\n']
69
+ end
70
+
71
+ def release_removal_arguments
72
+ ['dpkg', '--purge', '--', @release_package]
73
+ end
74
+
75
+ def remove_release_package
76
+ return unless installed_release_packages.include?(@release_package)
77
+
78
+ execute!(release_removal_arguments, 'release package cleanup')
79
+ raise Error, 'Cannot verify agent helper release package cleanup' if
80
+ installed_release_packages.include?(@release_package)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Agent
5
+ # Apply the selected-source policy to native source files supplied by a release package.
6
+ module ReleaseSources
7
+ DNF_SCOPED_OPTIONS = %w[gpgcheck sslverify skip_if_unavailable includepkgs username password
8
+ mirrorlist metalink].freeze
9
+
10
+ module_function
11
+
12
+ def apt(content, format:, source:)
13
+ urls = content.scan(%r{[a-z][a-z0-9+.-]*://[^\s]+}i)
14
+ raise Error, 'Agent release package must provide HTTPS APT sources' if urls.empty?
15
+
16
+ urls.each { |url| Configuration::AgentSchema.https_url!(url, 'agent release source URL') }
17
+ trusted = source['verify_signatures'] == false
18
+ format == '.sources' ? policy_deb822(content, trusted) : policy_list(content, trusted)
19
+ end
20
+
21
+ def policy_deb822(content, trusted)
22
+ content.split(/\n\s*\n/).map do |stanza|
23
+ safe = stanza.gsub(/^(?:Trusted|Allow-Insecure|Allow-Weak|Allow-Downgrade-To-Insecure):.*\n?/i, '').rstrip
24
+ "#{safe}\n#{"Trusted: yes\n" if trusted}"
25
+ end.join("\n")
26
+ end
27
+
28
+ def policy_list(content, trusted)
29
+ content.gsub(/^(\s*deb(?:-src)?\s+)(?:\[([^\]]*)\]\s+)?/) do
30
+ prefix = Regexp.last_match(1)
31
+ options = Regexp.last_match(2).to_s.gsub(/\b(?:trusted|allow-[a-z-]+)=\S+/, '').strip
32
+ options = [options, ('trusted=yes' if trusted)].compact.reject(&:empty?).join(' ')
33
+ "#{prefix}#{"[#{options}] " unless options.empty?}"
34
+ end
35
+ end
36
+
37
+ def dnf(content, target:, source:, package:)
38
+ sections = content.split(/(?=^\[)/)
39
+ sections.map do |section|
40
+ section.start_with?('[') ? dnf_section(section, target, source, package) : section
41
+ end.join
42
+ end
43
+
44
+ def scoped_dnf(section, source)
45
+ section = section.gsub(/^\s*(?:#{DNF_SCOPED_OPTIONS.join('|')})\s*=.*\n?/, '')
46
+ if source['verify_signatures'] == false
47
+ section = section.gsub(/^\s*repo_gpgcheck\s*=.*\n?/, '')
48
+ section = "#{section.rstrip}\nrepo_gpgcheck=0\n"
49
+ end
50
+ section
51
+ end
52
+
53
+ def dnf_section(section, target, source, package)
54
+ urls = section.scan(/^\s*baseurl\s*=\s*(.+)$/).flatten.flat_map(&:split)
55
+ raise Error, 'Agent release package must provide explicit HTTPS DNF base URLs' if urls.empty?
56
+
57
+ urls.each { |url| Configuration::AgentSchema.https_url!(target.expand(url), 'agent release source URL') }
58
+ section = scoped_dnf(section, source)
59
+ "#{section.rstrip}\ngpgcheck=#{source['verify_signatures'] == false ? 0 : 1}\n" \
60
+ "sslverify=1\nskip_if_unavailable=0\nincludepkgs=#{package}\n"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Agent
5
+ class Target < Data.define(:os, :release, :architecture)
6
+ SUITES = { '22.04' => 'jammy', '24.04' => 'noble' }.freeze
7
+ ARCHITECTURES = { 'amd64' => 'x86_64', 'arm64' => 'aarch64' }.freeze
8
+
9
+ def family
10
+ Node::PackageBootstrap::FAMILIES.fetch(os)
11
+ end
12
+
13
+ def format
14
+ family == 'debian' ? 'deb' : 'rpm'
15
+ end
16
+
17
+ def native_architecture
18
+ family == 'debian' ? architecture : ARCHITECTURES.fetch(architecture)
19
+ end
20
+
21
+ def key
22
+ family == 'debian' ? "#{os}#{release}" : "el#{release}"
23
+ end
24
+
25
+ def expand(value)
26
+ values = { 'suite' => SUITES[release], 'releasever' => release, 'basearch' => native_architecture }
27
+ value.gsub(/\$(?:\{([^}]+)\}|([a-zA-Z_][a-zA-Z_0-9]*))/) do
28
+ values.fetch(Regexp.last_match(1) || Regexp.last_match(2)) do
29
+ raise ConfigurationError, 'Unknown agent repository placeholder'
30
+ end || raise(ConfigurationError, 'Agent repository suite is unavailable for this target')
31
+ end
32
+ end
33
+
34
+ def source(install)
35
+ return package_source(install) if install.fetch('method') == 'package'
36
+
37
+ manager = family == 'debian' ? 'apt' : 'dnf'
38
+ sources = install.fetch(manager)
39
+ return install.fetch('repositories').fetch(key) unless configured?(sources)
40
+
41
+ resolved = Configuration::Merge.call(sources.fetch('default', {}), sources.fetch(key, {}),
42
+ path: ['agent', 'install', manager, key])
43
+ expand_source(resolved)
44
+ end
45
+
46
+ private
47
+
48
+ def package_source(install)
49
+ install.fetch('packages').fetch(key).fetch(architecture)
50
+ end
51
+
52
+ def configured?(sources)
53
+ sources.key?('default') || sources.key?(key)
54
+ end
55
+
56
+ def expand_source(source)
57
+ resolved = source.transform_values { |value| value.is_a?(String) ? expand(value) : value }
58
+ if family == 'debian'
59
+ resolved['suite'] ||= SUITES.fetch(release)
60
+ resolved['component'] ||= 'main'
61
+ end
62
+ resolved
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Agent
5
+ # Exact identity matching only; ordering remains the native package manager's responsibility.
6
+ module Versions
7
+ module_function
8
+
9
+ def match?(native, requested, suffix: nil)
10
+ return native == "#{requested}#{suffix}" if suffix
11
+ return true if native == requested
12
+ return false if requested.include?('-') || requested.include?(':')
13
+
14
+ native.split(':').last.split('-', 2).first == requested
15
+ end
16
+
17
+ def select(candidates, requested, suffix: nil)
18
+ matches = candidates.select { |candidate| match?(candidate.fetch('version'), requested, suffix: suffix) }.uniq
19
+ raise Error, 'Requested agent version is unavailable in the selected source' if matches.empty?
20
+ if matches.size > 1
21
+ raise Error,
22
+ 'Requested agent version is ambiguous; specify the full native version or suffix'
23
+ end
24
+
25
+ matches.first
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ class Application
5
+ Context = Data.define(:project, :workspace, :configuration, :platform, :locations) do
6
+ def container_engine
7
+ configuration.fetch('runtime').fetch('container_engine')
8
+ end
9
+ end
10
+
11
+ attr_reader :context, :runner
12
+
13
+ # Dependencies are explicit at this single composition boundary.
14
+ # rubocop:disable Metrics/ParameterLists
15
+ def initialize(project_path: Dir.pwd, overrides: {}, platform: Platform::Facts.new, runner: nil,
16
+ factories: {}, locations: nil, build_info: BuildInfo.load, progress: Progress.new,
17
+ recovery: false, recovery_path: nil)
18
+ @runner = runner || Execution::Runner.new(platform: platform)
19
+ locations ||= Platform::Locations.new(facts: platform)
20
+ if recovery
21
+ @context = recovery_context(project_path, recovery_path, platform, locations)
22
+ else
23
+ raise ConfigurationError, 'Explicit recovery project paths are only available for destroy' if recovery_path
24
+
25
+ project = ProjectRoot.resolve(project_path, runner: @runner)
26
+ @context = build_context(project:, locations:, platform:, overrides:)
27
+ end
28
+ @progress = progress
29
+ @build_info = build_info
30
+ @factories = { providers: Node.registry, runtimes: Runtime.registry,
31
+ engines: VM.registry }.merge(factories).freeze
32
+ end
33
+
34
+ # rubocop:enable Metrics/ParameterLists
35
+
36
+ def infrastructure
37
+ Infrastructure::Service.new(context: context, runner: runner, runtimes: @factories.fetch(:runtimes),
38
+ build_info: @build_info, progress: @progress)
39
+ end
40
+
41
+ def updates
42
+ Updates::Service.new(context: context, runner: runner, runtimes: @factories.fetch(:runtimes),
43
+ build_info: @build_info, progress: @progress)
44
+ end
45
+
46
+ def addons
47
+ Addons::Service.new(context: context, runner: runner)
48
+ end
49
+
50
+ def nodes
51
+ Node::Service.new(context: context, runner: runner, **@factories, build_info: @build_info, progress: @progress,
52
+ ssh_preferences: @ssh_preferences)
53
+ end
54
+
55
+ def run_node(hostname:, provider:)
56
+ request = Node::RunRequest.from_config(hostname: hostname, provider: provider, config: context.configuration)
57
+ nodes.run(request)
58
+ end
59
+
60
+ private
61
+
62
+ # rubocop:disable-next Metrics/AbcSize -- Resolve the saved owner independently of live project configuration.
63
+ def recovery_context(project_path, recovery_path, platform, locations)
64
+ project = ProjectRoot.resolve(project_path, runner: @runner, recovery: true) unless recovery_path
65
+ workspace = recovery_path ? Workspace.recover(recovery_path) : Workspace.new(path: project)
66
+ configuration = Configuration::Document.new.read(Configuration::Loader::DEFAULTS)
67
+ Configuration::Schema.new.validate_effective!(configuration)
68
+ context = Context.new(project: project, workspace: workspace, configuration: configuration,
69
+ platform: platform, locations: locations)
70
+ state = Infrastructure::Store.new(context: context, recovery: true).load
71
+ configuration.fetch('runtime')['container_engine'] = state.fetch('runtime') if state
72
+ @ssh_preferences = {}.freeze
73
+ Context.new(project: project, workspace: workspace, configuration: Immutable.deep_freeze(configuration),
74
+ platform: platform, locations: locations)
75
+ end
76
+
77
+ def build_context(project:, locations:, platform:, overrides:)
78
+ loader = Configuration::Loader.new(project_path: project, locations: locations)
79
+ configuration = loader.load(overrides: overrides)
80
+ @ssh_preferences = loader.ssh_preferences
81
+ workspace = Workspace.new(path: project)
82
+ Context.new(project: project, workspace: workspace, configuration: Immutable.deep_freeze(configuration),
83
+ platform: platform, locations: locations)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,5 @@
1
+ {
2
+ "version": "0.1.0-alpha.1",
3
+ "revision": "7cd2d71ea0b7cfa7008f8faf18e975499c5af9c0",
4
+ "build_time": "2026-10-14T09:19:05Z"
5
+ }
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'version_requirement'
5
+
6
+ module Empeira
7
+ class BuildInfo
8
+ METADATA_PATH = File.expand_path('build.json', __dir__).freeze
9
+ attr_reader :version, :revision, :build_time
10
+
11
+ def self.load(path: METADATA_PATH)
12
+ data = File.file?(path) ? JSON.parse(File.read(path)) : {}
13
+ new(**data.transform_keys(&:to_sym))
14
+ end
15
+
16
+ def initialize(version: 'development', revision: nil, build_time: nil)
17
+ @version = version.delete_prefix('v').freeze
18
+ @revision = revision&.dup&.freeze
19
+ @build_time = build_time&.dup&.freeze
20
+ freeze
21
+ end
22
+
23
+ def gem_version
24
+ version == 'development' ? '0.0.0.development' : version
25
+ end
26
+
27
+ def requirement_status(requirement)
28
+ parsed = VersionRequirement.parse(requirement)
29
+ return 'not required' unless parsed
30
+ return 'unverifiable (development build)' if version == 'development'
31
+
32
+ parsed.satisfied_by?(Gem::Version.new(version)) ? 'satisfied' : 'incompatible'
33
+ end
34
+
35
+ def require_compatible!(requirement)
36
+ return if ['not required', 'satisfied'].include?(requirement_status(requirement))
37
+
38
+ raise Error, "Empeira version requirement is not satisfied.\nRequired: #{requirement}\nInstalled: #{version}\n" \
39
+ "Update Empeira before modifying this project.\nRun: empeira self-update"
40
+ end
41
+
42
+ def to_h
43
+ { version: version, revision: revision, build_time: build_time }
44
+ end
45
+ end
46
+ end