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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ class Certificates
6
+ PUPPET = '/opt/puppetlabs/bin/puppet'
7
+ CA = '/opt/puppetlabs/bin/puppetserver'
8
+ RUBY = '/opt/puppetlabs/puppet/bin/ruby'
9
+
10
+ def initialize(runtime:, server:)
11
+ @runtime = runtime
12
+ @server = server
13
+ end
14
+
15
+ def enroll(node, record)
16
+ name = record.fetch('hostname')
17
+ raise Error, 'A CA identity already exists for this hostname; refusing to adopt it' if entry(name)
18
+
19
+ install_ca(node)
20
+ execute(node, [PUPPET, 'ssl', 'generate_request'], 'Node key preparation')
21
+ fingerprint = public_key(node, "/etc/puppetlabs/puppet/ssl/certificate_requests/#{name}.pem", request: true)
22
+ record['certificate_key'] = fingerprint
23
+ yield
24
+ execute(node, [PUPPET, 'ssl', 'submit_request'], 'Node certificate request')
25
+ request = entry(name)
26
+ unless request && fingerprint == ca_key(name, request: true)
27
+ raise Error, 'CA request does not match the Empeira-owned node key; signing refused'
28
+ end
29
+
30
+ execute(@server, [CA, 'ca', 'sign', '--certname', name], 'Node certificate signing')
31
+ execute(node, [PUPPET, 'ssl', 'download_cert'], 'Node certificate retrieval')
32
+ end
33
+
34
+ def clean(record)
35
+ certificate = entry(record.fetch('hostname'))
36
+ return unless certificate
37
+
38
+ request = certificate.fetch('state') == 'requested'
39
+ unless record['certificate_key'] && record['certificate_key'] == ca_key(record.fetch('hostname'),
40
+ request: request)
41
+ raise Error, 'CA identity differs from the recorded node key; certificate cleanup refused'
42
+ end
43
+
44
+ execute(@server, [CA, 'ca', 'clean', '--certname', record.fetch('hostname')], 'Node certificate cleanup')
45
+ end
46
+
47
+ def clean_retired(state, name)
48
+ retired = state.fetch('retired_certificates', {})
49
+ return unless retired.key?(name)
50
+
51
+ clean('hostname' => name, 'certificate_key' => retired.fetch(name))
52
+ retired.delete(name)
53
+ yield
54
+ end
55
+
56
+ private
57
+
58
+ def entry(name)
59
+ result = execute(@server, [CA, 'ca', 'list', '--all', '--format', 'json'], 'CA inventory')
60
+ data = JSON.parse(result.stdout)
61
+ data.values.flatten.find { |item| item.fetch('name') == name }
62
+ rescue JSON::ParserError, NoMethodError, KeyError
63
+ raise Error, 'Malformed CA inventory; certificate mutation refused', cause: nil
64
+ end
65
+
66
+ def ca_key(name, request:)
67
+ directory = request ? 'requests' : 'signed'
68
+ public_key(@server, "/etc/puppetlabs/puppetserver/ca/#{directory}/#{name}.pem", request: request)
69
+ end
70
+
71
+ def public_key(resource, path, request:)
72
+ type = request ? 'Request' : 'Certificate'
73
+ code = "require 'openssl'; require 'digest'; cert = OpenSSL::X509::#{type}.new(File.read(ARGV[0])); print Digest::SHA256.hexdigest(cert.public_key.to_der)"
74
+ execute(resource, [RUBY, '-e', code, path], 'Certificate identity verification').stdout
75
+ end
76
+
77
+ def install_ca(node)
78
+ Dir.mktmpdir('empeira-ca-') do |directory|
79
+ file = File.join(directory, 'ca.pem')
80
+ @runtime.copy_from(@server, '/etc/puppetlabs/puppet/ssl/certs/ca.pem', file)
81
+ execute(node, ['mkdir', '-p', '/etc/puppetlabs/puppet/ssl/certs'], 'Node CA directory')
82
+ @runtime.copy_to(node, file, '/etc/puppetlabs/puppet/ssl/certs/ca.pem')
83
+ end
84
+ end
85
+
86
+ def execute(resource, arguments, operation)
87
+ result = @runtime.service_exec(resource, arguments, timeout: 60)
88
+ unless result.success?
89
+ diagnostic = Execution::Diagnostics.native(result, operation: operation,
90
+ tool: File.basename(arguments.first))
91
+ raise Error, "#{operation} failed; node state retained for diagnosis\n#{diagnostic}", cause: nil
92
+ end
93
+
94
+ result
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Empeira
6
+ module Node
7
+ # A guest file resource with the same ownership labels and fingerprint contract as services.
8
+ class CommandMock < Services::Definition
9
+ attr_reader :command
10
+
11
+ def initialize(command:, workspace:, record:, definition:)
12
+ @command = command
13
+ super(key: "command-mock-#{command}", workspace: workspace, **definition.transform_keys(&:to_sym),
14
+ hostname: record.fetch('hostname'), provider: record.fetch('provider'))
15
+ end
16
+
17
+ def ownership_labels
18
+ super.merge('io.empeira.hostname' => options.fetch('hostname'),
19
+ 'io.empeira.provider' => options.fetch('provider'))
20
+ end
21
+
22
+ def file
23
+ Bootstrap::FileEntry.new(path: options.fetch('path'), content: content, mode: 0o755).freeze
24
+ end
25
+
26
+ def content
27
+ invocation = if options.fetch('mock_to') == 'echo'
28
+ "printf '%s' #{Shellwords.escape(command)}\n" \
29
+ "[ \"$#\" -eq 0 ] || printf ' %s' \"$@\"\nprintf '\\n'\n"
30
+ else
31
+ "#{Shellwords.escape(options.fetch('mock_to'))} \"$@\"\n"
32
+ end
33
+ status = options.fetch('exit_code') == 'passthrough' ? '$?' : options.fetch('exit_code').to_s
34
+ "#!/bin/sh\n# #{JSON.generate(labels)}\n#{invocation}exit #{status}\n"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # Reconciles guest resources under the existing workspace transaction.
6
+ class CommandMocks
7
+ def initialize(context:, record:, execute:, persist:)
8
+ @context = context
9
+ @record = record
10
+ @execute = execute
11
+ @persist = persist
12
+ end
13
+
14
+ def reconcile
15
+ desired = @context.configuration.fetch('mocks').fetch('commands')
16
+ return false if desired.empty? && @record.fetch('command_mocks', {}).empty?
17
+
18
+ @entries = @record['command_mocks'] ||= {}
19
+ changed = prune(desired)
20
+ desired.each do |name, definition|
21
+ changed = apply(name, definition) || changed
22
+ end
23
+ changed
24
+ end
25
+
26
+ def self.valid_inventory?(entries)
27
+ return false unless entries.is_a?(Hash)
28
+
29
+ entries.all? { |path, entry| valid_entry?(path, entry) }
30
+ end
31
+
32
+ def self.valid_entry?(path, entry)
33
+ entry.is_a?(Hash) && (entry.keys - %w[name definition fingerprint previous]).empty? &&
34
+ valid_definition?(path, entry['name'], entry['definition']) &&
35
+ entry['fingerprint'] == Infrastructure::Definition.fingerprint(entry['definition']) &&
36
+ (!entry.key?('previous') || valid_definition?(path, entry['name'], entry['previous']))
37
+ end
38
+
39
+ def self.valid_definition?(path, name, definition)
40
+ Configuration::CommandMocks.validate!({ name => definition }, effective: true)
41
+ definition['path'] == path
42
+ rescue ConfigurationError
43
+ false
44
+ end
45
+
46
+ private
47
+
48
+ def resource(name, definition)
49
+ CommandMock.new(command: name, definition: definition, workspace: @context.workspace, record: @record)
50
+ end
51
+
52
+ def accepted(entry)
53
+ [entry.fetch('definition'), entry['previous']].compact.map do |definition|
54
+ resource(entry.fetch('name'), definition).file.content
55
+ end
56
+ end
57
+
58
+ def prune(desired)
59
+ changed = false
60
+ @entries.each_key do |path|
61
+ entry = @entries.fetch(path)
62
+ next if desired.dig(entry.fetch('name'), 'path') == path
63
+
64
+ changed = execute(path, nil, accepted(entry)) || changed
65
+ @entries.delete(path)
66
+ @persist.call
67
+ end
68
+ changed
69
+ end
70
+
71
+ def apply(name, definition)
72
+ path = definition.fetch('path')
73
+ entry = @entries[path]
74
+ return finish(entry) if entry && entry['definition'] == definition
75
+
76
+ # Finish interrupted writes before accepting another desired revision.
77
+ recovered = entry&.key?('previous') ? finish(entry) : false
78
+ entry = { 'name' => name, 'definition' => definition,
79
+ 'fingerprint' => Infrastructure::Definition.fingerprint(definition),
80
+ **(entry ? { 'previous' => entry.fetch('definition') } : {}) }
81
+ @entries[path] = entry
82
+ @persist.call
83
+ finish(entry) || recovered
84
+ end
85
+
86
+ def finish(entry)
87
+ file = resource(entry.fetch('name'), entry.fetch('definition')).file
88
+ changed = execute(file.path, file.content, accepted(entry))
89
+ @persist.call if entry.delete('previous')
90
+ changed
91
+ end
92
+
93
+ def execute(path, content, accepted)
94
+ helper = File.read(File.expand_path('../../../resources/nodes/managed_file.rb', __dir__))
95
+ request = JSON.generate('path' => path, 'content' => content, 'accepted' => accepted, 'mode' => 0o755)
96
+ result = @execute.call([Certificates::RUBY, '-e', helper, request])
97
+ unless result.success?
98
+ unless @execute.call([Certificates::RUBY, '--version']).success?
99
+ raise Error,
100
+ "Cannot run node agent Ruby at #{Certificates::RUBY}; " \
101
+ 'the agent installation or guest execution is unavailable. Inspect the node and its image.'
102
+ end
103
+
104
+ raise Providers::OwnershipError,
105
+ "Cannot reconcile command mock at #{path}: unsafe target, cleanup conflict or guest I/O failure"
106
+ end
107
+
108
+ JSON.parse(result.stdout).fetch('changed')
109
+ rescue JSON::ParserError, KeyError
110
+ raise Error, "Cannot verify command-mock reconciliation at #{path}"
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # Container provider owns its workflow; the public service only routes logical providers.
6
+ # rubocop:disable-next Metrics/ClassLength -- Ordered provisioning and lifecycle share this provider boundary.
7
+ class Container < Interface
8
+ include ContainerLifecycle
9
+ include ContainerPreparation
10
+ include ContainerAgentBootstrap
11
+ include ContainerState
12
+ include ContainerGuest
13
+
14
+ attr_reader :context
15
+
16
+ def initialize(context:, runner:, backend:, build_info: BuildInfo.load, progress: Progress.new)
17
+ super(name: 'container', context: context, runner: runner, backend: backend)
18
+ @runtime = backend
19
+ @build_info = build_info
20
+ @progress = progress
21
+ @store = Infrastructure::Store.new(context: context)
22
+ @bootstrap_proxy = Network::BootstrapProxy.new(context: context, runtime: @runtime, store: @store)
23
+ end
24
+
25
+ def available?
26
+ @runtime.available?
27
+ end
28
+
29
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- Verify retired certificate ownership before the existing enrollment transaction.
30
+ def run(request)
31
+ mutate do
32
+ @progress.stage(10, 'Checking control plane and reserving hostname...')
33
+ server = ready_server
34
+ record, definition, image = reserve(request)
35
+ prepare_image(image, record)
36
+ resource = create_node(definition, record)
37
+ @progress.stage(65, 'Preparing node certificate...')
38
+ certificates = Certificates.new(runtime: @runtime, server: server)
39
+ certificates.clean_retired(@state, record.fetch('hostname')) { save } if @state['retired_certificates']
40
+ certificates.enroll(resource, record) { save }
41
+ reconcile_guest(resource, record)
42
+ complete_provisioning(record)
43
+ @progress.stage(85, 'Running Puppet agent...')
44
+ agent_run(resource, record)
45
+ lifecycle_result(record['hostname'], :running, changed: true)
46
+ end
47
+ end
48
+
49
+ def destroy_all(state:)
50
+ @state = state
51
+ @nodes = state.fetch('nodes', {})
52
+ @nodes.each { |name, record| remove_node(name, certificates: false) if record['provider'] == 'container' }
53
+ end
54
+
55
+ # rubocop:disable-next Naming/PredicateMethod -- Reconciliation mutates guest resources.
56
+ def reconcile_all(state:)
57
+ @state = state
58
+ @nodes = state.fetch('nodes', {})
59
+ container_records.values.map do |record|
60
+ resource = observed(record)
61
+ network_gateway.route(resource) if resource && resource['state'] == 'running'
62
+ next false unless record['provisioned'] && resource && resource['state'] == 'running'
63
+
64
+ reconcile_guest(resource, record)
65
+ end.any?
66
+ end
67
+
68
+ private
69
+
70
+ def complete_provisioning(record)
71
+ record['provisioned'] = true
72
+ save
73
+ end
74
+
75
+ def reconcile_command_mocks(resource, record)
76
+ CommandMocks.new(context: context, record: record, persist: method(:save),
77
+ execute: ->(arguments) { @runtime.service_exec(resource, arguments) }).reconcile
78
+ end
79
+
80
+ def prepare_image(image, record)
81
+ @progress.stage(25, 'Preparing node image...')
82
+ @runtime.with_local_images do
83
+ @runtime.ensure_image(image.reference, recipe: image.recipe, files: image.build_files)
84
+ return if @runtime.image_architecture(image.reference) == record['architecture']
85
+ end
86
+
87
+ raise Error, 'Node image architecture differs from the runtime; emulation is not enabled'
88
+ end
89
+
90
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- Ordered bootstrap cleanup precedes normal guest policy.
91
+ def create_node(definition, record)
92
+ @progress.stage(45, 'Creating isolated container node...')
93
+ resource = @runtime.create_service(definition)
94
+ record['id'] = resource.fetch('id')
95
+ record['image_id'] = resource['image_id']
96
+ save
97
+ network_gateway.phase(record, bootstrap: true)
98
+ @runtime.start_service(resource)
99
+ resource = observed(record)
100
+ network_gateway.route(resource)
101
+ record['ssh_port'] = SSHEndpoint.port(resource) if record['ssh_transport'] == 'loopback'
102
+ save
103
+ apply_bootstrap(resource, record)
104
+ finish_network_bootstrap(record)
105
+ prepare_guest_environment(resource, record)
106
+ configure(resource, record)
107
+ resource
108
+ end
109
+
110
+ def apply_bootstrap(resource, record)
111
+ apply_agent_bootstrap(resource, record)
112
+ ensure
113
+ @bootstrap_proxy.cleanup(@state) if @state
114
+ end
115
+
116
+ def package_bootstrap(resource, record)
117
+ execute = ->(arguments) { @runtime.service_exec(resource, arguments, timeout: 300) }
118
+ requirements = package_requirements(record)
119
+ PackageBootstrap.new(config: context.configuration.dig('bootstrap', 'packages'),
120
+ os: record.fetch('os'), execute: execute,
121
+ copy: guest_copy(resource, execute),
122
+ rpm_options: requirements.rpm_options,
123
+ progress: @progress.method(:heartbeat))
124
+ end
125
+
126
+ def package_requirements(record, agent_required: false)
127
+ ::Empeira::VM::BootstrapRequirements.new(context: context, os: record.fetch('os'),
128
+ version: record.fetch('version'), agent_required: agent_required,
129
+ distribution_required: true,
130
+ architecture: record.fetch('architecture'))
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # Runs the selected agent installation through container guest I/O.
6
+ module ContainerAgentBootstrap
7
+ private
8
+
9
+ # rubocop:disable-next Metrics/AbcSize -- The ordered guest transaction spans packages, APT and agent installation.
10
+ def apply_agent_bootstrap(resource, record)
11
+ execute = ->(arguments) { @runtime.service_exec(resource, arguments, timeout: 300) }
12
+ requirements = package_requirements(record, agent_required: true)
13
+ @bootstrap_proxy.start(@state, requirements, source: record.dig('definition', 'ip'))
14
+ family = PackageBootstrap::FAMILIES.fetch(record.fetch('os'))
15
+ configuration = family == 'debian' ? AptConfiguration : RpmConfiguration
16
+ configuration.new(os: record.fetch('os'), execute: execute).preserve do
17
+ packages = package_bootstrap(resource, record)
18
+ packages.run(proxy_url: @bootstrap_proxy.url) if packages.required?
19
+ install_agent(resource, record, requirements, execute)
20
+ end
21
+ ContainerBootstrap.new(runtime: @runtime).apply(resource: resource,
22
+ bootstrap: Bootstrap.new(provider: 'container'))
23
+ record['empeira_fact'] = ExternalFact.fingerprint('container')
24
+ save
25
+ PackageSources.verify!(execute: execute)
26
+ end
27
+
28
+ def install_agent(resource, _record, requirements, execute)
29
+ installer = AgentInstaller.build(context: context, runtime: @runtime, requirements: requirements,
30
+ execute: execute, copy: guest_copy(resource, execute), progress: @progress)
31
+ installer.install(proxy_url: @bootstrap_proxy.url)
32
+ end
33
+
34
+ def guest_copy(resource, execute)
35
+ lambda do |source, destination, mode|
36
+ @runtime.copy_to(resource, source, destination)
37
+ raise Error, 'Cannot set agent file permissions' unless
38
+ execute.call(['chmod', mode, destination]).success?
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # Containers use the already installed agent Ruby runtime, never Cloud-Init.
6
+ class ContainerBootstrap
7
+ INSTALL = <<~RUBY
8
+ require 'fileutils'
9
+ path, content, mode = ARGV
10
+ FileUtils.mkdir_p(File.dirname(path), mode: 0755)
11
+ File.write(path, content, mode: 'w', perm: mode.to_i(8))
12
+ File.chmod(mode.to_i(8), path)
13
+ RUBY
14
+ VERIFY = <<~RUBY
15
+ path, content, mode = ARGV
16
+ exit(File.binread(path) == content && (File.stat(path).mode & 0777) == mode.to_i(8) ? 0 : 1)
17
+ RUBY
18
+
19
+ def initialize(runtime:)
20
+ @runtime = runtime
21
+ end
22
+
23
+ def apply(resource:, bootstrap:)
24
+ bootstrap.files.each do |file|
25
+ run!(resource, INSTALL, file, 'install')
26
+ run!(resource, VERIFY, file, 'verify')
27
+ end
28
+ end
29
+
30
+ # rubocop:disable-next Naming/PredicateMethod -- Reconciliation mutates owned guest state and reports changes.
31
+ def reconcile(resource:, bootstrap:)
32
+ bootstrap.files.map do |file|
33
+ arguments = [Certificates::RUBY, '-e', VERIFY, file.path, file.content, format('%04o', file.mode)]
34
+ next false if @runtime.service_exec(resource, arguments).success?
35
+
36
+ run!(resource, INSTALL, file, 'install')
37
+ run!(resource, VERIFY, file, 'verify')
38
+ true
39
+ end.any?
40
+ end
41
+
42
+ private
43
+
44
+ def run!(resource, code, file, action)
45
+ arguments = [Certificates::RUBY, '-e', code, file.path, file.content, format('%04o', file.mode)]
46
+ return if @runtime.service_exec(resource, arguments).success?
47
+
48
+ raise Error, "Cannot #{action} required Empeira node bootstrap file #{file.path}; Puppet was not run"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ module ContainerGuest
6
+ private
7
+
8
+ def reconcile_guest(resource, record)
9
+ fact_changed = reconcile_provider_fact(resource, record)
10
+ environment_changed = prepare_guest_environment(resource, record)
11
+ reconcile_command_mocks(resource, record) || environment_changed || fact_changed
12
+ end
13
+
14
+ # rubocop:disable-next Naming/PredicateMethod -- Reconciliation mutates owned guest state and reports changes.
15
+ def reconcile_provider_fact(resource, record)
16
+ digest = ExternalFact.fingerprint('container')
17
+ return false if record['empeira_fact'] == digest
18
+
19
+ ContainerBootstrap.new(runtime: @runtime).reconcile(resource: resource,
20
+ bootstrap: Bootstrap.new(provider: 'container'))
21
+ record['empeira_fact'] = digest
22
+ save
23
+ true
24
+ end
25
+
26
+ def prepare_guest_environment(resource, record)
27
+ proxy_changed = reconcile_runtime_proxy(resource, record)
28
+ reconcile_interactive_tools(resource, record) || proxy_changed
29
+ end
30
+
31
+ def reconcile_interactive_tools(resource, record)
32
+ InteractiveTools.new(record: record, persist: method(:save),
33
+ execute: ->(arguments) { @runtime.service_exec(resource, arguments) }).reconcile
34
+ end
35
+
36
+ def reconcile_runtime_proxy(resource, record)
37
+ RuntimeProxy.new(context: context, record: record, persist: method(:save),
38
+ execute: ->(arguments) { @runtime.service_exec(resource, arguments) }).reconcile
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Node
5
+ # rubocop:disable-next Metrics/ModuleLength -- Provider lifecycle operations share one ownership boundary.
6
+ module ContainerLifecycle
7
+ def start(name:)
8
+ mutate do
9
+ record = fetch(name)
10
+ require_provisioned!(record)
11
+ resource = observed(record)
12
+ raise Error, 'Node is missing; destroy its reservation before recreating it' unless resource
13
+
14
+ ready_server
15
+ changed = resource['state'] != 'running'
16
+ @runtime.start_service(resource) if changed
17
+ network_gateway.route(observed(record))
18
+ refresh_dns
19
+ changed = reconcile_guest(resource, record) || changed
20
+ lifecycle_result(record['hostname'], :running, changed: changed)
21
+ end
22
+ end
23
+
24
+ def stop(name:)
25
+ mutate do
26
+ resource = observed(fetch(name))
27
+ raise Error, 'Node is missing; inspect node list' unless resource
28
+
29
+ changed = resource['state'] == 'running'
30
+ @runtime.stop_service(resource) if changed
31
+ lifecycle_result(name.downcase, :stopped, changed: changed)
32
+ end
33
+ end
34
+
35
+ def destroy(name:)
36
+ Infrastructure::Cleanup.new(context: context, runner: @runner, runtime: @runtime,
37
+ progress: @progress).node(name)
38
+ end
39
+
40
+ def puppet(name:)
41
+ mutate(availability: false) do
42
+ record = fetch(name)
43
+ require_provisioned!(record)
44
+ resource = running(record)
45
+ ready_network!
46
+ @progress.stage(30, 'Running Puppet agent...')
47
+ agent_run(resource, record)
48
+ end
49
+ end
50
+
51
+ def shell(name:)
52
+ mutate do
53
+ resource = running(fetch(name))
54
+ raise Error, 'Node shell requires a local terminal' unless $stdin.tty? && $stdout.tty?
55
+
56
+ arguments = RuntimeProxy.command(context, ['env', 'HISTFILE=/dev/null', '/bin/bash', '-i'])
57
+ @runtime.stream_service(resource, arguments, interactive: true, user: '0')
58
+ end
59
+ end
60
+
61
+ def console(name:)
62
+ raise Error, "Container node #{name} has no serial VM console; use node shell or node exec"
63
+ end
64
+
65
+ def exec(name:, arguments:, timeout: nil, output: $stdout, error: $stderr)
66
+ DiagnosticCommand.validate!(arguments, timeout)
67
+ mutate do
68
+ Execution::Terminal.with_interrupts do
69
+ @progress.streaming do
70
+ @runtime.diagnostic_exec(running(fetch(name)), arguments, timeout: timeout, output: output, error: error)
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- Verify the owned endpoint before selecting a guest tunnel.
77
+ def ssh(name:, user: nil, identity: nil, port: nil)
78
+ mutate do
79
+ port = Configuration::SSHPreferences.port!(port || 22)
80
+ record = fetch(name)
81
+ resource = running(record)
82
+ unless record['ssh_host'] && record['ssh_port']
83
+ raise Error, 'Node has no managed SSH endpoint; destroy and recreate it with the current Empeira image'
84
+ end
85
+
86
+ proxy = if port != 22
87
+ @runtime.ssh_proxy_command(resource, port: port)
88
+ elsif record['ssh_transport'] == 'tunnel'
89
+ @runtime.ssh_proxy_command(resource)
90
+ end
91
+ record = record.merge('ssh_port' => port) if port != 22
92
+ UserSSH.new(runner: @runner, credentials: ssh_credentials(record.fetch('hostname')),
93
+ proxy_command: proxy, home: context.locations.home).session(
94
+ record, user: user, identity: identity
95
+ )
96
+ end
97
+ end
98
+
99
+ def logs(name:)
100
+ load_state
101
+ check_runtime!
102
+ resource = observed(fetch(name))
103
+ raise Error, 'Node container is missing' unless resource
104
+
105
+ @runtime.stream_service(resource, [], logs: true)
106
+ end
107
+
108
+ private
109
+
110
+ def require_provisioned!(record)
111
+ return if record['provisioned']
112
+
113
+ raise Error, 'Node bootstrap is incomplete; inspect logs or shell, then destroy and recreate the node'
114
+ end
115
+
116
+ def remove_node(name, certificates: true)
117
+ record = @nodes[name]
118
+ return unless record
119
+
120
+ observed(record)
121
+ if certificates && record['certificate_key']
122
+ Certificates.new(runtime: @runtime, server: ready_server).clean(record)
123
+ end
124
+ @runtime.remove_service(definition(record), expected_id: record['id'])
125
+ ssh_credentials(name).cleanup
126
+ @nodes.delete(name)
127
+ save
128
+ true
129
+ end
130
+
131
+ def agent_run(resource, record)
132
+ require_provisioned!(record)
133
+ reconcile_provider_fact(resource, record)
134
+ result = @progress.streaming do
135
+ @runtime.stream_service(resource, RuntimeProxy.command(context, PuppetCommand.arguments))
136
+ end
137
+ record['last_puppet_exit'] = result.exit_status
138
+ save
139
+ return result if [0, 2].include?(result.exit_status)
140
+
141
+ raise Error, "Puppet agent failed (exit #{result.exit_status}); node retained for shell/logs and another run"
142
+ end
143
+ end
144
+ end
145
+ end