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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # rubocop:disable-next Metrics/ModuleLength -- Container preparation shares its ownership transaction.
6
+ module ContainerPreparation
7
+ private
8
+
9
+ def network_gateway
10
+ Network::Gateway.new(context: context, runtime: @runtime, state: @state)
11
+ end
12
+
13
+ def finish_network_bootstrap(record)
14
+ @bootstrap_proxy.cleanup(@state)
15
+ record['network_phase'] = 'runtime'
16
+ save
17
+ network_gateway.phase(record, bootstrap: false)
18
+ refresh_dns
19
+ end
20
+
21
+ def ready_server
22
+ raise Error, 'Control plane is down; run empeira up first' unless @state&.dig('resources', 'network', 'id')
23
+
24
+ ready_network!
25
+ plan = ControlPlane::Plan.new(context: context)
26
+ expected = @state.dig('control_plane', 'services', 'server', 'id')
27
+ server = @runtime.inspect_service(plan.definitions.fetch('server'), expected_id: expected)
28
+ unless current_server?(server, plan) &&
29
+ ControlPlane::Health.new(runtime: @runtime, plan: plan).ready?('server', 'server' => server)
30
+ raise Error, 'Configuration server is not ready; run empeira up first'
31
+ end
32
+
33
+ server
34
+ end
35
+
36
+ def ready_network!
37
+ definition = Infrastructure::Definition.new(context: context).network
38
+ expected = @state&.dig('resources', 'network', 'id')
39
+ raise Error, 'Control plane is down; run empeira up first' unless expected
40
+
41
+ @runtime.verify_isolated_network(definition, expected_id: expected)
42
+ end
43
+
44
+ def current_server?(server, plan)
45
+ server&.dig('labels', 'io.empeira.definition') == plan.definitions.fetch('server').fingerprint
46
+ end
47
+
48
+ def reserve(request)
49
+ reject_name!(request.hostname.downcase)
50
+ plan = ControlPlane::Plan.new(context: context)
51
+
52
+ image = Image.new(config: context.configuration, request: request, architecture: @runtime.architecture)
53
+ definition = node_definition(request, image, plan)
54
+ record = node_record(request, definition, image)
55
+ @nodes[record.fetch('hostname')] = record
56
+ save
57
+ [record, definition, image]
58
+ end
59
+
60
+ def node_record(request, definition, image)
61
+ request.to_h.transform_keys(&:to_s).merge(
62
+ 'hostname' => request.hostname.downcase, 'runtime' => context.container_engine,
63
+ 'architecture' => @runtime.architecture, **ssh_record,
64
+ 'image' => image.reference, 'id' => nil, 'network_phase' => 'bootstrap', 'provisioned' => false,
65
+ 'created_at' => Time.now.utc.iso8601,
66
+ 'definition' => definition.options
67
+ )
68
+ end
69
+
70
+ def ssh_record
71
+ publication = @runtime.node_ssh_publication?
72
+ { 'ssh_host' => '127.0.0.1', 'ssh_port' => publication ? nil : 22,
73
+ 'ssh_transport' => publication ? 'loopback' : 'tunnel' }
74
+ end
75
+
76
+ def reject_name!(hostname)
77
+ reserved = Network::Naming.new.reserved(context.configuration)
78
+ aliases = [hostname, hostname.delete_suffix('.empeira.internal')]
79
+ taken = @nodes.keys.any? { |key| aliases.include?(key.delete_suffix('.empeira.internal')) }
80
+ return unless aliases.intersect?(reserved) || taken
81
+
82
+ raise Providers::AlreadyExists, 'Hostname is reserved or already exists in this workspace'
83
+ end
84
+
85
+ def node_definition(request, image, plan)
86
+ init = node_init
87
+ Definition.new(hostname: request.hostname.downcase, workspace: context.workspace,
88
+ network: plan.network, dns: dns_address(plan), image: image.reference,
89
+ memory: request.memory, cpus: request.cpus, init: init,
90
+ **peer_options,
91
+ command: init == 'systemd' ? ['/sbin/init'] : %w[/usr/sbin/sshd -D -e],
92
+ **ssh_options(request),
93
+ environment: plan.node_proxy_environment)
94
+ end
95
+
96
+ def peer_options
97
+ layout = Network::Peer::Layout.new(@state.fetch('peer_network').fetch('subnet'))
98
+ { ip: layout.container_lease(@nodes), mac_address: "02:#{SecureRandom.hex(5).scan(/../).join(':')}",
99
+ sysctls: { 'net.ipv6.conf.all.disable_ipv6' => '1', 'net.ipv6.conf.default.disable_ipv6' => '1' } }
100
+ end
101
+
102
+ def node_init
103
+ init = context.configuration.dig('node_defaults', 'init')
104
+ @runtime.require_systemd! if init == 'systemd'
105
+ init
106
+ end
107
+
108
+ def ssh_options(request)
109
+ ssh_credentials(request.hostname.downcase).prepare_hosts
110
+ { entrypoint: '/usr/local/libexec/empeira-node-start',
111
+ ports: @runtime.node_ssh_publication? ? ['127.0.0.1::22/tcp'] : [],
112
+ mounts: [] }
113
+ end
114
+
115
+ def dns_address(plan)
116
+ dns = @runtime.inspect_service(plan.definitions.fetch('dns'),
117
+ expected_id: @state.dig('control_plane', 'services', 'dns', 'id'))
118
+ address = dns&.dig('networks', plan.network, 'IPAddress')
119
+ raise Error, 'Empeira DNS is unavailable; run empeira up first' if address.to_s.empty?
120
+
121
+ address
122
+ end
123
+
124
+ def ssh_credentials(hostname)
125
+ SSHCredentials.new(context: context, runner: @runner, provider: 'container', hostname: hostname)
126
+ end
127
+
128
+ def configure(resource, record)
129
+ { 'certname' => record.fetch('hostname'), 'server' => 'server.empeira.internal',
130
+ 'environment' => context.configuration.dig('server', 'environment') }.each do |key, value|
131
+ result = @runtime.service_exec(resource,
132
+ [Certificates::PUPPET, 'config', 'set', key, value, '--section', 'main'])
133
+ raise Error, 'Node agent configuration failed; node retained for diagnosis' unless result.success?
134
+ end
135
+ refresh_dns
136
+ end
137
+
138
+ def refresh_dns
139
+ plan = ControlPlane::Plan.new(context: context)
140
+ ControlPlane::Discovery.new(plan: plan, runtime: @runtime, state: @state).refresh
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ module ContainerState
6
+ def list
7
+ load_state
8
+ records = container_records
9
+ return [] if records.empty?
10
+
11
+ check_runtime!
12
+ definitions = records.transform_values { |record| definition(record) }
13
+ resources = @runtime.inspect_services(definitions, expected_ids: records.transform_values do |record|
14
+ record['id']
15
+ end)
16
+ records.map { |name, record| inventory_row(record, resources[name]) }
17
+ end
18
+
19
+ def inspect_resource(name:)
20
+ load_state
21
+ record = @nodes[name.downcase]
22
+ return unless record
23
+
24
+ check_runtime!
25
+ resource = observed(record)
26
+ return unless resource
27
+
28
+ state = record['provisioned'] && resource['state'] == 'running' ? :running : :stopped
29
+ Providers::Resource.new(name: record.fetch('hostname'), owner: context.workspace.id, state: state)
30
+ end
31
+
32
+ private
33
+
34
+ def inventory_row(record, resource)
35
+ verify_network!(resource, record) if resource
36
+ record.slice('hostname', 'provider', 'runtime', 'os', 'version', 'last_puppet_exit').merge(
37
+ 'state' => state_of(record, resource)
38
+ )
39
+ end
40
+
41
+ def container_records
42
+ @nodes.select { |_, record| record['provider'] == 'container' }
43
+ end
44
+
45
+ def lifecycle_result(name, state, changed:)
46
+ resource = Providers::Resource.new(name: name, owner: context.workspace.id, state: state)
47
+ Providers::Result.new(resource: resource, changed: changed)
48
+ end
49
+
50
+ def mutate(availability: true)
51
+ @build_info.require_compatible!(context.configuration.dig('requirements', 'empeira'))
52
+ @store.with_lock do
53
+ load_state
54
+ check_runtime!(availability: availability)
55
+ yield
56
+ end
57
+ end
58
+
59
+ def load_state
60
+ @state = @store.load
61
+ @nodes = @state&.fetch('nodes', {}) || {}
62
+ end
63
+
64
+ def save
65
+ @state['nodes'] = @nodes
66
+ @store.write(@state)
67
+ end
68
+
69
+ def check_runtime!(availability: true)
70
+ if @state && @state.fetch('runtime') != context.container_engine
71
+ raise Error, 'Node runtime differs from workspace ownership; restore runtime.container_engine'
72
+ end
73
+
74
+ @runtime.check_available! if availability
75
+ end
76
+
77
+ def fetch(name)
78
+ @nodes.fetch(name.downcase) { raise Providers::NotFound, 'Node does not exist in this workspace' }
79
+ end
80
+
81
+ def definition(record)
82
+ Definition.new(hostname: record.fetch('hostname'), workspace: context.workspace,
83
+ **record.fetch('definition').except('hostname').transform_keys(&:to_sym))
84
+ end
85
+
86
+ def observed(record)
87
+ resource = @runtime.inspect_service(definition(record), expected_id: record['id'])
88
+ verify_network!(resource, record) if resource
89
+ resource
90
+ end
91
+
92
+ def verify_network!(resource, record)
93
+ network = Network::Definition.new(workspace: context.workspace, policy: Network::Policy.new).backend_name
94
+ return if resource.fetch('networks').keys == [network] && SSHEndpoint.valid?(resource, record)
95
+
96
+ raise Providers::OwnershipError, 'Node has unexpected network membership or published ports'
97
+ end
98
+
99
+ def running(record)
100
+ resource = observed(record)
101
+ raise Error, 'Node is missing; destroy its reservation before recreating it' unless resource
102
+ unless resource['state'] == 'running'
103
+ raise Error,
104
+ "Node #{record.fetch('hostname')} is stopped; " \
105
+ "start it with: empeira node start #{record.fetch('hostname')}"
106
+ end
107
+
108
+ resource
109
+ end
110
+
111
+ def state_of(record, resource)
112
+ return 'missing' unless resource
113
+ return 'incomplete' unless record['provisioned']
114
+ return 'stale' unless resource.dig('labels', 'io.empeira.definition') == definition(record).fingerprint
115
+ return 'stale' unless Image.new(config: context.configuration, request: request_for(record),
116
+ architecture: record['architecture']).reference == record['image']
117
+
118
+ resource['state'] == 'exited' ? 'stopped' : resource['state']
119
+ rescue ConfigurationError
120
+ 'stale'
121
+ end
122
+
123
+ def request_for(record)
124
+ RunRequest.new(**record.slice('hostname', 'provider', 'os', 'version', 'memory', 'cpus')
125
+ .transform_keys(&:to_sym))
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ class Definition < Services::Definition
6
+ attr_reader :hostname
7
+
8
+ def initialize(hostname:, workspace:, **)
9
+ @hostname = hostname
10
+ super(key: "node-#{Digest::SHA256.hexdigest(hostname)[0, 24]}", workspace: workspace,
11
+ hostname: hostname, **)
12
+ end
13
+
14
+ def ownership_labels
15
+ super.merge('io.empeira.purpose' => 'node', 'io.empeira.hostname' => hostname,
16
+ 'io.empeira.provider' => 'container')
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ module DiagnosticCommand
6
+ def self.validate!(arguments, timeout)
7
+ raise ConfigurationError, 'Provide a guest command and arguments after --' unless valid_arguments?(arguments)
8
+ return if timeout.nil? || (timeout.is_a?(Numeric) && timeout.finite? && timeout.positive? && timeout <= 86_400)
9
+
10
+ raise ConfigurationError, 'Command timeout must be positive and at most 86400 seconds'
11
+ end
12
+
13
+ def self.valid_arguments?(arguments)
14
+ arguments.is_a?(Array) && !arguments.empty? && !arguments.first.to_s.empty? &&
15
+ arguments.all? { |argument| argument.is_a?(String) && !argument.include?("\0") }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,202 @@
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
+ # DNF metadata is queried against the isolated source directory only.
8
+ # rubocop:disable-next Metrics/ClassLength -- DNF and RPM share one isolated source and its temporary trusted keys.
9
+ class DnfAgentRepository < AgentRepository
10
+ REPO_PATH = '/var/tmp/empeira-agent-repos/agent.repo'
11
+ KEY_PATH = '/var/tmp/empeira-agent-rpm-key.gpg'
12
+ CONFIG_PATH = '/var/tmp/empeira-agent-dnf.conf'
13
+ PACKAGE_PATH = '/var/tmp/empeira-agent-package.rpm'
14
+ QUERY_FORMAT = '%{name}|%{epoch}|%{version}|%{release}|%{arch}|%{reponame}'
15
+
16
+ def resolve
17
+ @release_package = nil
18
+ @release_sources = {}
19
+ @repository_ids = []
20
+ install_source
21
+ result = native!([*dnf_command, 'repoquery', "--archlist=#{@target.native_architecture},noarch",
22
+ '--qf', QUERY_FORMAT, @package], 'DNF package resolution')
23
+ candidates = result.stdout.lines.filter_map { |line| dnf_candidate(line) }
24
+ candidate = Agent::Versions.select(candidates, @version, suffix: @source['suffix'])
25
+ candidate.merge('url' => package_url(candidate))
26
+ ensure
27
+ remove_release_package if @release_package
28
+ end
29
+
30
+ def prepare_keys
31
+ key = super
32
+ execute!(['rpmkeys', '--import', key], 'RPM signing key import') if key
33
+ key
34
+ end
35
+
36
+ def inspect_package(path)
37
+ @copy.call(path, PACKAGE_PATH, '0600')
38
+ result = execute!(['rpm', '-qp', '--qf', '%{NAME}|%{EPOCHNUM}:%{VERSION}-%{RELEASE}|%{ARCH}', PACKAGE_PATH],
39
+ 'RPM package inspection')
40
+ name, version, architecture = result.stdout.strip.split('|')
41
+ version = version.delete_prefix('0:') if version
42
+ metadata = validate_metadata(name, version, architecture)
43
+ verify_signature unless @source['verify_signatures'] == false
44
+ metadata
45
+ end
46
+
47
+ private
48
+
49
+ def install_source
50
+ execute!(['mkdir', '-p', File.dirname(REPO_PATH)], 'source preparation')
51
+ if @source['url'].end_with?('.rpm')
52
+ install_release(@source)
53
+ collect_release_sources
54
+ prepare_keys
55
+ else
56
+ @repository_ids = ['empeira-agent']
57
+ prepare_keys
58
+ copy_text(repo_content, REPO_PATH, mode: '0600')
59
+ end
60
+ copy_text("[main]\ngpgcheck=1\nsslverify=1\nlogfilelevel=0\nreposdir=#{File.dirname(REPO_PATH)}\n",
61
+ CONFIG_PATH, mode: '0600')
62
+ end
63
+
64
+ def install_release(artifact)
65
+ path = download_artifact(artifact, 'release.rpm')
66
+ destination = '/var/tmp/empeira-agent-release.rpm'
67
+ @copy.call(path, destination, '0600')
68
+ name = execute!(['rpm', '-qp', '--qf', '%{NAME}', destination], 'release metadata').stdout.strip
69
+ require_absent_release!(name)
70
+ @release_package = name
71
+ execute!(['rpm', '-i', destination], 'release package installation')
72
+ end
73
+
74
+ def collect_release_sources
75
+ files = execute!(['rpm', '-ql', @release_package], 'release source discovery').stdout.lines.map(&:strip)
76
+ files = files.select { |file| file.start_with?('/etc/yum.repos.d/') && file.end_with?('.repo') }
77
+ raise Error, 'Agent release package provides no native DNF source' if files.empty?
78
+
79
+ files.each_with_index do |file, index|
80
+ content = execute!(['cat', file], 'release repository configuration').stdout
81
+ import_release_keys(content)
82
+ store_release_source(content, index)
83
+ end
84
+ end
85
+
86
+ def store_release_source(content, index)
87
+ path = "#{File.dirname(REPO_PATH)}/#{index}.repo"
88
+ scoped = Agent::ReleaseSources.dnf(content, target: @target, source: @source, package: @package)
89
+ @repository_ids.concat(scoped.scan(/^\[([^\]]+)\]/).flatten)
90
+ @release_sources[path] = scoped
91
+ copy_text(scoped, path, mode: '0600')
92
+ end
93
+
94
+ def import_release_keys(content)
95
+ content.scan(/^\s*gpgkey\s*=\s*(.+)$/).flatten.flat_map(&:split).each_with_index do |url, index|
96
+ path = release_key(url, index)
97
+ @public_keys << Base64.strict_encode64(File.binread(path))
98
+ guest_path = "#{KEY_PATH}-#{index}"
99
+ @copy.call(path, guest_path, '0600')
100
+ execute!(['rpmkeys', '--import', guest_path], 'RPM signing key import')
101
+ end
102
+ end
103
+
104
+ def release_key(url, index)
105
+ path = File.join(@directory, "release-key-#{index}")
106
+ if url.start_with?('file:///')
107
+ key = execute!(['cat', URI(url).path], 'release signing key').stdout
108
+ File.write(path, key, perm: 0o600)
109
+ else
110
+ @download.fetch(url, path, operation: 'Download agent release signing key')
111
+ end
112
+ path
113
+ end
114
+
115
+ def repo_content
116
+ credentials = @authentication.credentials
117
+ lines = ['[empeira-agent]', 'name=Empeira temporary agent source', "baseurl=#{@source.fetch('url')}",
118
+ 'enabled=1', "gpgcheck=#{@source['verify_signatures'] == false ? 0 : 1}",
119
+ 'sslverify=1', 'skip_if_unavailable=0', "includepkgs=#{@package}"]
120
+ lines.push("username=#{credentials.first}", "password=#{credentials.last}") if credentials
121
+ "#{lines.join("\n")}\n"
122
+ end
123
+
124
+ def install_auth
125
+ if @release_package
126
+ @release_sources.each do |path, content|
127
+ copy_text(authenticated_release_content(content), path, mode: '0600')
128
+ end
129
+ return
130
+ end
131
+
132
+ copy_text(repo_content, REPO_PATH, mode: '0600')
133
+ end
134
+
135
+ def authenticated_release_content(content)
136
+ credentials = @authentication.credentials
137
+ return content unless credentials
138
+
139
+ content.gsub(/^(\s*baseurl\s*=\s*)(\S+)$/) do
140
+ line = Regexp.last_match(0)
141
+ url = @target.expand(Regexp.last_match(2))
142
+ if @authentication.credentials(url)
143
+ "#{line}\nusername=#{credentials.first}\npassword=#{credentials.last}"
144
+ else
145
+ line
146
+ end
147
+ end
148
+ end
149
+
150
+ def dnf_command
151
+ # --quiet also hides HTTP status details needed for the one authentication retry.
152
+ ['dnf', "--config=#{CONFIG_PATH}", "--releasever=#{@target.release}"]
153
+ end
154
+
155
+ def package_url(candidate)
156
+ spec = "#{@package}-#{candidate.fetch('version')}.#{candidate.fetch('architecture')}"
157
+ urls = native!([*dnf_command, 'repoquery', "--repoid=#{candidate.fetch('repository')}", '--location', spec],
158
+ 'DNF artifact location').stdout.lines
159
+ .map(&:strip).select { |line| line.start_with?('https://') }.uniq
160
+ raise Error, 'Selected agent source did not provide one HTTPS package location' unless urls.size == 1
161
+
162
+ Configuration::AgentSchema.https_url!(urls.first, 'agent resolved package URL')
163
+ urls.first
164
+ end
165
+
166
+ def dnf_candidate(line)
167
+ name, epoch, version, release, architecture, repository = line.strip.split('|')
168
+ return unless name == @package && [@target.native_architecture, 'noarch'].include?(architecture) &&
169
+ @repository_ids.include?(repository)
170
+
171
+ native = "#{epoch}:#{version}-#{release}".delete_prefix('0:')
172
+ { 'version' => native, 'architecture' => architecture, 'repository' => repository }
173
+ end
174
+
175
+ def validate_metadata(name, version, architecture)
176
+ unless name == @package && [@target.native_architecture, 'noarch'].include?(architecture) &&
177
+ Agent::Versions.match?(version.to_s, @version, suffix: @source['suffix'])
178
+ raise Error, 'Agent package name, native version or architecture differs from the requested target'
179
+ end
180
+
181
+ { 'version' => version, 'architecture' => architecture }
182
+ end
183
+
184
+ def verify_signature
185
+ result = execute!(['rpmkeys', '--checksig', '--verbose', PACKAGE_PATH], 'RPM package signature verification')
186
+ return if result.stdout.match?(/Signature[^\n]*:\s*OK/i) && !result.stdout.match?(/NOKEY|NOT OK/i)
187
+
188
+ raise Error, 'Agent RPM package has no verifiable trusted signature'
189
+ end
190
+
191
+ def release_inventory_arguments
192
+ ['rpm', '-qa', '--qf', "%{NAME}\n"]
193
+ end
194
+
195
+ def release_removal_arguments
196
+ ['rpm', '-e', '--', @release_package]
197
+ end
198
+ end
199
+ end
200
+ end
201
+
202
+ # rubocop:enable Style/FormatStringToken
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # Logical provider and the fixed native Hiera selector are present before the first catalog.
6
+ module ExternalFact
7
+ PATH = '/etc/puppetlabs/facter/facts.d/empeira.yaml'
8
+ PROVIDERS = %w[container vm].freeze
9
+
10
+ def self.content(provider)
11
+ raise ConfigurationError, 'Unknown Empeira node provider' unless PROVIDERS.include?(provider)
12
+
13
+ "empeira:\n provider: #{provider}\n hierarchy: empeira\n"
14
+ end
15
+
16
+ def self.fingerprint(provider)
17
+ Digest::SHA256.hexdigest(content(provider))
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ class Image
6
+ attr_reader :reference, :recipe, :build_files
7
+
8
+ def initialize(config:, request:, architecture:)
9
+ image = config.dig('images', 'nodes', request.os, request.version)
10
+ raise ConfigurationError, 'node_defaults.os/version must select a configured images.nodes entry' unless image
11
+ unless image.fetch('architectures', []).include?(architecture.to_s)
12
+ raise Error, 'Node image does not support runtime architecture; configure images.nodes without emulation'
13
+ end
14
+
15
+ @build_files = {}
16
+ @reference = Images::Configuration.reference(image, registry: config.dig('images', 'registry'))
17
+ return if image['reference'] || !image['build']
18
+
19
+ prepare_recipe(config, image)
20
+ @reference = Images::Configuration.local_image(recipe, purpose: "node-#{architecture}")
21
+ end
22
+
23
+ private
24
+
25
+ def prepare_recipe(config, image)
26
+ root = Pathname(Images::Configuration::ROOT).join('nodes/ssh')
27
+ @build_files = { 'node-start' => root.join('start').read, 'sshd_config' => root.join('sshd_config').read,
28
+ 'node-connect' => root.join('../../network/ssh_connect.rb').read }
29
+ @recipe = build_recipe(config, image) + "\n# SSH resources: #{Infrastructure::Definition.fingerprint(@build_files)}\n"
30
+ end
31
+
32
+ def build_recipe(config, image)
33
+ template = Images::Configuration.recipe(image, registry: config.dig('images', 'registry'))
34
+ template.gsub('@BASE_IMAGE@', reference)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # Agent tools are exposed through owned interactive OS startup fragments.
6
+ class InteractiveTools
7
+ def initialize(record:, execute:, persist:)
8
+ @record = record
9
+ @execute = execute
10
+ @persist = persist
11
+ end
12
+
13
+ def self.valid_inventory?(entry)
14
+ entry == { 'version' => 1 }
15
+ end
16
+
17
+ def reconcile
18
+ helper = Pathname(__dir__).join('../../../resources/nodes/interactive_tools.rb').read
19
+ @record['interactive_tools'] = { 'version' => 1 }
20
+ @persist.call
21
+ result = @execute.call([Certificates::RUBY, '-e', helper, JSON.generate('puppet' => Certificates::PUPPET)])
22
+ unless result.success?
23
+ details = Execution::Diagnostics.command(result, operation: 'Interactive agent tool PATH', tool: 'ruby')
24
+ raise Error, "Cannot configure interactive agent tools; node retained for diagnosis\n#{details}", cause: nil
25
+ end
26
+
27
+ JSON.parse(result.stdout).fetch('changed')
28
+ rescue JSON::ParserError, KeyError
29
+ raise Error, 'Cannot verify interactive agent tool PATH; node retained for diagnosis', cause: nil
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ class Interface < Providers::Lifecycle
6
+ attr_reader :backend
7
+
8
+ def initialize(backend:, **dependencies)
9
+ super(**dependencies)
10
+ @backend = backend
11
+ end
12
+
13
+ def run(request)
14
+ super(name: request.hostname, request: request)
15
+ end
16
+ end
17
+ end
18
+ end