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,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ class ProxyPolicy
6
+ LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'
7
+ DOMAIN = /\A(?:\*\.)?#{LABEL}(?:\.#{LABEL})+\z/
8
+ # Destination IP restrictions apply only to the authenticated bootstrap proxy.
9
+ IPV4_FORBIDDEN = %w[0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16
10
+ 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4].freeze
11
+ # Squid normalizes mapped addresses to IPv4 before interpreting the prefix.
12
+ # IPv6-sized prefixes are misparsed as ::/0, so retain the equivalent IPv4 prefix.
13
+ MAPPED_FORBIDDEN = IPV4_FORBIDDEN.map { |range| "::ffff:#{range}" }.freeze
14
+ FORBIDDEN = [*IPV4_FORBIDDEN, *MAPPED_FORBIDDEN, '::/128', '::1/128', 'fc00::/7', 'fe80::/10'].join(' ').freeze
15
+
16
+ def self.valid_domain?(value)
17
+ value.is_a?(String) && value.size <= 253 && value.match?(DOMAIN) &&
18
+ !value.delete_prefix('*.').match?(/\A[\d.]+\z/) &&
19
+ !value.end_with?('.empeira.internal', '.localhost') && value != 'empeira.internal'
20
+ end
21
+
22
+ def initialize(config, authorization: nil)
23
+ @config = config
24
+ @authorization = authorization
25
+ end
26
+
27
+ def configuration
28
+ [
29
+ 'dns_nameservers @EMPEIRA_DNS@', 'http_port 3128',
30
+ 'visible_hostname empeira-proxy', 'cache deny all',
31
+ 'access_log none', 'cache_log /dev/null', 'pid_filename none',
32
+ client_rule, 'http_access deny !clients',
33
+ 'request_header_access Proxy-Authorization deny all',
34
+ 'acl SSL_ports port 443', 'acl Safe_ports port 80 443', 'acl CONNECT method CONNECT',
35
+ ("acl forbidden dst #{FORBIDDEN}" if @authorization), 'http_access deny !Safe_ports',
36
+ 'http_access deny CONNECT !SSL_ports', ('http_access deny forbidden' if @authorization),
37
+ *destination_rules, 'http_access deny all', ''
38
+ ].compact.join("\n")
39
+ end
40
+
41
+ def self.domain_rules(domains, name: 'permitted', condition: nil)
42
+ return [] if domains.empty?
43
+
44
+ destinations = domains.map { |domain| domain.start_with?('*.') ? ".#{domain.delete_prefix('*.')}" : domain }
45
+ ["acl #{name} dstdomain -n #{destinations.join(' ')}",
46
+ "http_access allow #{[condition, name].compact.join(' ')}"]
47
+ end
48
+
49
+ private
50
+
51
+ def destination_rules
52
+ return self.class.domain_rules(@config.fetch('global')) if @authorization
53
+
54
+ ['include /empeira-proxy/proxy-rules.conf']
55
+ end
56
+
57
+ def client_rule
58
+ return 'acl clients src "/empeira-proxy/proxy-clients"' unless @authorization
59
+
60
+ "acl clients req_header Proxy-Authorization ^Basic[[:space:]]#{Regexp.escape(@authorization)}$"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ # Observed owned service addresses are transient gateway inputs, never inventory mappings.
6
+ class Redirects
7
+ EXCLUDED_TARGETS = %w[gateway browser-ui].freeze
8
+
9
+ def self.target_key(entry)
10
+ key = entry.fetch('to').fetch('service')
11
+ key == 'coredns' ? 'dns' : key
12
+ end
13
+
14
+ def self.validate_routes!(entries, routes:)
15
+ entries.each_with_index do |entry, index|
16
+ source = entry.fetch('from').fetch('ip')
17
+ next unless DirectEgress::BENCHMARK.include?(source)
18
+
19
+ collision = routes.find { |range| IPAddr.new(range).include?(source) }
20
+ next unless collision
21
+
22
+ raise ConfigurationError,
23
+ "network.redirects[#{index}].from.ip collides with host/VPN/runtime route #{collision}"
24
+ end
25
+ rescue IPAddr::InvalidAddressError
26
+ raise Error, 'Cannot verify virtual redirect source route collisions', cause: nil
27
+ end
28
+
29
+ def self.validate!(entries, definitions:, subnet: nil)
30
+ entries.each_with_index do |entry, index|
31
+ path = "network.redirects[#{index}]"
32
+ target = target_key(entry)
33
+ unless definitions.key?(target) && !EXCLUDED_TARGETS.include?(target)
34
+ raise ConfigurationError, "#{path}.to.service #{target} is missing, disabled or not an internal " \
35
+ 'application service'
36
+ end
37
+ next unless subnet && IPAddr.new(subnet).include?(entry.fetch('from').fetch('ip'))
38
+
39
+ raise ConfigurationError, "#{path}.from.ip collides with the workspace subnet #{subnet}"
40
+ end
41
+ end
42
+
43
+ def initialize(entries:, network:, subnet:)
44
+ @entries = entries
45
+ @network = network
46
+ @subnet = IPAddr.new(subnet)
47
+ end
48
+
49
+ def resolve(definitions:, resources:)
50
+ entries = @entries.map { |entry| resolved_entry(entry, definitions, resources) }
51
+ entries.sort_by do |entry|
52
+ entry.fetch('from').values_at('ip', 'port') + [entry.fetch('from').fetch('protocol', 'tcp')]
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def resolved_entry(entry, definitions, resources)
59
+ key = self.class.target_key(entry)
60
+ ip = target_address(resources[key], definitions.fetch(key))
61
+ source = entry.fetch('from').dup
62
+ source.delete('protocol') if source['protocol'] == 'tcp'
63
+ { 'from' => source, 'to' => ip && { 'ip' => ip, 'port' => entry.fetch('to').fetch('port') } }
64
+ end
65
+
66
+ def target_address(resource, definition)
67
+ return unless resource
68
+
69
+ definition.verify!(resource)
70
+ return unless resource['state'] == 'running' &&
71
+ resource.dig('labels', 'io.empeira.definition') == definition.fingerprint
72
+
73
+ ip = resource.dig('networks', @network, 'IPAddress')
74
+ unless valid_address?(ip)
75
+ raise Providers::OwnershipError, "Redirect target #{definition.key} has no valid workspace IPv4 address"
76
+ end
77
+
78
+ ip
79
+ rescue IPAddr::InvalidAddressError
80
+ raise Providers::OwnershipError, "Redirect target #{definition.key} has an invalid workspace address",
81
+ cause: nil
82
+ end
83
+
84
+ def valid_address?(ip)
85
+ ip.is_a?(String) && @subnet.include?(IPAddr.new(ip)) && IPAddr.new(ip).to_s == ip
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ module ResourceIdentity
6
+ private
7
+
8
+ def valid_identity?(value)
9
+ value.is_a?(String) && value.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z/)
10
+ end
11
+
12
+ def valid_labels?(labels)
13
+ labels.is_a?(Hash) && labels.all? { |key, value| key.is_a?(String) && value.is_a?(String) }
14
+ end
15
+ end
16
+
17
+ # Identity and isolation only; this cannot stand in for a deletion membership observation.
18
+ Binding = Data.define(:id, :name, :labels, :isolated) do
19
+ include ResourceIdentity
20
+
21
+ def initialize(id:, name:, labels:, isolated:)
22
+ unless valid_identity?(id) && valid_identity?(name) && valid_labels?(labels) && [true, false].include?(isolated)
23
+ raise Providers::ExecutionError, 'Runtime returned malformed network binding'
24
+ end
25
+
26
+ super(id: id.dup.freeze, name: name.dup.freeze, labels: Immutable.deep_freeze(labels.dup), isolated: isolated)
27
+ end
28
+ end
29
+
30
+ Resource = Data.define(:id, :name, :labels, :isolated, :attachment_count) do
31
+ include ResourceIdentity
32
+
33
+ def initialize(id:, name:, labels:, isolated:, attachment_count:)
34
+ unless valid_identity?(id) && valid_identity?(name) && valid_labels?(labels) &&
35
+ valid_policy?(isolated, attachment_count)
36
+ raise Providers::ExecutionError, 'Runtime returned malformed network data'
37
+ end
38
+
39
+ super(id: id.dup.freeze, name: name.dup.freeze, labels: Immutable.deep_freeze(labels.dup),
40
+ isolated: isolated, attachment_count: attachment_count)
41
+ end
42
+
43
+ private
44
+
45
+ def valid_policy?(isolated, count)
46
+ [true, false].include?(isolated) && count.is_a?(Integer) && count >= 0
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ module AgentInstaller
6
+ module_function
7
+
8
+ def build(context:, runtime:, requirements:, execute:, copy:, progress: Progress.new)
9
+ AgentInstallation.new(context: context, runtime: runtime, requirements: requirements,
10
+ execute: execute, copy: copy, progress: progress)
11
+ end
12
+ end
13
+
14
+ class AgentInstallation
15
+ def initialize(context:, runtime:, requirements:, execute:, copy:, progress:)
16
+ @acquisition = Agent::Acquisition.new(context: context, runtime: runtime, progress: progress)
17
+ @requirements = requirements
18
+ @execute = execute
19
+ @copy = copy
20
+ end
21
+
22
+ def install(proxy_url:)
23
+ @acquisition.with_package(@requirements) do |artifact|
24
+ AgentPackage.new(target: @requirements.target, package: @requirements.agent.fetch('package'),
25
+ artifact: artifact, execute: @execute, copy: @copy,
26
+ rpm_options: @requirements.rpm_options).install(proxy_url: proxy_url)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/FormatStringToken -- RPM and DNF use their native query-format tokens.
4
+
5
+ module Empeira
6
+ module Node
7
+ # Installs one host-acquired artifact through the guest's native manager and original base sources.
8
+ class AgentPackage
9
+ PACKAGE_PATH = '/var/tmp/empeira-agent-package'
10
+
11
+ def initialize(target:, package:, artifact:, execute:, copy:, rpm_options: [])
12
+ @target = target
13
+ @package = package
14
+ @artifact = artifact
15
+ @execute = execute
16
+ @copy = copy
17
+ @rpm_options = rpm_options
18
+ @paths = []
19
+ end
20
+
21
+ def install(proxy_url:)
22
+ @proxy_url = proxy_url
23
+ @package_proxy = PackageProxy.new(family: @target.family, execute: @execute, copy: @copy)
24
+ @paths << @package_proxy.path
25
+ @package_proxy.prepare(proxy_url)
26
+ @paths << package_path
27
+ @copy.call(@artifact.path, package_path, '0600')
28
+ install_keys if @target.format == 'rpm'
29
+ install_package
30
+ verify_version!
31
+ ensure
32
+ cleanup
33
+ end
34
+
35
+ private
36
+
37
+ def cleanup
38
+ remove_introduced_keys if @original_keys
39
+ ensure
40
+ execute!(['rm', '-f', '--', *@paths], 'local agent artifact cleanup') unless @paths.empty?
41
+ end
42
+
43
+ def package_path
44
+ "#{PACKAGE_PATH}.#{@target.format}"
45
+ end
46
+
47
+ def install_package
48
+ if @target.format == 'deb'
49
+ options = ['-o', 'Acquire::http::AllowRedirect=false', '-o', 'Acquire::https::AllowRedirect=false',
50
+ '-o', 'APT::Update::Error-Mode=any']
51
+ execute!(['apt-get', *@package_proxy.arguments, *options, 'update'], 'base dependency metadata acquisition')
52
+ execute!(['apt-get', *@package_proxy.arguments, *options, 'install', '-y', '--', package_path],
53
+ 'local APT agent installation (dependencies must be available in the original base sources)')
54
+ else
55
+ verification = @artifact.metadata.fetch('verify_signatures') ? 1 : 0
56
+ execute!(['dnf', *@package_proxy.arguments, "--setopt=localpkg_gpgcheck=#{verification}", *@rpm_options,
57
+ 'install', '--assumeyes', '--', package_path],
58
+ 'local DNF agent installation (dependencies must be available in the original base sources)')
59
+ end
60
+ end
61
+
62
+ def install_keys
63
+ @original_keys = signing_keys
64
+ @artifact.metadata.fetch('public_keys').each_with_index do |encoded, index|
65
+ path = "/var/tmp/empeira-agent-signing-key-#{index}"
66
+ @paths << path
67
+ PackageProxy.copy_text(@copy, Base64.strict_decode64(encoded), path)
68
+ execute!(['rpmkeys', '--import', path], 'temporary agent signing key import')
69
+ end
70
+ end
71
+
72
+ def signing_keys
73
+ result = execute!(['rpm', '-qa', '--qf', "%{NAME}-%{VERSION}-%{RELEASE}\n", 'gpg-pubkey'],
74
+ 'agent signing key ownership verification')
75
+ result.stdout.lines.map(&:strip).sort.tap do |keys|
76
+ raise Error, 'Cannot validate RPM signing key identity; Puppet was not run' unless
77
+ keys.all? { |key| key.match?(/\Agpg-pubkey-[a-f0-9]+-[a-f0-9]+\z/) }
78
+ end
79
+ end
80
+
81
+ def remove_introduced_keys
82
+ (signing_keys - @original_keys).each do |key|
83
+ execute!(['rpm', '-e', '--', key], 'temporary signing key cleanup')
84
+ end
85
+ raise Error, 'Cannot verify agent signing key cleanup; Puppet was not run' unless signing_keys == @original_keys
86
+ end
87
+
88
+ def installed_identity
89
+ command = identity_command
90
+ result = execute!(command, 'installed agent identity verification')
91
+ name, version, architecture = result.stdout.strip.split('|')
92
+ version = version.delete_prefix('0:') if @target.format == 'rpm' && version
93
+ [name, version, architecture]
94
+ end
95
+
96
+ def identity_command
97
+ if @target.format == 'deb'
98
+ ['dpkg-query', '-W', '-f=${Package}|${Version}|${Architecture}', @package]
99
+ else
100
+ ['rpm', '-q', '--qf', '%{NAME}|%{EPOCHNUM}:%{VERSION}-%{RELEASE}|%{ARCH}', @package]
101
+ end
102
+ end
103
+
104
+ def verify_version!
105
+ name, version, architecture = installed_identity
106
+ return if name == @package && version == @artifact.metadata.fetch('version') &&
107
+ architecture == @artifact.metadata.fetch('architecture')
108
+
109
+ raise Error, 'Installed agent package identity differs from the resolved artifact; Puppet was not run'
110
+ end
111
+
112
+ def execute!(arguments, operation)
113
+ result = @execute.call(arguments)
114
+ return result if result.success?
115
+
116
+ text = PackageProxy.redact("#{result.stderr}\n#{result.stdout}", @proxy_url)
117
+ diagnostic = Execution::Diagnostics.native(result.with(stderr: text, stdout: ''),
118
+ operation: "Agent #{operation}", tool: arguments.first)
119
+ raise Error, "Agent #{operation} failed; node retained and Puppet was not run\n#{diagnostic}", cause: nil
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ # rubocop:enable Style/FormatStringToken
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'digest'
5
+ require 'base64'
6
+
7
+ module Empeira
8
+ module Node
9
+ # Native source setup and package inspection run only in an owned disposable helper.
10
+ # rubocop:disable-next Metrics/ClassLength -- APT metadata resolution and package inspection share one isolated source.
11
+ class AgentRepository
12
+ include Agent::NativeCommands
13
+
14
+ AUTH_PATH = '/etc/apt/auth.conf.d/00-empeira-agent.conf'
15
+ SOURCE_PATH = '/var/tmp/empeira-agent-sources/agent.list'
16
+ KEY_PATH = '/var/tmp/empeira-agent-key.gpg'
17
+ PACKAGE_PATH = '/var/tmp/empeira-agent-package.deb'
18
+ attr_reader :public_keys
19
+
20
+ # rubocop:disable-next Metrics/ParameterLists -- The helper receives explicit transport and target dependencies.
21
+ def initialize(source:, package:, version:, target:, execute:, copy:, download:, authentication:, directory:)
22
+ @source = source
23
+ @package = package
24
+ @version = version
25
+ @target = target
26
+ @execute = execute
27
+ @copy = copy
28
+ @download = download
29
+ @authentication = authentication
30
+ @directory = directory
31
+ @public_keys = []
32
+ end
33
+
34
+ def resolve
35
+ @release_package = nil
36
+ install_source
37
+ native!(['apt-get', *apt_options, 'update'], 'APT metadata acquisition')
38
+ result = native!(['apt-cache', *apt_options, 'show', '--', @package], 'APT package resolution')
39
+ candidates = result.stdout.split(/\n\s*\n/).filter_map { |stanza| apt_candidate(stanza) }
40
+ Agent::Versions.select(candidates, @version, suffix: @source['suffix'])
41
+ ensure
42
+ remove_release_package if @release_package
43
+ end
44
+
45
+ def inspect_package(path)
46
+ @copy.call(path, PACKAGE_PATH, '0600')
47
+ result = execute!(['dpkg-deb', '--show', '--showformat=${Package}|${Version}|${Architecture}', PACKAGE_PATH],
48
+ 'DEB package inspection')
49
+ name, version, architecture = result.stdout.strip.split('|')
50
+ validate_metadata(name, version, architecture)
51
+ end
52
+
53
+ def prepare_keys
54
+ return unless @source['key']
55
+
56
+ path = download_artifact(@source.fetch('key'), 'signing-key')
57
+ @public_keys << Base64.strict_encode64(File.binread(path))
58
+ key = File.binread(path).start_with?('-----BEGIN PGP PUBLIC KEY BLOCK-----') ? "#{KEY_PATH}.asc" : KEY_PATH
59
+ @copy.call(path, key, '0644')
60
+ key
61
+ end
62
+
63
+ private
64
+
65
+ def install_source
66
+ execute!(['mkdir', '-p', File.dirname(SOURCE_PATH), '/var/tmp/empeira-agent-lists/partial'],
67
+ 'source preparation')
68
+ if @source['release'] || !@source.key?('suite')
69
+ install_release(@source['release'] || @source)
70
+ collect_release_sources
71
+ else
72
+ install_repository
73
+ end
74
+ end
75
+
76
+ def install_repository
77
+ key = prepare_keys
78
+ options = []
79
+ options << "signed-by=#{key}" if key
80
+ options << 'trusted=yes' if @source['verify_signatures'] == false
81
+ line = "deb [#{options.join(' ')}] #{@source.fetch('url')} #{@source.fetch('suite')} " \
82
+ "#{@source.fetch('component')}\n"
83
+ copy_text(line.sub('[] ', ''), SOURCE_PATH, mode: '0644')
84
+ end
85
+
86
+ def install_release(artifact)
87
+ path = download_artifact(artifact, 'release.deb')
88
+ destination = '/var/tmp/empeira-agent-release.deb'
89
+ @copy.call(path, destination, '0600')
90
+ name = execute!(['dpkg-deb', '--field', destination, 'Package'], 'release metadata').stdout.strip
91
+ require_absent_release!(name)
92
+ @release_package = name
93
+ execute!(['dpkg', '-i', destination], 'release package installation')
94
+ end
95
+
96
+ def collect_release_sources
97
+ files = execute!(['dpkg-query', '-L', @release_package], 'release source discovery').stdout.lines.map(&:strip)
98
+ files = files.select do |file|
99
+ file.start_with?('/etc/apt/sources.list.d/') && file.match?(/\.(?:list|sources)\z/)
100
+ end
101
+ raise Error, 'Agent release package provides no native APT source' if files.empty?
102
+
103
+ files.each_with_index do |file, index|
104
+ copy_release_source(file, index)
105
+ end
106
+ end
107
+
108
+ def copy_release_source(file, index)
109
+ content = execute!(['cat', file], 'release source configuration').stdout
110
+ content = Agent::ReleaseSources.apt(content, format: File.extname(file), source: @source)
111
+ copy_text(content, "/var/tmp/empeira-agent-sources/#{index}#{File.extname(file)}", mode: '0644')
112
+ end
113
+
114
+ def apt_options
115
+ ['-o', 'Dir::Etc::sourcelist=/dev/null', '-o', 'Dir::Etc::sourceparts=/var/tmp/empeira-agent-sources',
116
+ '-o', 'Dir::State::lists=/var/tmp/empeira-agent-lists', '-o', 'Dir::State::status=/dev/null',
117
+ '-o', 'Acquire::http::AllowRedirect=false', '-o', 'Acquire::https::AllowRedirect=false',
118
+ '-o', 'APT::Update::Error-Mode=any']
119
+ end
120
+
121
+ def apt_candidate(stanza)
122
+ fields = apt_fields(stanza)
123
+ return unless apt_identity?(fields) && %w[Filename Version SHA256].all? { |key| fields[key] }
124
+
125
+ return unless Agent::Versions.match?(fields.fetch('Version'), @version, suffix: @source['suffix'])
126
+
127
+ { 'version' => fields.fetch('Version'), 'architecture' => fields.fetch('Architecture'),
128
+ 'url' => package_url(fields.fetch('Version')), 'sha256' => fields.fetch('SHA256') }
129
+ end
130
+
131
+ def apt_fields(stanza)
132
+ stanza.lines.filter_map do |line|
133
+ line.split(': ', 2) if line.match?(/\A\w[^:]*: /)
134
+ end.to_h.transform_values(&:strip)
135
+ end
136
+
137
+ def apt_identity?(fields)
138
+ fields['Package'] == @package && [@target.native_architecture, 'all'].include?(fields['Architecture'])
139
+ end
140
+
141
+ def package_url(version)
142
+ result = native!(['apt-get', *apt_options, '--print-uris', 'download', "#{@package}=#{version}"],
143
+ 'APT artifact location')
144
+ urls = result.stdout.lines.filter_map { |line| line[%r{\A'(https://[^']+)'}, 1] }.uniq
145
+ raise Error, 'Selected agent source did not provide one HTTPS package location' unless urls.size == 1
146
+
147
+ urls.first
148
+ end
149
+
150
+ def validate_metadata(name, version, architecture)
151
+ unless name == @package && [@target.native_architecture, 'all'].include?(architecture) &&
152
+ Agent::Versions.match?(version.to_s, @version, suffix: @source['suffix'])
153
+ raise Error, 'Agent package name, native version or architecture differs from the requested target'
154
+ end
155
+
156
+ { 'version' => version, 'architecture' => architecture }
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # Preserve the base image's complete APT configuration around temporary
6
+ # agent-repository and package-bootstrap changes.
7
+ class AptConfiguration
8
+ DISTRIBUTIONS = %w[ubuntu debian].freeze
9
+ BACKUP = '/root/.empeira-apt-before-bootstrap.tar'
10
+
11
+ def initialize(os:, execute:, root: '/', backup: BACKUP, on_retry: nil)
12
+ @os = os
13
+ @execute = execute
14
+ @root = Pathname(root).cleanpath
15
+ @backup = Pathname(backup).cleanpath
16
+ @on_retry = on_retry
17
+ end
18
+
19
+ def preserve
20
+ return yield unless DISTRIBUTIONS.include?(@os)
21
+
22
+ capture!
23
+ begin
24
+ yield
25
+ ensure
26
+ restore!
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def capture!
33
+ execute!(['install', '--mode', '0600', '/dev/null', @backup.to_s], 'Cannot prepare APT configuration backup')
34
+ execute!(['tar', '--create', '--file', @backup.to_s, '--directory', @root.to_s, 'etc/apt'],
35
+ 'Cannot preserve the base image APT configuration')
36
+ rescue StandardError => e
37
+ cleanup = @execute.call(['rm', '--force', '--', @backup.to_s])
38
+ unless cleanup.success?
39
+ details = Execution::Diagnostics.command(cleanup, operation: 'Partial APT backup cleanup', tool: 'rm')
40
+ raise Error, "#{e.message}\nPartial APT backup cleanup also failed\n#{details}", cause: nil
41
+ end
42
+ raise
43
+ end
44
+
45
+ def restore!
46
+ execute!(['tar', '--list', '--file', @backup.to_s], restore_error)
47
+ execute!(['rm', '--recursive', '--force', '--', @root.join('etc/apt').to_s], restore_error)
48
+ execute!(['tar', '--extract', '--file', @backup.to_s, '--directory', @root.to_s], restore_error)
49
+ execute!(['tar', '--compare', '--file', @backup.to_s, '--directory', @root.to_s], restore_error)
50
+ remove_backup!
51
+ end
52
+
53
+ def remove_backup!
54
+ retried = false
55
+ begin
56
+ execute!(['rm', '--force', '--', @backup.to_s],
57
+ 'Cannot remove the restored APT configuration backup; ' \
58
+ 'Puppet was not run. Node retained for diagnosis')
59
+ rescue ::Empeira::VM::Guest::TransportError => e
60
+ raise unless e.transient? && !retried
61
+
62
+ retried = true
63
+ @on_retry&.call("Retrying only the restored APT backup removal once after a transient SSH error.\n" \
64
+ "#{e.message}")
65
+ retry
66
+ end
67
+ end
68
+
69
+ def restore_error
70
+ 'Cannot restore and verify the base image APT configuration; Puppet was not run. Node retained for diagnosis'
71
+ end
72
+
73
+ def execute!(arguments, message)
74
+ result = @execute.call(arguments)
75
+ return if result.success?
76
+
77
+ details = Execution::Diagnostics.command(result, operation: "#{message} (#{arguments.first} #{arguments[1]})",
78
+ tool: arguments.first)
79
+ raise Error, "#{message}\n#{details}", cause: nil
80
+ rescue ::Empeira::VM::Guest::TransportError => e
81
+ raise e.with_context(message), cause: nil
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # The complete Empeira-owned guest file state required before the first catalog.
6
+ class Bootstrap
7
+ FileEntry = Struct.new(:path, :content, :mode, keyword_init: true)
8
+
9
+ attr_reader :files
10
+
11
+ def initialize(provider:)
12
+ @files = [FileEntry.new(path: ExternalFact::PATH, content: ExternalFact.content(provider),
13
+ mode: 0o644).freeze].freeze
14
+ end
15
+ end
16
+ end
17
+ end