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,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Modules
5
+ class GitSafety
6
+ def initialize(project:, runner:, progress: Progress.new)
7
+ @project = project
8
+ @runner = runner
9
+ @progress = progress
10
+ end
11
+
12
+ def verify(path, recommend: false)
13
+ relative = relative_path(path)
14
+ return unless relative
15
+ raise Error, 'The managed module path cannot be the control-repository root' if relative == '.'
16
+
17
+ tracked = git('ls-files', '--cached', '-z', '--', ":(literal)#{relative}")
18
+ raise Error, 'Cannot verify Git-tracked contents of the managed module path' unless tracked.success?
19
+ unless tracked.stdout.empty?
20
+ raise Error,
21
+ 'The managed module path contains Git-tracked files; refusing to modify it'
22
+ end
23
+
24
+ recommend_ignore(relative) if recommend
25
+ end
26
+
27
+ def self.canonical_path(path)
28
+ return path.realpath if path.exist? || path.symlink?
29
+
30
+ canonical_path(path.parent).join(path.basename)
31
+ rescue SystemCallError
32
+ raise Error, 'Cannot resolve the managed module path for Git safety checks', cause: nil
33
+ end
34
+
35
+ private
36
+
37
+ def relative_path(path)
38
+ canonical = self.class.canonical_path(path.expand_path)
39
+ return unless canonical == @project || canonical.to_s.start_with?("#{@project}/")
40
+
41
+ canonical.relative_path_from(@project).to_s
42
+ end
43
+
44
+ def recommend_ignore(relative)
45
+ ignored = git('check-ignore', '--quiet', '--', "#{relative}/")
46
+ return if ignored.success? || @recommended
47
+ unless ignored.exit_status == 1 && !ignored.timed_out
48
+ raise Error, 'Cannot determine whether Git ignores the managed module path'
49
+ end
50
+
51
+ @progress.warning("Recommendation: #{relative}/ is not ignored by Git. Add /#{relative}/ to .gitignore.")
52
+ @recommended = true
53
+ end
54
+
55
+ def git(*arguments)
56
+ @runner.run('git', arguments: arguments, directory: @project, timeout: 15)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Empeira
6
+ module Modules
7
+ # Source acquisition deliberately uses the invoking user's Git/OpenSSH context.
8
+ # Persistent bare repositories are mounted read-only; SSH credentials never enter the helper.
9
+ class HostGit
10
+ def initialize(runner:, project:, progress: Progress.new, passive: false)
11
+ @runner = runner
12
+ @project = project
13
+ @progress = progress
14
+ @passive = passive
15
+ end
16
+
17
+ # rubocop:disable-next Metrics/AbcSize -- Acquire each host source and notify its module before network access.
18
+ def prepare(entries, directory:)
19
+ validate!(entries)
20
+ entries.to_h do |entry|
21
+ block_given? ? yield(entry.fetch('name')) : @progress.heartbeat(entry.fetch('name'))
22
+ mirror = source_path(directory, entry.fetch('remote'))
23
+
24
+ result = clone(acquisition_arguments(entry.fetch('remote'), mirror))
25
+ fail_acquisition(entry.fetch('name'), result) unless result.success?
26
+ [entry.fetch('name'), mirror.to_s]
27
+ end
28
+ end
29
+
30
+ def self.valid_remote?(remote)
31
+ return false unless remote.is_a?(String) && !remote.start_with?('-') && !remote.match?(/[\x00-\x1f]/)
32
+ return remote.match?(/\A(?:[\w.-]+@)?[\w.-]+:[^:].*\z/) unless remote.include?('://')
33
+
34
+ valid_uri?(URI.parse(remote))
35
+ rescue URI::InvalidURIError
36
+ false
37
+ end
38
+
39
+ def self.valid_uri?(uri)
40
+ %w[ssh http https].include?(uri.scheme) && uri.host && !uri.host.start_with?('-') &&
41
+ (uri.scheme == 'ssh' ? uri.password.nil? : uri.userinfo.nil?)
42
+ end
43
+
44
+ private
45
+
46
+ def source_path(directory, remote)
47
+ path = Pathname(directory).join("#{Digest::SHA256.hexdigest(remote)}.git")
48
+ raise Error, 'Git source cache must not be a symlink' if path.symlink?
49
+
50
+ path
51
+ end
52
+
53
+ def acquisition_arguments(remote, mirror)
54
+ return ['clone', '--mirror', '--template=', '--', remote, mirror.to_s] unless mirror.exist?
55
+
56
+ ['-C', mirror.to_s, 'fetch', '--prune', '--force', '--', remote, '+refs/*:refs/*']
57
+ end
58
+
59
+ def fail_acquisition(name, result)
60
+ diagnostic = Execution::Diagnostics.native(result, operation: "Acquire Git source for #{name}", tool: 'git/ssh')
61
+ raise Error, "Module: #{name}\nError: Git source acquisition failed" \
62
+ "#{' (timed out)' if result.timed_out}\n\ngit/ssh output:\n#{diagnostic}", cause: nil
63
+ end
64
+
65
+ def clone(arguments)
66
+ if @passive
67
+ arguments = ['-c', "core.hooksPath=#{File::NULL}", '-c', 'core.fsmonitor=false',
68
+ '-c', 'submodule.recurse=false', '-c', 'protocol.ext.allow=never', *arguments]
69
+ end
70
+ if $stdin.tty? && $stderr.tty?
71
+ @runner.buffered('git', arguments: arguments, directory: @project)
72
+ else
73
+ @runner.run('git', arguments: arguments, directory: @project, timeout: 300)
74
+ end
75
+ end
76
+
77
+ def validate!(entries)
78
+ unless entries.is_a?(Array) && entries.all? { |entry| valid_entry?(entry) } &&
79
+ entries.map { |entry| entry['name'] }.uniq.size == entries.size
80
+ raise Error, 'Puppetfile installer returned an invalid Git source plan'
81
+ end
82
+ end
83
+
84
+ def valid_entry?(entry)
85
+ entry.is_a?(Hash) && entry.keys.sort == %w[name remote] &&
86
+ entry['name'].is_a?(String) && entry['name'].match?(Configuration::HieraMountSchema::NAME) &&
87
+ self.class.valid_remote?(entry['remote'])
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Modules
5
+ class Image
6
+ def initialize(config, registry: nil)
7
+ @config = config
8
+ @registry = registry
9
+ end
10
+
11
+ def files
12
+ %w[Gemfile Gemfile.lock worker.rb].to_h do |name|
13
+ [name, File.read(File.join(Images::Configuration::ROOT, 'modules', name))]
14
+ end
15
+ end
16
+
17
+ def recipe
18
+ source = Images::Configuration.recipe(@config, registry: @registry)
19
+ "#{source}\n# Managed build inputs: #{Infrastructure::Definition.fingerprint(files)}\n"
20
+ end
21
+
22
+ def reference
23
+ Images::Configuration.local_image(recipe, purpose: 'r10k')
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Modules
5
+ class Installer
6
+ def initialize(runtime:, image:, git:, progress: Progress.new)
7
+ @git = git
8
+ @runtime = runtime
9
+ @image = image
10
+ @progress = progress
11
+ @events = Events.new(progress: progress)
12
+ end
13
+
14
+ # rubocop:disable-next Metrics/AbcSize -- Plan, host acquisition, sync and cleanup share one disposable helper.
15
+ def synchronize(request, destination, state:)
16
+ request.data.fetch('overrides').each { |name| @progress.heartbeat("#{name} (local override)") }
17
+ Dir.mktmpdir('empeira-puppetfile-') do |input|
18
+ write_input(request, input, state)
19
+ @sources = state.sources
20
+ @runtime.with_update_helper(image: @image, input: input, output: destination,
21
+ sources: state.sources, cache: state.cache) do |resource|
22
+ @resource = resource
23
+ execute('plan')
24
+ acquire_git
25
+ execute('sync')
26
+ @events.finish!
27
+ @names | request.data.fetch('overrides')
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def execute(mode)
35
+ command = ['ruby', '/opt/empeira-modules/worker.rb', '/work', mode]
36
+ result = @runtime.service_exec(@resource, command, timeout: 600, on_stdout: @events.method(:observe))
37
+ return if result.success?
38
+
39
+ diagnostic = Execution::Diagnostics.native(result, operation: "Puppetfile #{mode}", tool: 'r10k')
40
+ raise Error, "Puppetfile synchronization failed#{' (timed out)' if result.timed_out}:\n" \
41
+ "#{"Module: #{@events.name}\n" if @events.name}#{diagnostic}", cause: nil
42
+ end
43
+
44
+ def acquire_git
45
+ Dir.mktmpdir('empeira-git-sources-') do |directory|
46
+ plan = File.join(directory, 'git-sources.json')
47
+ @runtime.copy_from(@resource, '/work/git-sources.json', plan)
48
+ plan_data = JSON.parse(File.read(plan))
49
+ @names = plan_data.fetch('names')
50
+ acquired = @git.prepare(plan_data.fetch('git'), directory: @sources) { |name| @events.start(name) }
51
+ path = File.join(directory, 'acquired.json')
52
+ File.write(path, JSON.generate(acquired))
53
+ @runtime.copy_to(@resource, path, '/work/acquired.json') unless acquired.empty?
54
+ end
55
+ rescue JSON::ParserError, SystemCallError
56
+ raise Error, 'Cannot transfer the Puppetfile Git source plan or cache locations', cause: nil
57
+ end
58
+
59
+ def write_input(request, directory, state)
60
+ data = request.data.merge('sources' => state.sources.to_s, 'cache' => state.cache.to_s)
61
+ { 'Puppetfile' => request.puppetfile, 'request.json' => JSON.generate(data) }.each do |name, content|
62
+ File.write(File.join(directory, name), content, mode: 'wx', perm: 0o600)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Modules
5
+ class Request
6
+ attr_reader :puppetfile, :data
7
+
8
+ def initialize(context:)
9
+ @context = context
10
+ @project = context.project.path
11
+ @config = context.configuration
12
+ @hiera = Configuration::HieraMounts.new(config: @config.fetch('hiera'), project: @project,
13
+ environment: @config.dig('server', 'environment'))
14
+ @overrides = @hiera.entries.select { |entry| entry['type'] == 'module' && entry['status'] == 'available' }
15
+ .map { |entry| entry.fetch('name') }.sort
16
+ load_file
17
+ end
18
+
19
+ def warnings
20
+ @hiera.warnings
21
+ end
22
+
23
+ def present?
24
+ !puppetfile.nil?
25
+ end
26
+
27
+ def verify_available!
28
+ return unless present?
29
+
30
+ root = Storage.new(context: @context).root
31
+ return if State.new(context: @context).available?(root, overrides: data.fetch('overrides'))
32
+
33
+ raise Error, "Puppetfile modules are not available.\nRun: empeira update modules"
34
+ end
35
+
36
+ private
37
+
38
+ def load_file
39
+ path = @project.join('Puppetfile')
40
+ return unless path.exist? || path.symlink?
41
+ raise ConfigurationError, 'Puppetfile must be a readable regular file inside the control repository' unless
42
+ path.file? && path.realpath.to_s.start_with?("#{@project}/")
43
+
44
+ verify_modulepath!
45
+ validate_environment_mounts!
46
+ @puppetfile = path.binread
47
+ @data = { 'overrides' => @overrides }
48
+ rescue SystemCallError
49
+ raise ConfigurationError, 'Cannot read Puppetfile or control-repository modules', cause: nil
50
+ end
51
+
52
+ def verify_modulepath!
53
+ environment = @config.dig('server', 'environment')
54
+ Configuration::HieraModulepath.new(project: @project, environment: environment).verify!
55
+ end
56
+
57
+ def validate_environment_mounts!
58
+ @hiera.entries.each_with_index do |entry, index|
59
+ next unless entry['status'] == 'available' && entry['type'] == 'environment'
60
+ next unless Configuration::HieraMountSchema.overlap?(entry.fetch('target'), 'modules')
61
+
62
+ raise ConfigurationError,
63
+ "hiera.mounts.#{index}.target overlaps Puppetfile modules; use type: module for local overrides"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Modules
5
+ # Native tooling caches and a small optional list of installed names, never input/tree fingerprints.
6
+ class State
7
+ attr_reader :sources, :cache
8
+
9
+ def initialize(context:)
10
+ @workspace = context.workspace.id
11
+ @directory = context.locations.workspace(context.workspace)
12
+ @path = @directory.join('module-sync.json')
13
+ @sources = @directory.join('r10k', 'sources')
14
+ @cache = @directory.join('r10k', 'cache')
15
+ end
16
+
17
+ def available?(root, overrides: [])
18
+ return false unless root.directory?
19
+
20
+ names = installed_names(root)
21
+ !names.nil? && names.all? { |name| overrides.include?(name) || populated?(root.join(name)) }
22
+ rescue SystemCallError, JSON::ParserError
23
+ raise Error, 'Cannot read module synchronization metadata', cause: nil
24
+ end
25
+
26
+ def prepare
27
+ [sources.parent, sources, cache].each do |path|
28
+ raise Error, 'r10k cache directories must not be symlinks' if path.symlink?
29
+
30
+ FileUtils.mkdir_p(path, mode: 0o700)
31
+ end
32
+ end
33
+
34
+ def invalidate
35
+ raise Error, 'Module synchronization metadata must not be a symlink' if @path.symlink?
36
+
37
+ @path.unlink if @path.exist?
38
+ end
39
+
40
+ def record(root, names)
41
+ data = { 'version' => 1, 'workspace' => @workspace, 'path' => root.to_s, 'names' => names }
42
+ Tempfile.create(['module-sync-', '.json'], @directory) do |file|
43
+ file.chmod(0o600)
44
+ file.write(JSON.generate(data))
45
+ file.flush
46
+ File.rename(file.path, @path)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def installed_names(root)
53
+ raise Error, 'Module synchronization metadata must not be a symlink' if @path.symlink?
54
+
55
+ data = read if @path.exist?
56
+ data && data['path'] == root.to_s ? data['names'] : existing_names(root)
57
+ end
58
+
59
+ def read
60
+ data = JSON.parse(@path.read)
61
+ raise Error, 'Invalid module synchronization metadata; inspect the workspace state' unless valid?(data)
62
+
63
+ data
64
+ end
65
+
66
+ def valid?(data)
67
+ data.is_a?(Hash) && data['workspace'] == @workspace && data['version'] == 1 &&
68
+ data['path'].is_a?(String) && valid_names?(data['names'])
69
+ end
70
+
71
+ def valid_names?(names)
72
+ names.is_a?(Array) && names.all? { |name| name.is_a?(String) && name.match?(Configuration::HieraMountSchema::NAME) }
73
+ end
74
+
75
+ def existing_names(root)
76
+ names = root.children.filter_map do |path|
77
+ path.basename.to_s if populated?(path) && path.basename.to_s.match?(Configuration::HieraMountSchema::NAME)
78
+ end
79
+ names unless names.empty?
80
+ end
81
+
82
+ def populated?(path)
83
+ path.directory? && !path.symlink? && !path.children.empty?
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Modules
5
+ # The configured path is the module directory itself, never a deployment cache.
6
+ class Storage
7
+ attr_reader :root
8
+
9
+ def initialize(context:)
10
+ project = context.project.path
11
+ @root = GitSafety.canonical_path(Pathname(context.configuration.dig('modules', 'path')).expand_path(project))
12
+ validate_root!(project)
13
+ end
14
+
15
+ def prepare
16
+ raise Error, 'modules.path must be a directory' if root.exist? && !root.directory?
17
+
18
+ FileUtils.mkdir_p(root)
19
+ rescue SystemCallError
20
+ raise Error, 'Cannot create the configured module directory', cause: nil
21
+ end
22
+
23
+ private
24
+
25
+ def validate_root!(project)
26
+ git = project.join('.git')
27
+ return unless root == project || project.to_s.start_with?("#{root}/") || root == root.parent ||
28
+ root == git || root.to_s.start_with?("#{git}/")
29
+
30
+ raise ConfigurationError,
31
+ 'modules.path must not contain the control repository, a filesystem root or Git metadata'
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Empeira
6
+ module Network
7
+ # A temporary authenticated Squid gateway, managed under the workspace transaction.
8
+ # No credentials enter inventory, image definitions, guest seeds or logs.
9
+ # rubocop:disable-next Metrics/ClassLength -- One ownership transaction coordinates temporary gateway recovery.
10
+ class BootstrapProxy
11
+ attr_reader :url
12
+
13
+ def initialize(context:, runtime:, store:)
14
+ @context = context
15
+ @runtime = runtime
16
+ @store = store
17
+ @plan = ControlPlane::Plan.new(context: context)
18
+ @files = @plan.files
19
+ end
20
+
21
+ def preflight!(state)
22
+ @plan.subnet = state.fetch('peer_network').fetch('subnet')
23
+ @dns = service_address(state, 'dns')
24
+ service_address(state, 'gateway')
25
+ egress = Network::Egress.new(workspace: @context.workspace, policy: Network::Policy.new)
26
+ resource = @runtime.inspect_network(identifier: egress.backend_name)
27
+ egress.verify_ownership!(resource, expected_id: state.dig('control_plane', 'egress'))
28
+ raise Error, 'Control-plane egress is unavailable; run empeira up first' unless resource
29
+
30
+ egress.verify_isolation!(resource)
31
+ end
32
+
33
+ def start(state, requirements, source:)
34
+ preflight!(state)
35
+ cleanup(state)
36
+ reject_unrecorded!
37
+ token = SecureRandom.hex(32)
38
+ prepare_policy(token, requirements, source)
39
+ definition = @plan.bootstrap_proxy(dns: @dns)
40
+ @runtime.ensure_image(definition.options.fetch('image'), recipe: definition.options['recipe'])
41
+ resource = create(state, definition)
42
+ address = connect_and_verify(resource, definition, state)
43
+ @url = "http://bootstrap:#{token}@#{address}:3128"
44
+ wait_ready(state, address)
45
+ address
46
+ end
47
+
48
+ def cleanup(state)
49
+ return unless state.key?('bootstrap_proxy')
50
+
51
+ entry = state.fetch('bootstrap_proxy')
52
+ definition = Services::Definition.new(key: 'bootstrap-proxy', workspace: @context.workspace)
53
+ remove_owned(definition, entry.fetch('id'))
54
+ policy = @files.path('bootstrap-squid.conf')
55
+ File.unlink(policy) if File.exist?(policy) || File.symlink?(policy)
56
+ state.delete('bootstrap_proxy')
57
+ @store.write(state)
58
+ @url = nil
59
+ rescue SystemCallError
60
+ raise Error, 'Cannot remove bootstrap proxy credentials; cleanup is incomplete and Puppet was not run'
61
+ end
62
+
63
+ private
64
+
65
+ def connect_and_verify(resource, definition, state)
66
+ @runtime.start_service(resource)
67
+ observed = @runtime.inspect_service(definition, expected_id: resource.fetch('id'))
68
+ address = verify_endpoint(observed)
69
+ Gateway.new(context: @context, runtime: @runtime, state: state).route(observed)
70
+ address
71
+ end
72
+
73
+ def reject_unrecorded!
74
+ definition = Services::Definition.new(key: 'bootstrap-proxy', workspace: @context.workspace)
75
+ return unless @runtime.inspect_service(definition)
76
+
77
+ raise Providers::OwnershipError, 'Unrecorded bootstrap proxy exists; refusing adoption'
78
+ end
79
+
80
+ def remove_owned(definition, id)
81
+ @runtime.remove_service(definition, expected_id: id)
82
+ rescue Providers::ExecutionError
83
+ resource = @runtime.inspect_service(definition, expected_id: id)
84
+ @runtime.stop_service(resource) if resource && resource['state'] == 'running'
85
+ raise
86
+ end
87
+
88
+ def create(state, definition)
89
+ state['bootstrap_proxy'] = { 'id' => nil }
90
+ @store.write(state)
91
+ resource = @runtime.create_service(definition)
92
+ state['bootstrap_proxy']['id'] = resource.fetch('id')
93
+ @store.write(state)
94
+ resource
95
+ end
96
+
97
+ def service_address(state, key)
98
+ expected = state.dig('control_plane', 'services', key, 'id')
99
+ raise Error, "#{key} is not recorded; run empeira up first" unless expected
100
+
101
+ resource = @runtime.inspect_service(@plan.definitions.fetch(key), expected_id: expected)
102
+ address = resource&.dig('networks', @plan.network, 'IPAddress')
103
+ raise Error, "#{key} is unavailable; run empeira up first" unless resource&.dig('state') == 'running' && address
104
+
105
+ IPAddr.new(address).to_s
106
+ end
107
+
108
+ def prepare_policy(token, requirements, source)
109
+ authorization = ["bootstrap:#{token}"].pack('m0')
110
+ config = { 'global' => requirements.destinations }
111
+ policy = ProxyPolicy.new(config, authorization: authorization)
112
+ content = policy.configuration.gsub('@EMPEIRA_DNS@', @dns)
113
+ IPAddr.new(source)
114
+ content = "acl provisioning_vm src #{source}/32\nhttp_access deny !provisioning_vm\n#{content}"
115
+ @files.write('bootstrap-squid.conf', content)
116
+ end
117
+
118
+ def verify_endpoint(resource)
119
+ expected = [@plan.network]
120
+ unless resource && resource['state'] == 'running' && resource.fetch('networks').keys.sort == expected &&
121
+ unpublished?(resource)
122
+ raise Providers::OwnershipError, 'Bootstrap proxy has unexpected network attachments or published ports'
123
+ end
124
+
125
+ IPAddr.new(resource.fetch('networks').fetch(@plan.network).fetch('IPAddress')).to_s
126
+ end
127
+
128
+ def unpublished?(resource)
129
+ resource.fetch('ports').empty? && resource.fetch('published_ports').values.all?(&:nil?)
130
+ end
131
+
132
+ def readiness_probe
133
+ 's=TCPSocket.new(ARGV[0],3128);s.write("GET http://bootstrap.invalid/ HTTP/1.0\r\n\r\n");' \
134
+ 'line=s.gets.to_s;abort(line) unless line.match?(/HTTP\/1\.[01] 403 /)'
135
+ end
136
+
137
+ def wait_ready(state, address)
138
+ server = @runtime.inspect_service(@plan.definitions.fetch('server'),
139
+ expected_id: state.dig('control_plane', 'services', 'server', 'id'))
140
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30
141
+ loop do
142
+ result = @runtime.service_exec(server, [Node::Certificates::RUBY, '-rsocket', '-e',
143
+ readiness_probe, address], timeout: 3)
144
+ return if result.success?
145
+
146
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
147
+ detail = Execution::Diagnostics.native(result, operation: 'Bootstrap proxy readiness', tool: 'ruby',
148
+ url: 'http://bootstrap.invalid/')
149
+ raise Error, "Bootstrap proxy did not become reachable on the peer network\n#{detail}", cause: nil
150
+ end
151
+
152
+ sleep 0.2
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ class Definition
6
+ LABEL_PREFIX = 'io.empeira'
7
+ attr_reader :workspace, :policy
8
+ attr_accessor :allocation
9
+
10
+ def initialize(workspace:, policy:, revision: 2)
11
+ @workspace = workspace
12
+ @policy = policy
13
+ @revision = revision
14
+ end
15
+
16
+ def identity
17
+ "#{workspace.id}:environment"
18
+ end
19
+
20
+ def backend_name
21
+ "empeira-#{workspace.id}-network"
22
+ end
23
+
24
+ def labels
25
+ ownership_labels.merge('io.empeira.definition' => Infrastructure::Definition.fingerprint(to_h))
26
+ end
27
+
28
+ def ownership_labels
29
+ { "#{LABEL_PREFIX}.managed-by" => 'empeira', "#{LABEL_PREFIX}.workspace" => workspace.id,
30
+ "#{LABEL_PREFIX}.purpose" => 'environment-network' }
31
+ end
32
+
33
+ def verify_ownership!(resource, expected_id: nil)
34
+ return unless resource
35
+
36
+ owned = resource.name == backend_name && ownership_labels.all? { |key, value| resource.labels[key] == value }
37
+ return if owned && (expected_id.nil? || resource.id == expected_id)
38
+
39
+ raise Providers::OwnershipError, "Network ownership or recorded ID conflicts for workspace #{workspace.id}"
40
+ end
41
+
42
+ def verify_definition!(resource)
43
+ return if resource.labels['io.empeira.definition'] == labels.fetch('io.empeira.definition')
44
+
45
+ raise Error, 'Network definition is stale or unknown. Run empeira down before recreating it.'
46
+ end
47
+
48
+ def verify_isolation!(resource)
49
+ return if resource&.isolated == true
50
+
51
+ raise UnsupportedPolicy, "Cannot verify isolated network for workspace #{workspace.id}"
52
+ end
53
+
54
+ def to_h
55
+ { 'identity' => identity, 'backend_name' => backend_name,
56
+ 'policy' => { 'isolated' => true, 'controlled_egress' => false },
57
+ 'labels' => ownership_labels, 'revision' => @revision }
58
+ end
59
+ end
60
+ end
61
+ end