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,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Infrastructure
5
+ # One recovery transaction over the existing inventory and native lifecycle adapters.
6
+ # rubocop:disable-next Metrics/ClassLength -- One recovery transaction retains ownership until native removals are confirmed.
7
+ class Cleanup
8
+ def initialize(context:, runner:, runtimes: Runtime.registry, runtime: nil, progress: Progress.new)
9
+ @context = context
10
+ @runner = runner
11
+ @runtimes = runtimes
12
+ @runtime = runtime
13
+ @progress = progress
14
+ @store = Store.new(context: context, recovery: true)
15
+ @errors = []
16
+ @failures = []
17
+ @changed = false
18
+ end
19
+
20
+ def destroy
21
+ transaction do
22
+ @state.fetch('nodes', {}).dup.each_key { |name| attempt("node #{name}") { remove_node(name) } }
23
+ remove_bootstrap
24
+ remove_services
25
+ remove_volumes
26
+ remove_networks
27
+ reject_unknown_resources
28
+ if @errors.empty?
29
+ cleanup_files
30
+ @store.clear
31
+ end
32
+ report_errors!
33
+ end
34
+ end
35
+
36
+ def node(name)
37
+ hostname = name.downcase
38
+ raise ConfigurationError, 'Invalid node hostname' unless Node::Inventory.valid_name?(hostname)
39
+
40
+ transaction do
41
+ attempt("node #{hostname}") { remove_node(hostname) }
42
+ attempt('node discovery records') { remove_dns(hostname) } unless @state.fetch('nodes', {}).key?(hostname)
43
+ report_errors!
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def transaction
50
+ @errors.clear
51
+ @failures.clear
52
+ @changed = false
53
+ @store.with_lock do
54
+ @state = @store.load
55
+ next Providers::Result.new(resource: nil, changed: false) unless @state
56
+
57
+ @runtime ||= @runtimes.build(@state.fetch('runtime'), context: @context, runner: @runner)
58
+ @runtime.check_available!
59
+ yield
60
+ Providers::Result.new(resource: nil, changed: @changed)
61
+ end
62
+ end
63
+
64
+ def attempt(identity)
65
+ yield
66
+ rescue Error, SystemCallError, KeyError, ArgumentError => e
67
+ @failures << e
68
+ diagnostic = Execution::Diagnostics.safe_text(e.message)
69
+ @errors << "#{identity}: #{diagnostic}"
70
+ rescue TypeError, NoMethodError => e
71
+ @failures << e
72
+ @errors << "#{identity}: Resource fields cannot be safely interpreted (#{e.class.name}); retained"
73
+ end
74
+
75
+ def save
76
+ @store.write(@state)
77
+ end
78
+
79
+ def report_errors!
80
+ return if @errors.empty?
81
+
82
+ failure = @failures.any? && @failures.all?(Locked) ? Locked : Error
83
+ raise failure, "Cleanup is incomplete; inventory and remaining ownership data retained.\n#{@errors.join("\n")}",
84
+ cause: nil
85
+ end
86
+
87
+ def remove_node(name)
88
+ record = @state.fetch('nodes', {})[name]
89
+ return unless record
90
+
91
+ RecoveryInventory.node!(name, record)
92
+ if record['provider'] == 'vm'
93
+ VM::Removal.new(context: @context, runner: @runner, runtime: @runtime, store: @store).remove(record, @state)
94
+ else
95
+ remove_container(record)
96
+ end
97
+ retire_certificate(record)
98
+ remove_session_lock(record) if record['provider'] == 'vm'
99
+ @state.fetch('nodes').delete(name)
100
+ save
101
+ @changed = true
102
+ end
103
+
104
+ def remove_session_lock(record)
105
+ path = @store.directory.join("vm-session-#{record.fetch('peer').fetch('token')}.lock")
106
+ OwnedFiles.remove(path, root: @store.directory)
107
+ end
108
+
109
+ def remove_container(record)
110
+ name = record.fetch('hostname')
111
+ path = @store.directory.join('containers', name)
112
+ OwnedFiles.verify_removable!(path, root: @store.directory)
113
+ definition = Node::Definition.new(hostname: name, workspace: @context.workspace)
114
+ resource = @runtime.inspect_service(definition, expected_id: record['id'])
115
+ if resource
116
+ verify_container_network!(resource, record)
117
+ recover_container_id!(resource, record) unless record['id']
118
+ remove_service(definition, record['id'])
119
+ end
120
+ OwnedFiles.remove(path, root: @store.directory)
121
+ end
122
+
123
+ def verify_container_network!(resource, record)
124
+ network = Network::Definition.new(workspace: @context.workspace, policy: Network::Policy.new).backend_name
125
+ return if resource.fetch('networks').keys == [network] && Node::SSHEndpoint.valid?(resource, record)
126
+
127
+ raise Providers::OwnershipError, 'Node has unexpected network membership or published ports; retained'
128
+ end
129
+
130
+ # rubocop:disable-next Metrics/AbcSize -- Match persisted random instance intent before recording a recovered ID.
131
+ def recover_container_id!(resource, record)
132
+ options = record.fetch('definition')
133
+ definition = Node::Definition.new(hostname: record.fetch('hostname'), workspace: @context.workspace,
134
+ **options.except('hostname').transform_keys(&:to_sym))
135
+ network = resource.fetch('networks').values.first
136
+ mac = options['mac_address']
137
+ unless mac.is_a?(String) && mac.match?(/\A02:(?:[0-9a-f]{2}:){4}[0-9a-f]{2}\z/) &&
138
+ network['MacAddress'] == mac && resource.dig('labels', 'io.empeira.definition') == definition.fingerprint
139
+ raise Providers::OwnershipError, 'Uncertain node creation has no matching instance intent; retained'
140
+ end
141
+
142
+ record['id'] = resource.fetch('id')
143
+ save
144
+ end
145
+
146
+ def retire_certificate(record)
147
+ key = record['certificate_key']
148
+ return unless key
149
+ unless Node::Inventory.valid_certificate?(key)
150
+ raise StateError, 'Recorded certificate identity is invalid; ownership data retained'
151
+ end
152
+
153
+ @state['retired_certificates'] ||= {}
154
+ @state.fetch('retired_certificates')[record.fetch('hostname')] = key
155
+ end
156
+
157
+ def remove_dns(name)
158
+ files = ControlPlane::Files.new(context: @context)
159
+ path = Pathname(files.path('hosts'))
160
+ return unless OwnedFiles.verify_removable!(path, root: @store.directory)
161
+
162
+ aliases = Network::Naming.new.aliases(name)
163
+ content = path.readlines.reject { |line| line.split.drop(1).intersect?(aliases) }.join
164
+ @changed = files.write('hosts', content) || @changed
165
+ end
166
+
167
+ def remove_service(definition, id)
168
+ raise StateError, 'Invalid recorded service ID' unless Node::Inventory.valid_id?(id)
169
+
170
+ resource = @runtime.inspect_service(definition, expected_id: id)
171
+ return unless resource
172
+ raise Providers::OwnershipError, 'Service has no recorded instance ID; resource retained' unless id
173
+
174
+ @runtime.remove_service(definition, expected_id: id)
175
+ end
176
+
177
+ def identity(key)
178
+ unless key.is_a?(String) && key.match?(/\A[a-z][a-z0-9]*(?:-[a-z0-9]+)*\z/)
179
+ raise StateError, 'Invalid service identity; resource retained'
180
+ end
181
+
182
+ Services::Definition.new(key: key, workspace: @context.workspace)
183
+ end
184
+
185
+ def plane
186
+ @state.fetch('control_plane', {})
187
+ end
188
+
189
+ def remove_bootstrap
190
+ return unless @state.key?('bootstrap_proxy')
191
+
192
+ attempt('bootstrap-proxy') do
193
+ remove_service(identity('bootstrap-proxy'), @state.fetch('bootstrap_proxy').fetch('id'))
194
+ @state.delete('bootstrap_proxy')
195
+ save
196
+ end
197
+ end
198
+
199
+ # rubocop:disable-next Metrics/AbcSize -- Preserve gateway dependencies while continuing independent service cleanup.
200
+ def remove_services
201
+ records = plane.fetch('services', {})
202
+ keys = (records.keys - %w[proxy dns gateway]).reverse + %w[proxy dns gateway]
203
+ keys.each do |key|
204
+ next unless records.key?(key)
205
+ next if key == 'gateway' && @state.fetch('nodes', {}).any?
206
+
207
+ attempt("service #{key}") do
208
+ remove_service(identity(key), records.fetch(key).fetch('id'))
209
+ records.delete(key)
210
+ save
211
+ end
212
+ end
213
+ end
214
+
215
+ def remove_volumes
216
+ records = plane.fetch('volumes', {})
217
+ records.dup.each_key do |key|
218
+ attempt("volume #{key}") do
219
+ remove_volume(identity(key), records.fetch(key).fetch('id'))
220
+ records.delete(key)
221
+ save
222
+ end
223
+ end
224
+ end
225
+
226
+ def remove_volume(definition, id)
227
+ raise StateError, 'Invalid recorded volume ID' unless Node::Inventory.valid_id?(id)
228
+
229
+ resource = @runtime.inspect_volume(definition, expected_id: id)
230
+ return unless resource
231
+
232
+ raise Providers::OwnershipError, 'Volume has no recorded instance ID; resource retained' unless id
233
+
234
+ @runtime.remove_volume(definition, expected_id: id)
235
+ end
236
+
237
+ # rubocop:disable-next Metrics/AbcSize -- Independent network teardown follows confirmed node and bridge removal.
238
+ def remove_networks
239
+ if @state.fetch('nodes', {}).any?
240
+ @errors << 'Shared network and gateway retained while node cleanup is incomplete'
241
+ return
242
+ end
243
+ remove_bridge
244
+ attempt('egress network') do
245
+ definition = Network::Egress.new(workspace: @context.workspace, policy: Network::Policy.new)
246
+ remove_network(definition, plane['egress'])
247
+ plane['egress'] = nil if @state.key?('control_plane')
248
+ save
249
+ end
250
+ attempt('environment network') { remove_environment_network }
251
+ end
252
+
253
+ # rubocop:disable-next Metrics/AbcSize -- Check recorded logical identity before native network removal.
254
+ def remove_environment_network
255
+ raise StateError, 'Workspace bridge cleanup is incomplete; network retained' if plane['bridge_prepared']
256
+
257
+ entry = @state.fetch('resources').fetch('network')
258
+ definition = Network::Definition.new(workspace: @context.workspace, policy: Network::Policy.new)
259
+ unless [nil, definition.identity].include?(entry['logical_identity']) &&
260
+ [nil, definition.backend_name].include?(entry['name'])
261
+ raise Providers::OwnershipError, 'Recorded network identity is inconsistent; network retained'
262
+ end
263
+
264
+ remove_network(definition, entry['id'])
265
+ entry['id'] = nil
266
+ save
267
+ end
268
+
269
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- Native bridge teardown uses only recorded owner, ID and allocation.
270
+ def remove_bridge
271
+ return unless plane['bridge_prepared']
272
+
273
+ attempt('workspace bridge') do
274
+ definition = Network::Definition.new(workspace: @context.workspace, policy: Network::Policy.new)
275
+ id = @state.dig('resources', 'network', 'id')
276
+ resource = @runtime.inspect_network(identifier: definition.backend_name)
277
+ if resource
278
+ definition.verify_ownership!(resource, expected_id: id)
279
+ raise Providers::OwnershipError, 'Bridge has no recorded network ID' unless id
280
+
281
+ definition.allocation = @state.fetch('peer_network').fetch('subnet')
282
+ @runtime.reconcile_workspace_bridge(definition: definition, expected_id: id,
283
+ image: plane.fetch('bridge_image'), action: 'remove')
284
+ end
285
+ plane.delete('bridge_prepared')
286
+ plane.delete('bridge_image')
287
+ save
288
+ end
289
+ end
290
+
291
+ def remove_network(definition, id)
292
+ raise StateError, 'Invalid recorded network ID' unless Node::Inventory.valid_id?(id)
293
+
294
+ resource = @runtime.inspect_network(identifier: definition.backend_name)
295
+ definition.verify_ownership!(resource, expected_id: id)
296
+ if resource
297
+ raise Providers::OwnershipError, 'Network has no recorded instance ID; resource retained' unless id
298
+
299
+ @runtime.remove_network(definition: definition, expected_id: id)
300
+ elsif id && @runtime.inspect_network(identifier: id)
301
+ raise Providers::OwnershipError, 'Recorded network ID exists with a different identity; resource retained'
302
+ end
303
+ end
304
+
305
+ def reject_unknown_resources
306
+ unknown = @state.fetch('resources').keys - ['network']
307
+ @errors << "Unrecognized resource groups retained: #{unknown.join(', ')}" unless unknown.empty?
308
+ known_plane = %w[services volumes egress bridge_prepared bridge_image stopped environment_cache
309
+ gateway_policy proxy_reload]
310
+ unknown = plane.keys - known_plane
311
+ @errors << "Unrecognized control-plane fields retained: #{unknown.join(', ')}" unless unknown.empty?
312
+ return unless @state.key?('vm_gateway')
313
+
314
+ @errors << 'Legacy VM gateway ownership is not safely interpretable; state retained'
315
+ end
316
+
317
+ def cleanup_files
318
+ %w[containers vms services environments additional-configurations puppetdb-relay database.env].each do |name|
319
+ OwnedFiles.remove(@store.directory.join(name), root: @store.directory, symlinks: name == 'environments')
320
+ end
321
+ locks = @store.directory.children.select do |path|
322
+ path.basename.to_s.match?(/\Avm-session-[0-9a-f]{32}\.lock\z/)
323
+ end
324
+ locks.each { |path| OwnedFiles.remove(path, root: @store.directory) }
325
+ end
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Empeira
7
+ module Infrastructure
8
+ class Definition
9
+ REVISION = 2
10
+ attr_reader :network, :metadata, :plan
11
+
12
+ def self.fingerprint(data)
13
+ Digest::SHA256.hexdigest(JSON.generate(canonical(data)))
14
+ end
15
+
16
+ def self.canonical(value)
17
+ case value
18
+ when Hash then value.keys.sort.to_h { |key| [key, canonical(value.fetch(key))] }
19
+ when Array then value.map { |entry| canonical(entry) }
20
+ else value
21
+ end
22
+ end
23
+
24
+ def initialize(context:, revision: REVISION)
25
+ @network = Network::Definition.new(
26
+ workspace: context.workspace,
27
+ policy: Network::Policy.new,
28
+ revision: revision
29
+ )
30
+ @metadata = Immutable.deep_freeze(
31
+ { 'revision' => revision, 'runtime' => context.container_engine,
32
+ 'components' => component_definitions(context) }
33
+ )
34
+ end
35
+
36
+ # rubocop:disable-next Metrics/AbcSize -- Aggregate fingerprints include dormant browser configuration.
37
+ def component_definitions(context)
38
+ @plan = ControlPlane::Plan.new(context: context)
39
+ components = plan.definitions.merge(ControlPlane::Browser.new(plan).definitions).transform_values(&method(:safe_options))
40
+ components['network'] = network.to_h
41
+ components['egress'] = Network::Egress.new(workspace: context.workspace, policy: Network::Policy.new).to_h
42
+ components
43
+ end
44
+
45
+ def safe_options(entry)
46
+ options = entry.options.except('dns', 'runtime_environment', 'runtime_ip')
47
+ options.merge('environment' => self.class.fingerprint(options.fetch('environment', {})))
48
+ end
49
+
50
+ def fingerprint
51
+ self.class.fingerprint(metadata)
52
+ end
53
+
54
+ def component_fingerprints
55
+ metadata.fetch('components').transform_values { |definition| self.class.fingerprint(definition) }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Infrastructure
5
+ # Only private workspace descendants are removable; never traverse a symlink.
6
+ module OwnedFiles
7
+ def self.remove(path, root:, symlinks: false)
8
+ return unless verify_removable!(path, root: root, symlinks: symlinks)
9
+
10
+ FileUtils.remove_entry_secure(path)
11
+ end
12
+
13
+ def self.verify_removable!(path, root:, symlinks: false)
14
+ verify_root!(root)
15
+ candidate = Pathname(path)
16
+ unless candidate.to_s.start_with?("#{root}/") && candidate.cleanpath == candidate
17
+ raise Providers::OwnershipError, 'Cleanup path escapes its workspace'
18
+ end
19
+ return unless candidate.exist? || candidate.symlink?
20
+
21
+ ancestors(candidate, root).each { |entry| verify_entry!(entry, directory: true) }
22
+ verify_tree!(candidate, symlinks: symlinks)
23
+ true
24
+ end
25
+
26
+ def self.verify_root!(root)
27
+ verify_entry!(root, directory: true)
28
+ raise Providers::OwnershipError, 'Workspace directory is not private' unless root.lstat.mode.nobits?(0o077)
29
+ end
30
+
31
+ def self.ancestors(path, root)
32
+ entries = []
33
+ current = path.dirname
34
+ until current == root
35
+ entries << current
36
+ current = current.dirname
37
+ end
38
+ entries.reverse
39
+ end
40
+
41
+ def self.verify_tree!(path, symlinks: false)
42
+ if symlinks && path.symlink?
43
+ raise Providers::OwnershipError, 'Generated link ownership changed' unless path.lstat.uid == Process.uid
44
+
45
+ return
46
+ end
47
+ verify_entry!(path)
48
+ return unless path.directory?
49
+
50
+ path.children.each { |child| verify_tree!(child, symlinks: symlinks) }
51
+ end
52
+
53
+ # rubocop:disable-next Metrics/CyclomaticComplexity -- File kind, link and owner checks form one fail-closed predicate.
54
+ def self.verify_entry!(path, directory: false)
55
+ metadata = path.lstat
56
+ unless metadata.uid == Process.uid && !metadata.symlink? &&
57
+ (!directory || metadata.directory?) && (metadata.file? || metadata.directory? || metadata.socket?)
58
+ raise Providers::OwnershipError, 'Unsafe workspace path or ownership; cleanup refused'
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Infrastructure
5
+ # Recovery reads ownership, never migrates schemas or interprets guest configuration.
6
+ module RecoveryInventory
7
+ def self.validate!(data, workspace:)
8
+ return if valid_envelope?(data, workspace) && valid_retired_certificates?(data)
9
+
10
+ raise StateError, 'Recovery inventory ownership or resource envelope is invalid; preserve the state'
11
+ end
12
+
13
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity -- Validate the complete recoverable ownership envelope.
14
+ def self.valid_envelope?(data, workspace)
15
+ data.is_a?(Hash) && data['workspace'] == workspace &&
16
+ data['schema_version'].is_a?(Integer) && data['schema_version'].positive? &&
17
+ Runtime.registry.names.include?(data['runtime']) && data['resources'].is_a?(Hash) &&
18
+ data.fetch('nodes', {}).is_a?(Hash) && valid_plane?(data.fetch('control_plane', {}))
19
+ end
20
+
21
+ def self.valid_plane?(plane)
22
+ plane.is_a?(Hash) && %w[services volumes].all? { |key| plane.fetch(key, {}).is_a?(Hash) }
23
+ end
24
+
25
+ def self.valid_retired_certificates?(data)
26
+ entries = data.fetch('retired_certificates', {})
27
+ entries.is_a?(Hash) && entries.all? do |name, key|
28
+ Node::Inventory.valid_name?(name) && key.is_a?(String) && key.match?(/\A[0-9a-f]{64}\z/)
29
+ end
30
+ end
31
+
32
+ # rubocop:disable-next Metrics/CyclomaticComplexity -- Validate identity before dispatching numeric-root host cleanup.
33
+ def self.node!(name, record)
34
+ unless Node::Inventory.valid_name?(name) && record.is_a?(Hash) && record['hostname'] == name &&
35
+ %w[container vm].include?(record['provider']) && Node::Inventory.valid_id?(record['id'])
36
+ raise StateError, 'Node identity is not safely interpretable; resources retained'
37
+ end
38
+ return if record['provider'] == 'container'
39
+
40
+ return if valid_vm_instance?(name, record)
41
+
42
+ raise StateError, 'VM instance or disk identity is incomplete; resources retained'
43
+ end
44
+
45
+ def self.valid_vm_instance?(name, record)
46
+ peer = record['peer']
47
+ valid_token = peer.is_a?(Hash) && peer['token'].is_a?(String) && peer['token'].match?(/\A[0-9a-f]{32}\z/)
48
+ valid_pid = record['pid'].nil? || (record['pid'].is_a?(Integer) && record['pid'].positive?)
49
+ valid_token && valid_pid && record['overlay'] == "vms/#{name}/disk.qcow2"
50
+ end
51
+ end
52
+ end
53
+ end