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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Completion
5
+ # Only local configuration and declarative manifests; never acquire or execute.
6
+ class Addons
7
+ attr_reader :file_completion, :file_prefix, :replacement
8
+
9
+ def initialize(service: Application.new.addons)
10
+ @service = service
11
+ end
12
+
13
+ def candidates(words, index, management: false)
14
+ prefix = words[index].to_s
15
+ return @service.names.grep(/^#{Regexp.escape(prefix)}/) if index.zero? || management
16
+
17
+ commands = local_commands(words.first)
18
+ return commands.keys.grep(/^#{Regexp.escape(prefix)}/) if index == 1
19
+
20
+ command = commands[words[1]]
21
+ command ? command_candidates(command, words[2...index], prefix) : []
22
+ rescue Empeira::Error, SystemCallError
23
+ []
24
+ end
25
+
26
+ private
27
+
28
+ def local_commands(name)
29
+ manifest = @service.installation(name).local&.last
30
+ manifest ? manifest.data.fetch('commands') : {}
31
+ end
32
+
33
+ def command_candidates(command, before, prefix)
34
+ options = command.fetch('options', {})
35
+ position, pending, literal = argument_position(before, options)
36
+ if !literal && prefix.start_with?('-') && !pending
37
+ return assignment(options, prefix) if prefix.include?('=')
38
+
39
+ return options.keys.grep(/^#{Regexp.escape(prefix)}/)
40
+ end
41
+ descriptor = pending || command.fetch('arguments', [])[position]
42
+ complete(descriptor, prefix)
43
+ end
44
+
45
+ def argument_position(words, options)
46
+ @position = 0
47
+ @pending = nil
48
+ @literal = false
49
+ words.each { |word| consume_word(word, options) }
50
+ [@position, @pending, @literal]
51
+ end
52
+
53
+ def consume_word(word, options)
54
+ if @pending
55
+ @pending = nil unless word == '='
56
+ return
57
+ end
58
+ return @position += 1 if @literal
59
+
60
+ case word
61
+ when '--' then @literal = true
62
+ when /\A-/ then @pending = option_value(word, options)
63
+ else @position += 1
64
+ end
65
+ end
66
+
67
+ def option_value(word, options)
68
+ name, value = word.split('=', 2)
69
+ descriptor = options[name]
70
+ descriptor if descriptor && descriptor['completion'] != 'none' && (value.nil? || value.empty?)
71
+ end
72
+
73
+ def assignment(options, prefix)
74
+ name, value = prefix.split('=', 2)
75
+ @replacement = "#{name}="
76
+ complete(options[name], value.to_s).map { |item| "#{@replacement}#{item}" }
77
+ end
78
+
79
+ def complete(descriptor, prefix)
80
+ return [] unless descriptor
81
+
82
+ case descriptor['completion']
83
+ when 'file', 'directory'
84
+ @file_completion = descriptor['completion']
85
+ @file_prefix = prefix
86
+ []
87
+ when 'enum' then descriptor.fetch('values').grep(/^#{Regexp.escape(prefix)}/)
88
+ else []
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Empeira
6
+ module Completion
7
+ module Bash
8
+ module_function
9
+
10
+ # COMP_WORDS retains shell quoting, including an unfinished quoted word.
11
+ # Shellwords only parses text; it never evaluates substitutions or commands.
12
+ def path_prefix(value)
13
+ return value unless value.match?(/[\\'"]/)
14
+
15
+ [value, "#{value}'", "#{value}\""].each do |candidate|
16
+ words = Shellwords.split(candidate)
17
+ return words.first if words.size == 1
18
+ rescue ArgumentError
19
+ next
20
+ end
21
+ value
22
+ end
23
+
24
+ def generate
25
+ <<~'BASH'
26
+ # Empeira Bash completion. Load with: source <(empeira completion bash)
27
+ _empeira_complete() {
28
+ local candidate mode='' prefix='' replacement='' phase=0
29
+ COMPREPLY=()
30
+ while IFS= read -r -d '' candidate; do
31
+ if [[ "$candidate" == $'\x1f'file || "$candidate" == $'\x1f'directory ]]; then
32
+ mode="${candidate:1}"
33
+ phase=1
34
+ elif [[ $phase == 1 ]]; then
35
+ prefix="$candidate"
36
+ phase=2
37
+ elif [[ $phase == 2 ]]; then
38
+ replacement="$candidate"
39
+ phase=3
40
+ else
41
+ COMPREPLY+=("$candidate")
42
+ fi
43
+ done < <(printf '%s\0' "${COMP_WORDS[@]:1}" | "${COMP_WORDS[0]}" completion candidates "$COMP_CWORD" --null)
44
+ if [[ -n "$mode" ]]; then
45
+ compopt -o filenames 2>/dev/null || :
46
+ while IFS= read -r -d '' candidate; do COMPREPLY+=("$replacement$candidate"); done < <(
47
+ # Native pathname expansion with NUL framing also preserves newlines.
48
+ # Shell settings change only in this subshell, including on Bash 3.2.
49
+ shopt -s nullglob dotglob
50
+ for candidate in "$prefix"*; do
51
+ [[ "$mode" == directory && ! -d "$candidate" ]] && continue
52
+ printf '%s\0' "$candidate"
53
+ done
54
+ )
55
+ fi
56
+ }
57
+ complete -o filenames -F _empeira_complete empeira
58
+ BASH
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'addons'
4
+
5
+ module Empeira
6
+ module Completion
7
+ # Shared CLI vocabulary and context parsing stay in one shell-neutral catalog.
8
+ # rubocop:disable-next Metrics/ClassLength
9
+ class Catalog
10
+ COMMANDS = {
11
+ [] => %w[help version config proxy up status down destroy node images self-update update browser completion
12
+ addon addons],
13
+ ['addons'] => %w[help list info sync update],
14
+ ['update'] => ['help', *Updates::Service::TARGETS],
15
+ ['proxy'] => %w[help show],
16
+ ['config'] => %w[help show validate],
17
+ ['node'] => %w[help run start stop destroy list exec shell console ssh puppet logs],
18
+ ['completion'] => %w[help bash],
19
+ ['version'] => ['--verbose']
20
+ }.tap { |commands| Immutable.deep_freeze(commands) }
21
+ GLOBAL_OPTIONS = %w[--container-engine --help].freeze
22
+ NODE_OPTIONS = %w[--provider --os --version --memory --cpus].freeze
23
+ VALUE_OPTIONS = %w[--container-engine --provider --os --version --memory --cpus --user
24
+ --identity --port --timeout --project-path].freeze
25
+
26
+ attr_reader :addon_completion
27
+
28
+ def candidates(words, index)
29
+ before = words.take(index)
30
+ context = command_path(before)
31
+ return addon_candidates(words, index, context) if addon_context?(context)
32
+ return [] if before.include?('--')
33
+
34
+ @before = before
35
+ prefix = words[index].to_s
36
+ assignment = assignment_prefix(prefix, before)
37
+ return assignment_candidates(assignment, context) if assignment
38
+
39
+ choices = values(pending_option(before), context)
40
+ choices ||= context_choices(context)
41
+ choices.grep(/^#{Regexp.escape(prefix)}/)
42
+ end
43
+
44
+ private
45
+
46
+ def addon_context?(context)
47
+ context.first == 'addon' || context == %w[addons info]
48
+ end
49
+
50
+ def addon_candidates(words, index, context)
51
+ @addon_completion = Addons.new
52
+ offset = words.index(context.first) + (context.first == 'addon' ? 1 : 2)
53
+ @addon_completion.candidates(words.drop(offset), index - offset, management: context.first == 'addons')
54
+ rescue Error, SystemCallError
55
+ []
56
+ end
57
+
58
+ def context_choices(context)
59
+ Array(node_names(context)) + COMMANDS.fetch(context, []) + options(context)
60
+ end
61
+
62
+ def assignment_candidates(assignment, context)
63
+ option, value, replacement = assignment
64
+ values(option, context).to_a.grep(/^#{Regexp.escape(value)}/).map { |item| "#{replacement}#{item}" }
65
+ end
66
+
67
+ def assignment_prefix(prefix, before)
68
+ if prefix.start_with?('--') && prefix.include?('=')
69
+ option, value = prefix.split('=', 2)
70
+ return [option, value, "#{option}="] if VALUE_OPTIONS.include?(option)
71
+ elsif prefix.start_with?('=') && VALUE_OPTIONS.include?(before.last)
72
+ return [before.last, prefix.delete_prefix('='), '=']
73
+ end
74
+ nil
75
+ end
76
+
77
+ def pending_option(before)
78
+ option = before.last == '=' ? before[-2] : before.last
79
+ option&.delete_suffix('=')
80
+ end
81
+
82
+ def command_path(words)
83
+ path = []
84
+ pending_value = false
85
+ words.each do |word|
86
+ if pending_value
87
+ pending_value = word == '='
88
+ elsif value_option?(word, path)
89
+ pending_value = true
90
+ elsif command_word?(word, path)
91
+ path << word
92
+ end
93
+ end
94
+ path
95
+ end
96
+
97
+ def value_option?(word, path)
98
+ option = word.delete_suffix('=')
99
+ VALUE_OPTIONS.include?(option) && !(option == '--version' && path.empty?)
100
+ end
101
+
102
+ def command_word?(word, path)
103
+ !word.start_with?('-') && path.size < 2
104
+ end
105
+
106
+ def values(option, context)
107
+ return Configuration::Schema::CONTAINER_ENGINES if option == '--container-engine'
108
+ return Empeira::Node.registry.names if option == '--provider' && context == %w[node run]
109
+ return node_values(option) if %w[--os --version].include?(option) && context == %w[node run]
110
+ return [] if VALUE_OPTIONS.include?(option)
111
+
112
+ nil
113
+ end
114
+
115
+ def node_names(context)
116
+ return unless context.size == 2 && context.first == 'node' && %w[start stop destroy exec shell console ssh
117
+ puppet logs].include?(context.last)
118
+ return recovery_node_names if context.last == 'destroy'
119
+
120
+ completion_application.nodes.names
121
+ rescue Error
122
+ []
123
+ end
124
+
125
+ def completion_application
126
+ Application.new
127
+ end
128
+
129
+ def recovery_node_names
130
+ context = Application.new(recovery: true, recovery_path: option_value('--project-path')).context
131
+ state = Infrastructure::Store.new(context: context, recovery: true).load
132
+ (state&.fetch('nodes', {})&.keys || []).select { |name| Node::Inventory.valid_name?(name) }
133
+ end
134
+
135
+ def option_value(option)
136
+ @before.each_with_index.filter_map do |word, index|
137
+ if word == option || word == "#{option}="
138
+ value = @before[index + 1]
139
+ value == '=' ? @before[index + 2] : value&.delete_prefix('=')
140
+ elsif word.start_with?("#{option}=")
141
+ word.split('=', 2).last
142
+ end
143
+ end.last
144
+ end
145
+
146
+ def node_values(option)
147
+ images = completion_application.context.configuration.dig('images', 'nodes')
148
+ return images.keys if option == '--os'
149
+
150
+ images.fetch(selected_os || completion_application.context.configuration.dig('node_defaults', 'os'), {}).keys
151
+ rescue Error
152
+ []
153
+ end
154
+
155
+ def selected_os
156
+ option_value('--os')
157
+ end
158
+
159
+ def options(context)
160
+ result = GLOBAL_OPTIONS.dup
161
+ result << '--version' if context.empty?
162
+ result.push('--user', '--identity', '--port') if context == %w[node ssh]
163
+ result << '--timeout' if context == %w[node exec]
164
+ result << '--project-path' if [['destroy'], %w[node destroy]].include?(context)
165
+ result.concat(NODE_OPTIONS) if context == %w[node run]
166
+ result
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Configuration
5
+ class AdditionalServices
6
+ NAME = /\A[a-z][a-z0-9]*(?:-[a-z0-9]+)*\z/
7
+ RESERVED = %w[server puppetdb puppetdb-backend postgres dns coredns proxy gateway browser browser-ui vm-gateway
8
+ bootstrap-proxy module-installer ca ssl server-data postgres-data
9
+ puppetdb-data node localhost].freeze
10
+ FIELDS = %w[name image environment command configuration].freeze
11
+
12
+ def self.validate!(entries, path)
13
+ raise ConfigurationError, "#{path} must be an array" unless entries.is_a?(Array)
14
+
15
+ names = []
16
+ entries.each_with_index do |entry, index|
17
+ location = "#{path}[#{index}]"
18
+ validate_entry!(entry, location)
19
+ name = entry.fetch('name')
20
+ raise ConfigurationError, "#{location}.name collides with another service" if names.include?(name)
21
+
22
+ names << name
23
+ end
24
+ end
25
+
26
+ def self.validate_entry!(entry, path)
27
+ unless entry.is_a?(Hash) && (entry.keys - FIELDS).empty?
28
+ raise ConfigurationError, "#{path} must contain only name, image, environment, command and configuration"
29
+ end
30
+
31
+ name!(entry['name'], path)
32
+ image!(entry['image'], "#{path}.image")
33
+ optional_fields!(entry, path)
34
+ end
35
+
36
+ def self.optional_fields!(entry, path)
37
+ environment!(entry.fetch('environment', {}), "#{path}.environment")
38
+ command!(entry['command'], "#{path}.command") if entry.key?('command')
39
+ ServiceConfiguration.validate!(entry['configuration'], "#{path}.configuration") if entry.key?('configuration')
40
+ end
41
+
42
+ def self.name!(name, path)
43
+ return if name.is_a?(String) && name.length <= 63 && name.match?(NAME) && !RESERVED.include?(name)
44
+
45
+ raise ConfigurationError, "#{path}.name must be a unique lowercase DNS label, excluding built-in names"
46
+ end
47
+
48
+ def self.image!(image, path)
49
+ unless image.is_a?(Hash) && image.size == 2 && %w[repository tag].all? { |key| image.key?(key) }
50
+ raise ConfigurationError, "#{path} requires only repository and tag"
51
+ end
52
+
53
+ ImageSchema.validate!(image, path, effective: true)
54
+ end
55
+
56
+ def self.environment!(environment, path)
57
+ return if environment.is_a?(Hash) && environment.all? do |key, value|
58
+ key.is_a?(String) && key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) &&
59
+ value.is_a?(String) && !value.include?("\0")
60
+ end
61
+
62
+ raise ConfigurationError, "#{path} must map variable names to strings without null bytes"
63
+ end
64
+
65
+ def self.command!(command, path)
66
+ return if command.is_a?(Array) && !command.empty? && command.all? do |value|
67
+ value.is_a?(String) && !value.include?("\0")
68
+ end
69
+
70
+ raise ConfigurationError, "#{path} must be a nonempty argument array of strings without null bytes"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Configuration
5
+ module Addons
6
+ NAME = /\A[a-z][a-z0-9_-]*\z/
7
+
8
+ def self.validate!(entries, path = 'addons', effective: false)
9
+ raise ConfigurationError, "#{path} must be a mapping" unless entries.is_a?(Hash)
10
+
11
+ entries.each do |name, entry|
12
+ unless name.is_a?(String) && name.size <= 63 && name.match?(NAME)
13
+ raise ConfigurationError, "#{path} contains an invalid addon name"
14
+ end
15
+
16
+ entry!(entry, "#{path}.#{name}", effective: effective)
17
+ end
18
+ end
19
+
20
+ def self.entry!(entry, path, effective:)
21
+ unless entry.is_a?(Hash) && (entry.keys - %w[git ref config]).empty?
22
+ raise ConfigurationError, "#{path} may contain only git, ref and config"
23
+ end
24
+
25
+ require_fields!(entry, path) if effective
26
+ validate_fields!(entry, path)
27
+ end
28
+
29
+ def self.validate_fields!(entry, path)
30
+ validate_config!(entry['config'], "#{path}.config") if entry.key?('config')
31
+ if entry.key?('git') && !valid_source?(entry['git'])
32
+ raise ConfigurationError, "#{path}.git must be a credential-free HTTP(S) or SSH Git source"
33
+ end
34
+ return unless entry.key?('ref') && !valid_ref?(entry['ref'])
35
+
36
+ raise ConfigurationError, "#{path}.ref must be a nonempty Git reference without control characters"
37
+ end
38
+
39
+ def self.validate_config!(config, path)
40
+ raise ConfigurationError, "#{path} must be a mapping" unless config.is_a?(Hash)
41
+
42
+ config_value!(config, path)
43
+ end
44
+
45
+ def self.config_value!(value, path)
46
+ case value
47
+ when Hash then config_mapping!(value, path)
48
+ when Array then value.each_with_index { |child, index| config_value!(child, "#{path}[#{index}]") }
49
+ when Float
50
+ raise ConfigurationError, "#{path} must contain finite numbers" unless value.finite?
51
+ when String, Integer, TrueClass, FalseClass, NilClass then nil
52
+ else raise ConfigurationError, "#{path} must contain only mappings, arrays and scalar values"
53
+ end
54
+ end
55
+
56
+ def self.config_mapping!(value, path)
57
+ value.each do |key, child|
58
+ unless key.is_a?(String) && key.match?(/\A[a-z][a-z0-9_]*\z/)
59
+ raise ConfigurationError, "#{path} has an invalid key; use lowercase letters, digits and underscores"
60
+ end
61
+
62
+ config_value!(child, "#{path}.#{key}")
63
+ end
64
+ end
65
+
66
+ def self.require_fields!(entry, path)
67
+ %w[git ref].each do |key|
68
+ raise ConfigurationError, "#{path}.#{key} is required" unless entry.key?(key)
69
+ end
70
+ end
71
+
72
+ def self.valid_ref?(value)
73
+ value.is_a?(String) && !value.strip.empty? && !value.start_with?('-') && !value.match?(/[\p{Cc}\p{Cf}]/)
74
+ end
75
+
76
+ def self.valid_source?(value)
77
+ return false unless Modules::HostGit.valid_remote?(value)
78
+ return true unless value.include?('://')
79
+
80
+ uri = URI.parse(value)
81
+ uri.query.nil? && uri.fragment.nil?
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Empeira
6
+ module Configuration
7
+ # Fragment validation allows partial OS overrides; effective validation checks the resolved source.
8
+ # rubocop:disable-next Metrics/ModuleLength -- Keep fragment and effective validation of this source contract together.
9
+ module AgentSchema
10
+ extend AgentValues
11
+
12
+ SOURCE_VALIDATORS = { 'url' => :template_url!, 'sha256' => :digest!, 'suffix' => :suffix!,
13
+ 'suite' => :token!, 'component' => :token!, 'key' => :artifact!,
14
+ 'release' => :artifact!, 'verify_signatures' => :boolean!,
15
+ 'destinations' => :destinations! }.freeze
16
+
17
+ module_function
18
+
19
+ def mapping!(value, path)
20
+ raise ConfigurationError, "#{path} must be a mapping" unless value.is_a?(Hash)
21
+ end
22
+
23
+ def catalog!(value, path)
24
+ mapping!(value, path)
25
+ value.each do |key, item|
26
+ unless key.is_a?(String) && key.match?(/\A[a-z0-9][a-z0-9_.-]*\z/)
27
+ raise ConfigurationError, "#{path} has an invalid catalog key"
28
+ end
29
+
30
+ yield item, "#{path}.#{key}"
31
+ end
32
+ end
33
+
34
+ def validate!(data, path)
35
+ mapping!(data, path)
36
+ data.each do |key, value|
37
+ field = "#{path}.#{key}"
38
+ case key
39
+ when 'package' then package_token!(value, field)
40
+ when 'version' then token!(value, field)
41
+ when 'cache' then cache!(value, field)
42
+ when 'install' then install!(value, field)
43
+ else raise ConfigurationError, "#{field} is unsupported"
44
+ end
45
+ end
46
+ end
47
+
48
+ def cache!(data, path)
49
+ mapping!(data, path)
50
+ data.each do |key, value|
51
+ raise ConfigurationError, "#{path}.#{key} is unsupported" unless key == 'enabled'
52
+
53
+ boolean!(value, "#{path}.enabled")
54
+ end
55
+ end
56
+
57
+ def effective!(data)
58
+ required!(data, %w[package version cache install], 'agent')
59
+ required!(data.fetch('cache'), ['enabled'], 'agent.cache')
60
+ install = data.fetch('install')
61
+ required!(install, %w[method repositories apt dnf packages], 'agent.install')
62
+ install.fetch('repositories').each do |key, source|
63
+ required!(source, ['url'], "agent.install.repositories.#{key}")
64
+ end
65
+ %w[apt dnf].each { |manager| effective_sources!(install.fetch(manager), "agent.install.#{manager}") }
66
+ return unless install.fetch('method') == 'package' && install.fetch('packages').empty?
67
+
68
+ raise ConfigurationError, 'agent.install.packages must define at least one distribution and architecture'
69
+ end
70
+
71
+ def effective_sources!(sources, path)
72
+ sources.each do |key, source|
73
+ resolved = Merge.call(sources.fetch('default', {}), source, path: [*path.split('.'), key])
74
+ required!(resolved, ['url'], "#{path}.#{key}")
75
+ source!(resolved, "#{path}.#{key}", manager: path.split('.').last)
76
+ end
77
+ end
78
+
79
+ def install!(data, path)
80
+ mapping!(data, path)
81
+ data.each do |key, value|
82
+ field = "#{path}.#{key}"
83
+ case key
84
+ when 'method'
85
+ raise ConfigurationError, "#{field} must be repository or package" unless %w[repository
86
+ package].include?(value)
87
+ when 'repositories' then catalog!(value, field) { |source, source_path| source!(source, source_path) }
88
+ when 'apt', 'dnf' then catalog!(value, field) do |source, source_path|
89
+ source!(source, source_path, manager: key)
90
+ end
91
+ when 'packages' then package_sources!(value, field)
92
+ else raise ConfigurationError, "#{field} is unsupported"
93
+ end
94
+ end
95
+ end
96
+
97
+ def package_sources!(data, path)
98
+ catalog!(data, path) do |architectures, os_path|
99
+ catalog!(architectures, os_path) do |artifact, artifact_path|
100
+ raise ConfigurationError, "#{artifact_path} has unsupported architecture" unless
101
+ %w[amd64 arm64].include?(artifact_path.split('.').last)
102
+
103
+ artifact!(artifact, artifact_path, package: true)
104
+ family = os_path.delete_prefix("#{path}.")
105
+ extension = family.start_with?('ubuntu', 'debian') ? '.deb' : '.rpm'
106
+ unless URI(artifact.fetch('url')).path.end_with?(extension)
107
+ raise ConfigurationError, "#{artifact_path}.url must end in #{extension}"
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ def artifact!(data, path, package: false)
114
+ mapping!(data, path)
115
+ allowed = package ? %w[url sha256 verify_signatures key] : %w[url sha256]
116
+ fields!(data, allowed, path)
117
+
118
+ required!(data, ['url'], path)
119
+ https_url!(data.fetch('url'), "#{path}.url")
120
+ digest!(data['sha256'], "#{path}.sha256") if data.key?('sha256')
121
+ boolean!(data['verify_signatures'], "#{path}.verify_signatures") if data.key?('verify_signatures')
122
+ artifact!(data['key'], "#{path}.key") if data.key?('key')
123
+ end
124
+
125
+ def source!(data, path, manager: nil)
126
+ mapping!(data, path)
127
+ allowed = %w[url sha256 suffix destinations verify_signatures]
128
+ allowed += %w[suite component release key] if manager == 'apt'
129
+ allowed += ['key'] if manager == 'dnf'
130
+ fields!(data, allowed, path)
131
+ data.each { |key, value| public_send(SOURCE_VALIDATORS.fetch(key), value, "#{path}.#{key}") }
132
+ return unless data.key?('release') && data.key?('key')
133
+
134
+ raise ConfigurationError, "#{path} cannot specify both release and key"
135
+ end
136
+
137
+ def destinations!(value, path)
138
+ raise ConfigurationError, "#{path} must contain valid proxy destination domains" unless
139
+ BootstrapGuests.destinations?(value)
140
+ end
141
+
142
+ def fields!(data, allowed, path)
143
+ key = (data.keys - allowed).first
144
+ raise ConfigurationError, "#{path}.#{key} is unsupported" if key
145
+ end
146
+
147
+ def template_url!(value, path)
148
+ raise ConfigurationError, "#{path} must be a credential-free HTTPS URL" unless value.is_a?(String)
149
+
150
+ expanded = value.gsub(/\$(?:\{([^}]+)\}|([a-zA-Z_][a-zA-Z_0-9]*))/) do
151
+ name = Regexp.last_match(1) || Regexp.last_match(2)
152
+ raise ConfigurationError, "#{path} has unknown placeholder #{name}" unless %w[suite releasever
153
+ basearch].include?(name)
154
+
155
+ 'target'
156
+ end
157
+ raise ConfigurationError, "#{path} has an invalid placeholder" if expanded.include?('$')
158
+
159
+ https_url!(expanded, path)
160
+ end
161
+ end
162
+ end
163
+ end