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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Addons
5
+ class Installation
6
+ attr_reader :name, :source, :ref
7
+
8
+ def initialize(name:, definition:, context:, runner:)
9
+ @name = name
10
+ @source, @ref = definition.values_at('git', 'ref')
11
+ @runner = runner
12
+ @project = context.project.path
13
+ @storage = Storage.new(base: cache_root(context), source: source, ref: ref)
14
+ rescue Empeira::Error, SystemCallError => e
15
+ failure(e)
16
+ end
17
+
18
+ def local
19
+ record = @storage.installed
20
+ return unless record
21
+
22
+ commit, path = record
23
+ [commit, path, Manifest.new(name: name, path: path)]
24
+ rescue Empeira::Error, SystemCallError => e
25
+ failure(e)
26
+ end
27
+
28
+ def synchronize(missing_only: false)
29
+ return local if missing_only && local
30
+
31
+ @storage.synchronize do
32
+ return local if missing_only && local
33
+
34
+ acquire
35
+ commit = resolve
36
+ materialize(commit)
37
+ @storage.publish(commit)
38
+ local
39
+ end
40
+ rescue Empeira::Error, SystemCallError => e
41
+ failure(e)
42
+ end
43
+
44
+ private
45
+
46
+ def cache_root(context)
47
+ root = Modules::GitSafety.canonical_path(context.locations.addons)
48
+ if root == @project || root.to_s.start_with?("#{@project}/")
49
+ raise Error.new(name, 'user addon cache must be outside the control repository')
50
+ end
51
+
52
+ root
53
+ end
54
+
55
+ def failure(error)
56
+ raise error if error.is_a?(Error)
57
+
58
+ message = error.is_a?(SystemCallError) ? 'cache is inaccessible or installation is incomplete' : error.message
59
+ raise Error.new(name, message), cause: nil
60
+ end
61
+
62
+ def acquire
63
+ git = Modules::HostGit.new(runner: @runner, project: @project, passive: true)
64
+ entries = [{ 'name' => 'addon', 'remote' => source }]
65
+ return git.prepare(entries, directory: @storage.root) if @storage.mirror.exist?
66
+
67
+ @storage.stage do |directory|
68
+ mirror = git.prepare(entries, directory: directory).fetch('addon')
69
+ File.rename(mirror, @storage.mirror)
70
+ end
71
+ end
72
+
73
+ def resolve
74
+ result = git('rev-parse', '--verify', '--end-of-options', "#{ref}^{commit}")
75
+ commit = result.stdout.strip
76
+ unless result.success? && commit.match?(Storage::COMMIT)
77
+ raise Error.new(name, 'Git reference is unknown or does not resolve to a commit'), cause: nil
78
+ end
79
+
80
+ commit
81
+ end
82
+
83
+ def materialize(commit)
84
+ final = @storage.revision(commit)
85
+ return Manifest.new(name: name, path: final) if final.exist?
86
+
87
+ @storage.stage do |directory|
88
+ checkout(commit, directory)
89
+ Manifest.new(name: name, path: directory)
90
+ File.rename(directory, final)
91
+ end
92
+ end
93
+
94
+ def checkout(commit, directory)
95
+ Tempfile.create('.index-', @storage.root) do |index|
96
+ File.unlink(index.path)
97
+ environment = { 'GIT_INDEX_FILE' => index.path }
98
+ tree = git('read-tree', commit, environment: environment)
99
+ raise Error.new(name, 'Git could not read the resolved commit') unless tree.success?
100
+
101
+ files = git('--work-tree', directory.to_s, 'checkout-index', '--all', "--prefix=#{directory}/",
102
+ environment: environment)
103
+ raise Error.new(name, 'Git could not materialize the resolved commit') unless files.success?
104
+ end
105
+ end
106
+
107
+ def git(*arguments, environment: {})
108
+ # Authentication happens only in HostGit. Local export must not run
109
+ # user-configured smudge filters from .gitattributes or hooks.
110
+ environment = { 'GIT_CONFIG_GLOBAL' => File::NULL, 'GIT_CONFIG_NOSYSTEM' => '1',
111
+ 'GIT_CONFIG_COUNT' => '0', 'GIT_CONFIG_PARAMETERS' => nil, 'GIT_ATTR_NOSYSTEM' => '1' }
112
+ .merge(environment)
113
+ @runner.run('git', arguments: ['-c', "core.hooksPath=#{File::NULL}", '-c', 'core.fsmonitor=false',
114
+ '--git-dir', @storage.mirror.to_s, *arguments],
115
+ environment: environment, directory: @project, timeout: 300)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Addons
5
+ class Manifest
6
+ TOKEN = /\A[a-zA-Z0-9][a-zA-Z0-9_-]*\z/
7
+ OPTION = /\A--?[a-zA-Z0-9][a-zA-Z0-9-]*\z/
8
+ COMPLETIONS = %w[file directory enum none].freeze
9
+ attr_reader :data, :executable
10
+
11
+ def initialize(name:, path:)
12
+ @name = name
13
+ @root = Pathname(path).realpath
14
+ manifest = contained!('empeira-addon.yaml', 'manifest')
15
+ @data = Configuration::Document.new.read(manifest)
16
+ validate!
17
+ @executable = contained!(data.fetch('executable'), 'executable')
18
+ fail!('executable is not executable', 126) unless @executable.executable?
19
+ Immutable.deep_freeze(@data)
20
+ rescue ConfigurationError
21
+ fail!('manifest must contain valid, unique YAML data without aliases or object tags')
22
+ rescue SystemCallError
23
+ fail!('installation or manifest is missing or inaccessible')
24
+ end
25
+
26
+ private
27
+
28
+ def fail!(message, status = 1)
29
+ raise Error.new(@name, message, exit_status: status), cause: nil
30
+ end
31
+
32
+ def contained!(relative, label)
33
+ fail!("#{label} must be a relative path without traversal") unless relative?(relative)
34
+ path = @root.join(relative)
35
+ fail!("#{label} is missing", label == 'executable' ? 127 : 1) unless path.file?
36
+ fail!("#{label} escapes the addon directory") unless path.realpath.to_s.start_with?("#{@root}/")
37
+ path.realpath
38
+ end
39
+
40
+ def relative?(value)
41
+ value.is_a?(String) && !value.empty? && !value.match?(/[\p{Cc}\p{Cf}]/) &&
42
+ !Pathname(value).absolute? && !value.split('/').intersect?(%w[. ..])
43
+ end
44
+
45
+ def validate!
46
+ mapping!(data, %w[schema description executable commands], 'manifest', required: true)
47
+ unless data['schema'].is_a?(Integer) && data['schema'] == 1
48
+ fail!('unsupported manifest schema; expected integer 1')
49
+ end
50
+ text!(data['description'], 'description')
51
+ commands!(data['commands'])
52
+ end
53
+
54
+ def commands!(commands)
55
+ fail!('commands must be a mapping') unless commands.is_a?(Hash)
56
+ commands.each do |name, command|
57
+ fail!('invalid command name') unless name.is_a?(String) && name.match?(TOKEN)
58
+ command!(command)
59
+ end
60
+ end
61
+
62
+ def command!(command)
63
+ mapping!(command, %w[description arguments options], 'command')
64
+ text!(command['description'], 'command description')
65
+ arguments = command.fetch('arguments', [])
66
+ fail!('arguments must be an array') unless arguments.is_a?(Array)
67
+ arguments.each { |argument| argument!(argument) }
68
+ options!(command.fetch('options', {}))
69
+ end
70
+
71
+ def options!(options)
72
+ fail!('options must be a mapping') unless options.is_a?(Hash)
73
+ options.each do |name, value|
74
+ fail!('invalid option name') unless name.is_a?(String) && name.match?(OPTION)
75
+ completion!(value, option: true)
76
+ end
77
+ end
78
+
79
+ def argument!(argument)
80
+ completion!(argument)
81
+ name = argument['name']
82
+ fail!('argument requires a name') unless name.is_a?(String) && name.match?(TOKEN)
83
+ end
84
+
85
+ def completion!(value, option: false)
86
+ mapping!(value, option ? %w[description completion values] : %w[name completion values], 'completion')
87
+ text!(value['description'], 'option description') if option && value.key?('description')
88
+ fail!('unknown or missing completion type') unless COMPLETIONS.include?(value['completion'])
89
+ return enum!(value['values']) if value['completion'] == 'enum'
90
+
91
+ fail!('values are only supported for enum completion') if value.key?('values')
92
+ end
93
+
94
+ def enum!(values)
95
+ fail!('enum completion requires an array of values') unless values.is_a?(Array) && !values.empty?
96
+ values.each { |value| text!(value, 'enum value') }
97
+ end
98
+
99
+ def mapping!(value, fields, label, required: false)
100
+ return if value.is_a?(Hash) && (value.keys - fields).empty? && (!required || (fields - value.keys).empty?)
101
+
102
+ fail!("#{label} has missing or unsupported fields")
103
+ end
104
+
105
+ def text!(value, label)
106
+ return if value.is_a?(String) && !value.strip.empty? && !value.match?(/[\p{Cc}\p{Cf}]/)
107
+
108
+ fail!("#{label} must be a nonempty string without control characters")
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Addons
5
+ class Service
6
+ def initialize(context:, runner:)
7
+ @context = context
8
+ @runner = runner
9
+ end
10
+
11
+ def names
12
+ @context.configuration.fetch('addons').keys
13
+ end
14
+
15
+ def installation(name)
16
+ definition = @context.configuration.fetch('addons')[name]
17
+ raise Error.new(name, 'not configured') unless definition
18
+
19
+ Installation.new(name: name, definition: definition, context: @context, runner: @runner)
20
+ end
21
+
22
+ def synchronize(missing_only: false)
23
+ errors = names.filter_map do |name|
24
+ installation(name).synchronize(missing_only: missing_only)
25
+ nil
26
+ rescue Error => e
27
+ e.message
28
+ end
29
+ raise Error.new('synchronization', errors.join("\n")) unless errors.empty?
30
+ end
31
+
32
+ def execute(name, arguments)
33
+ entry = installation(name)
34
+ _, path, manifest = entry.synchronize(missing_only: true)
35
+ environment = execution_environment(entry, path, [manifest.executable.to_s, *arguments])
36
+ @runner.replace(manifest.executable.to_s, arguments: arguments, environment: environment)
37
+ rescue Execution::LaunchError => e
38
+ raise Error.new(name, e.message, exit_status: e.exit_status), cause: nil
39
+ rescue ExecutionError => e
40
+ raise Error.new(name, e.message), cause: nil
41
+ end
42
+
43
+ private
44
+
45
+ def execution_environment(entry, path, arguments)
46
+ Environment.new(context: @context, name: entry.name, ref: entry.ref, path: path).export(arguments: arguments)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+ require 'digest'
6
+
7
+ module Empeira
8
+ module Addons
9
+ class Storage
10
+ COMMIT = /\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/
11
+ attr_reader :root, :source_key
12
+
13
+ def initialize(base:, source:, ref:)
14
+ @base = Pathname(base)
15
+ @source_key = Digest::SHA256.hexdigest(source)
16
+ @root = @base.join(source_key)
17
+ @reference = root.join('refs', Digest::SHA256.hexdigest(ref))
18
+ end
19
+
20
+ def installed
21
+ return unless @reference.exist?
22
+
23
+ directories.each { |directory| owned!(directory, directory: true) }
24
+ owned!(@reference)
25
+ commit = @reference.read.strip
26
+ raise Empeira::Error, 'Installation reference is incomplete' unless commit.match?(COMMIT)
27
+
28
+ path = revision(commit)
29
+ owned!(path, directory: true)
30
+ [commit, path]
31
+ end
32
+
33
+ def revision(commit)
34
+ root.join('revisions', commit)
35
+ end
36
+
37
+ def synchronize
38
+ prepare
39
+ File.open(root.join('lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |file|
40
+ owned!(root.join('lock'))
41
+ file.flock(File::LOCK_EX)
42
+ yield
43
+ ensure
44
+ file.flock(File::LOCK_UN)
45
+ end
46
+ end
47
+
48
+ def publish(commit)
49
+ Tempfile.create('.reference-', @reference.dirname) do |file|
50
+ file.write("#{commit}\n")
51
+ file.flush
52
+ file.fsync
53
+ File.rename(file.path, @reference)
54
+ end
55
+ end
56
+
57
+ def stage
58
+ directory = Pathname(Dir.mktmpdir('.staging-', root))
59
+ yield directory
60
+ ensure
61
+ FileUtils.remove_entry(directory) if directory&.exist?
62
+ end
63
+
64
+ def mirror
65
+ root.join("#{source_key}.git")
66
+ end
67
+
68
+ private
69
+
70
+ def prepare
71
+ FileUtils.mkdir_p(@base.dirname, mode: 0o700)
72
+ directories.each do |path|
73
+ begin
74
+ Dir.mkdir(path, 0o700)
75
+ rescue Errno::EEXIST
76
+ # Stable private directories, following the existing agent cache convention.
77
+ end
78
+ owned!(path, directory: true)
79
+ end
80
+ end
81
+
82
+ def directories
83
+ [@base, root, root.join('refs'), root.join('revisions')]
84
+ end
85
+
86
+ def owned!(path, directory: false)
87
+ stat = path.lstat
88
+ valid = stat.uid == Process.uid && !stat.symlink? && stat.mode.nobits?(0o077)
89
+ valid &&= directory ? stat.directory? : stat.file? && stat.nlink == 1
90
+ raise Empeira::Error, 'Addon cache contains an unsafe, missing or foreign entry' unless valid
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Agent
5
+ # One source-resolution and acquisition path for both providers and both install methods.
6
+ class Acquisition
7
+ def initialize(context:, runtime:, progress: Progress.new)
8
+ @context = context
9
+ @runtime = runtime
10
+ @progress = progress
11
+ end
12
+
13
+ def with_package(requirements)
14
+ prepare(requirements)
15
+ request = identity
16
+ acquire = ->(directory) { fetch(directory, request) }
17
+ cache.with_artifact(request, enabled: @context.configuration.dig('agent', 'cache', 'enabled'),
18
+ acquire: acquire, pin: package_pin) do |artifact|
19
+ @progress.heartbeat('Installing the resolved local agent package...')
20
+ yield artifact
21
+ end
22
+ end
23
+
24
+ def preflight!(requirements)
25
+ return unless requirements.required?
26
+
27
+ @runtime.check_available!
28
+ return if @runtime.architecture == requirements.target.architecture
29
+
30
+ raise Error, 'Agent package helper architecture differs from the target; emulation is not enabled'
31
+ end
32
+
33
+ private
34
+
35
+ def cache
36
+ Cache.new(root: @context.locations.cache.join('agents'), temporary: @context.locations.temporary)
37
+ end
38
+
39
+ def prepare(requirements)
40
+ @requirements = requirements
41
+ @target = requirements.target
42
+ @source = requirements.repository
43
+ end
44
+
45
+ def identity
46
+ { 'package' => @requirements.agent.fetch('package'),
47
+ 'requested_version' => @requirements.agent.fetch('version'),
48
+ 'family' => @target.family, 'distribution' => @target.os, 'release' => @target.release,
49
+ 'architecture' => @target.architecture, 'format' => @target.format, 'suffix' => @source['suffix'],
50
+ 'source' => Cache.fingerprint(@source), 'method' => @requirements.agent.dig('install', 'method') }
51
+ end
52
+
53
+ def package_pin
54
+ return @source['sha256'] unless @source['url'].end_with?('.deb', '.rpm') &&
55
+ @requirements.agent.dig('install', 'method') == 'repository'
56
+
57
+ nil
58
+ end
59
+
60
+ def fetch(directory, request)
61
+ @authentication = Authentication.new(url: @source.fetch('url'), progress: @progress)
62
+ @download = Download.new(authentication: @authentication)
63
+ @progress.heartbeat('Resolving and acquiring the agent package...')
64
+ image = helper_image
65
+ @runtime.with_agent_helper(image: image.reference) do |resource|
66
+ resolver = native_source(resource, directory)
67
+ path, metadata, selected = acquire_package(resolver, directory, request)
68
+ describe(path, metadata, selected, resolver, request)
69
+ end
70
+ end
71
+
72
+ # rubocop:disable-next Metrics/AbcSize -- Keep native resolution, acquisition and identity verification together.
73
+ def acquire_package(resolver, directory, request)
74
+ direct = request.fetch('method') == 'package'
75
+ resolver.prepare_keys if direct
76
+ selected = direct ? { 'url' => @source.fetch('url'), 'sha256' => package_pin } : resolver.resolve
77
+ path = Pathname(directory).join("package.#{@target.format}")
78
+ @download.fetch(selected.fetch('url'), path, sha256: package_pin || selected['sha256'])
79
+ metadata = resolver.inspect_package(path)
80
+ if selected['version'] && metadata['version'] != selected['version']
81
+ raise Error, 'Agent package version differs from its native repository metadata'
82
+ end
83
+
84
+ [path, metadata, selected]
85
+ end
86
+
87
+ def describe(path, metadata, selected, resolver, request)
88
+ details = { 'schema' => 1, 'request' => request, 'url' => selected.fetch('url'),
89
+ 'sha256' => Digest::SHA256.file(path).hexdigest, 'size' => path.size,
90
+ 'public_keys' => resolver.public_keys,
91
+ 'verify_signatures' => @source.fetch('verify_signatures', true) }
92
+ Artifact.new(path: path, metadata: metadata.merge(details))
93
+ end
94
+
95
+ def native_source(resource, directory)
96
+ klass = @target.family == 'debian' ? Node::AgentRepository : Node::DnfAgentRepository
97
+ execute = ->(arguments) { @runtime.service_exec(resource, arguments, timeout: 300) }
98
+ copy = lambda do |source, destination, mode|
99
+ @runtime.copy_to(resource, source, destination)
100
+ raise Error, 'Cannot set agent helper file permissions' unless execute.call(['chmod', mode,
101
+ destination]).success?
102
+ end
103
+ klass.new(source: @source, package: @requirements.agent.fetch('package'),
104
+ version: @requirements.agent.fetch('version'), target: @target, execute: execute, copy: copy,
105
+ download: @download, authentication: @authentication, directory: directory)
106
+ end
107
+
108
+ def helper_image
109
+ preflight!(@requirements)
110
+ request = Struct.new(:os, :version).new(@target.os, @target.release)
111
+ image = Node::Image.new(config: @context.configuration, request: request, architecture: @target.architecture)
112
+ @runtime.ensure_image(image.reference, recipe: image.recipe, files: image.build_files)
113
+ raise Error, 'Agent helper image architecture differs from the target' unless
114
+ @runtime.image_architecture(image.reference) == @target.architecture
115
+
116
+ image
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Agent
5
+ Artifact = Data.define(:path, :metadata) do
6
+ def valid_identity?(request)
7
+ metadata.is_a?(Hash) && metadata['schema'] == 1 && metadata['request'] == request &&
8
+ valid_version?(request) && valid_transport? && valid_keys? && valid_architecture?(request)
9
+ end
10
+
11
+ private
12
+
13
+ def valid_version?(request)
14
+ metadata['version'].is_a?(String) &&
15
+ Versions.match?(metadata['version'], request.fetch('requested_version'), suffix: request['suffix'])
16
+ end
17
+
18
+ def valid_transport?
19
+ uri = URI(metadata['url'])
20
+ Configuration::AgentSchema.https_endpoint?(uri) &&
21
+ [true, false].include?(metadata['verify_signatures'])
22
+ rescue URI::InvalidURIError, TypeError, ArgumentError
23
+ false
24
+ end
25
+
26
+ def valid_keys?
27
+ keys = metadata['public_keys']
28
+ keys.is_a?(Array) && keys.all? { |key| key.is_a?(String) && !Base64.strict_decode64(key).empty? }
29
+ rescue ArgumentError
30
+ false
31
+ end
32
+
33
+ def valid_architecture?(request)
34
+ target = Target.new(os: request.fetch('distribution'), release: request.fetch('release'),
35
+ architecture: request.fetch('architecture'))
36
+ generic = request.fetch('format') == 'deb' ? 'all' : 'noarch'
37
+ [target.native_architecture, generic].include?(metadata['architecture'])
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require 'uri'
5
+ require 'base64'
6
+
7
+ module Empeira
8
+ module Agent
9
+ class AuthenticationRequired < Error
10
+ attr_reader :url
11
+
12
+ def initialize(message = 'Agent repository requires authentication', url: nil)
13
+ super(message)
14
+ @url = url
15
+ end
16
+ end
17
+
18
+ class Authentication
19
+ def self.credentials
20
+ values = %w[EMPEIRA_AGENT_REPO_USERNAME EMPEIRA_AGENT_REPO_PASSWORD].map { |key| ENV.fetch(key, nil) }
21
+ return if values.all?(&:nil?)
22
+ return values if valid?(values)
23
+
24
+ raise ConfigurationError, 'Set both nonempty EMPEIRA_AGENT_REPO_USERNAME and ' \
25
+ 'EMPEIRA_AGENT_REPO_PASSWORD for agent repository authentication'
26
+ end
27
+
28
+ def self.valid?(values)
29
+ values.all? { |value| value.is_a?(String) && !value.empty? && !value.match?(/[\x00-\x1f\x7f]/) } &&
30
+ !values.first.include?(':')
31
+ end
32
+
33
+ def initialize(url:, progress: Progress.new, input: $stdin, output: $stderr)
34
+ @origin = URI(url)
35
+ @credentials = self.class.credentials
36
+ @credential_source = @credentials ? 'ENV' : 'interactive'
37
+ @progress = progress
38
+ @input = input
39
+ @output = output
40
+ end
41
+
42
+ def credentials(url = @origin.to_s)
43
+ uri = URI(url)
44
+ @credentials if same_origin?(uri)
45
+ end
46
+
47
+ def attempt
48
+ yield
49
+ rescue AuthenticationRequired => e
50
+ require_retry!(e)
51
+ login_after(e)
52
+ begin
53
+ yield
54
+ rescue AuthenticationRequired => retry_error
55
+ raise Error, failure_message(retry_error), cause: nil
56
+ end
57
+ end
58
+
59
+ def redact(value)
60
+ secrets = [*@credentials, (Base64.strict_encode64(@credentials.join(':')) if @credentials)].compact
61
+ sorted = secrets.sort_by { |secret| -secret.length }
62
+ sorted.reduce(value.to_s) { |text, secret| text.gsub(secret, '[REDACTED]') }
63
+ end
64
+
65
+ private
66
+
67
+ def require_retry!(error)
68
+ if error.url && !same_origin?(URI(error.url))
69
+ raise Error, 'Agent authentication required at a different origin; source credentials were not forwarded.' \
70
+ "\n#{error.message}",
71
+ cause: nil
72
+ end
73
+ raise Error, failure_message(error), cause: nil if @credentials
74
+ end
75
+
76
+ def login_after(error)
77
+ login
78
+ rescue Error => e
79
+ raise Error, "#{e.message}\n#{error.message}", cause: nil
80
+ end
81
+
82
+ def same_origin?(uri)
83
+ [uri.scheme, uri.host&.downcase, uri.port] == [@origin.scheme, @origin.host&.downcase, @origin.port]
84
+ end
85
+
86
+ def failure_message(error)
87
+ source = @credential_source == 'ENV' ? 'ENV credentials were not replaced' : 'interactive login was rejected'
88
+ "Agent repository authentication failed: #{source}.\n#{error.message}"
89
+ end
90
+
91
+ def login
92
+ unless @input.tty? && @output.tty?
93
+ raise Error, 'Agent repository requires authentication; set EMPEIRA_AGENT_REPO_USERNAME and ' \
94
+ 'EMPEIRA_AGENT_REPO_PASSWORD in noninteractive environments'
95
+ end
96
+
97
+ @progress.streaming { read_credentials }
98
+ end
99
+
100
+ def read_credentials
101
+ @output.puts("Agent repository requires authentication: #{@origin.host}")
102
+ @output.print('Log in? [y/N] ')
103
+ @output.flush
104
+ raise Error, 'Agent repository login cancelled' unless @input.gets&.strip&.match?(/\Ay(?:es)?\z/i)
105
+
106
+ username = read_value('Username: ')
107
+ password = read_value('Password: ', secret: true)
108
+ @output.puts
109
+ values = [username, password]
110
+ raise Error, 'Agent repository login cancelled or credentials are invalid' unless self.class.valid?(values)
111
+
112
+ @credentials = values
113
+ end
114
+
115
+ def read_value(prompt, secret: false)
116
+ @output.print(prompt)
117
+ @output.flush
118
+ (secret ? @input.noecho(&:gets) : @input.gets)&.chomp
119
+ end
120
+ end
121
+ end
122
+ end