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,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Infrastructure
5
+ # Keep version gating and the single workspace mutation boundary together.
6
+ # rubocop:disable-next Metrics/ClassLength
7
+ class Service
8
+ def initialize(context:, runner:, runtimes:, build_info:, store: Store.new(context: context),
9
+ progress: Progress.new)
10
+ @progress = progress
11
+ @context = context
12
+ @runner = runner
13
+ @runtimes = runtimes
14
+ @build_info = build_info
15
+ @store = store
16
+ end
17
+
18
+ # rubocop:disable-next Metrics/AbcSize -- Read-only artifact validation precedes runtime mutation.
19
+ def up
20
+ require_compatible!
21
+ @store.with_lock do
22
+ @progress.stage(5, 'Checking runtime and workspace ownership...')
23
+ state = @store.load
24
+ runtime = mutation_runtime(state)
25
+ resource = observe(runtime, state)
26
+ verify_current!(state, resource)
27
+ plane = control_plane(runtime)
28
+ plane.preflight
29
+ @progress.stage(15, 'Preparing isolated network...')
30
+ result = ensure_network(runtime, state, resource)
31
+ changed = plane.up
32
+ changed = reconcile_nodes || changed
33
+
34
+ Providers::Result.new(resource: result.resource, changed: result.changed || changed)
35
+ end
36
+ end
37
+
38
+ def browser
39
+ require_compatible!
40
+ state = @store.load
41
+ raise Error, browser_workspace_error unless state
42
+
43
+ @store.with_lock do
44
+ state = @store.load
45
+ raise Error, browser_workspace_error unless state
46
+
47
+ @progress.stage(5, 'Checking runtime and workspace ownership...')
48
+ runtime = mutation_runtime(state)
49
+ resource = observe(runtime, state)
50
+ raise Error, browser_network_error unless resource
51
+
52
+ verify_current!(state, resource)
53
+ control_plane(runtime).browser
54
+ end
55
+ end
56
+
57
+ # rubocop:disable-next Metrics/AbcSize -- Keep version checks and retained-storage teardown in one lock boundary.
58
+ def down
59
+ require_compatible!
60
+ @store.with_lock do
61
+ @progress.stage(5, 'Checking runtime and workspace ownership...')
62
+ state = @store.load
63
+ runtime = mutation_runtime(state)
64
+ resource = observe(runtime, state)
65
+ require_no_nodes!(state)
66
+
67
+ changed = control_plane(runtime).down
68
+ result = runtime.remove_network(definition: definition.network, expected_id: resource&.id)
69
+ retain_storage
70
+ Providers::Result.new(resource: result.resource, changed: result.changed || changed)
71
+ end
72
+ end
73
+
74
+ def destroy
75
+ Cleanup.new(context: @context, runner: @runner, runtimes: @runtimes, progress: @progress).destroy
76
+ end
77
+
78
+ def status
79
+ status = Status.new(context: @context, build_info: @build_info, definition: definition)
80
+ state = @store.load
81
+ status.record(state)
82
+ runtime = runtime_for(state&.fetch('runtime') || @context.container_engine)
83
+ runtime.check_available!
84
+ status.observe(observe(runtime, state))
85
+ health_report(status, runtime, state)
86
+ rescue Error => e
87
+ status.report.merge('Infrastructure' => 'unknown', 'Diagnostic' => e.message)
88
+ end
89
+
90
+ private
91
+
92
+ def definition
93
+ @definition ||= Definition.new(context: @context)
94
+ end
95
+
96
+ def browser_workspace_error
97
+ "Workspace is not initialized. Start it with 'empeira up' first."
98
+ end
99
+
100
+ def browser_network_error
101
+ "Required workspace network is not running. Start the workspace with 'empeira up' first."
102
+ end
103
+
104
+ def reconcile_nodes
105
+ Node::Service.new(context: @context, runner: @runner, runtimes: @runtimes,
106
+ build_info: @build_info, progress: @progress).reconcile(state: @store.load)
107
+ end
108
+
109
+ def destroy_nodes(runtime, state)
110
+ Node::Container.new(context: @context, runner: @runner, backend: runtime).destroy_all(state: state)
111
+ engine = VM.registry.build('qemu', context: @context, runner: @runner)
112
+ Node::VM.new(context: @context, runner: @runner, backend: engine, runtime: runtime)
113
+ .destroy_all(state: state)
114
+ end
115
+
116
+ def require_no_nodes!(state)
117
+ return unless state&.fetch('nodes', {})&.any?
118
+
119
+ raise Error, 'Nodes exist; destroy them before empeira down, or use empeira destroy for complete removal'
120
+ end
121
+
122
+ def health_report(status, runtime, state)
123
+ report = status.report.merge(control_plane(runtime).status(state: state))
124
+ if report['Infrastructure'] == 'up' && report['Control plane'] == 'degraded'
125
+ report['Infrastructure'] = 'degraded'
126
+ end
127
+ report
128
+ end
129
+
130
+ def allocate_network(runtime, state)
131
+ @peer_network = state&.fetch('peer_network', nil) || {
132
+ 'subnet' => Network::Peer::Allocation.new(context: @context, runtime: runtime, runner: @runner).choose
133
+ }
134
+ definition.network.allocation = @peer_network.fetch('subnet')
135
+ definition.plan.validate_network_redirects!(subnet: @peer_network.fetch('subnet'))
136
+ end
137
+
138
+ def ensure_network(runtime, state, resource)
139
+ allocate_network(runtime, state)
140
+ definition.network.policy.require_support!(**runtime.capabilities)
141
+ persist(state, resource)
142
+ result = if resource
143
+ Providers::Result.new(resource: resource, changed: false)
144
+ else
145
+ runtime.create_network(definition: definition.network)
146
+ end
147
+ persist(state, result.resource)
148
+ result
149
+ end
150
+
151
+ def retain_storage
152
+ retained = @store.load
153
+ if retained&.dig('control_plane', 'volumes')&.any?
154
+ retained['resources']['network']['id'] = nil
155
+ @store.write(retained)
156
+ else
157
+ @store.clear
158
+ end
159
+ end
160
+
161
+ def control_plane(runtime)
162
+ ControlPlane::Controller.new(context: @context, runtime: runtime, store: @store, runner: @runner,
163
+ plan: definition.plan, progress: @progress)
164
+ end
165
+
166
+ def requirement
167
+ @context.configuration.fetch('requirements').fetch('empeira')
168
+ end
169
+
170
+ def require_compatible!
171
+ @build_info.require_compatible!(requirement)
172
+ end
173
+
174
+ def runtime_for(name)
175
+ @runtimes.build(name, context: @context, runner: @runner)
176
+ end
177
+
178
+ def runtime_mismatch?(state)
179
+ state && state.fetch('runtime') != @context.container_engine
180
+ end
181
+
182
+ def mutation_runtime(state)
183
+ if runtime_mismatch?(state)
184
+ raise Error, "Existing infrastructure belongs to #{state.fetch('runtime')}, configured runtime is " \
185
+ "#{@context.container_engine}. Restore the owning runtime and remove retained infrastructure " \
186
+ 'before switching. ' \
187
+ 'Runtime migration is not implemented.'
188
+ end
189
+ runtime = runtime_for(@context.container_engine)
190
+ runtime.check_available!
191
+ runtime
192
+ end
193
+
194
+ def observe(runtime, state)
195
+ network = definition.network
196
+ expected_id = state&.dig('resources', 'network', 'id')
197
+ resource = runtime.inspect_network(identifier: network.backend_name)
198
+ network.verify_ownership!(resource, expected_id: expected_id)
199
+ if resource.nil? && expected_id && runtime.inspect_network(identifier: expected_id)
200
+ raise Providers::OwnershipError,
201
+ 'Recorded network ID exists under a different name; automatic removal refused'
202
+ end
203
+
204
+ resource
205
+ end
206
+
207
+ def verify_current!(state, resource)
208
+ if state && (state.dig('definition', 'revision') != definition.metadata['revision'] ||
209
+ state.dig('component_fingerprints', 'network') != definition.component_fingerprints['network'])
210
+ raise Error,
211
+ 'Infrastructure definition is stale. Inspect status, then explicitly run ' \
212
+ 'empeira destroy and empeira up.'
213
+ end
214
+ return unless resource
215
+
216
+ definition.network.verify_definition!(resource)
217
+ definition.network.verify_isolation!(resource)
218
+ end
219
+
220
+ # The inventory envelope is deliberately written atomically in one operation.
221
+ # rubocop:disable-next Metrics/AbcSize
222
+ def persist(previous, resource)
223
+ now = Time.now.utc.iso8601
224
+ @store.write(
225
+ 'schema_version' => Store::SCHEMA_VERSION, 'workspace' => @context.workspace.id,
226
+ 'runtime' => @context.container_engine,
227
+ 'resources' => { 'network' => { 'logical_identity' => definition.network.identity,
228
+ 'name' => definition.network.backend_name, 'id' => resource&.id } },
229
+ 'fingerprint' => definition.fingerprint, 'definition' => definition.metadata,
230
+ 'component_fingerprints' => definition.component_fingerprints, 'peer_network' => @peer_network,
231
+ 'created_at' => previous&.fetch('created_at') || now, 'reconciled_at' => now,
232
+ **previous.to_h.slice('nodes', 'retired_certificates', 'control_plane')
233
+ )
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Infrastructure
5
+ class Status
6
+ attr_reader :report
7
+
8
+ def initialize(context:, build_info:, definition:)
9
+ @context = context
10
+ @definition = definition
11
+ requirement = context.configuration.fetch('requirements').fetch('empeira')
12
+ @report = {
13
+ 'Workspace' => context.workspace.id, 'Platform' => "#{context.platform.os}/#{context.platform.architecture}",
14
+ 'Empeira version' => build_info.version, 'Project Empeira requirement' => requirement || 'none',
15
+ 'Version requirement' => build_info.requirement_status(requirement),
16
+ 'Configured runtime' => context.container_engine, 'Owning runtime' => 'unknown',
17
+ 'Network' => 'unknown', 'Infrastructure' => 'unknown', 'Infrastructure fingerprint' => 'unknown'
18
+ }
19
+ end
20
+
21
+ def record(state)
22
+ @state = state
23
+ report['Owning runtime'] = state&.fetch('runtime') || 'none recorded'
24
+ report['Infrastructure fingerprint'] = fingerprint_status
25
+ report['Runtime ownership'] =
26
+ if state && state.fetch('runtime') != @context.container_engine
27
+ 'mismatch; restore owning runtime for cleanup'
28
+ else
29
+ 'consistent'
30
+ end
31
+ end
32
+
33
+ def observe(resource)
34
+ report['Network'] = network_status(resource)
35
+ report['Infrastructure'] = infrastructure_status(resource)
36
+ return unless resource
37
+ return if resource.labels['io.empeira.definition'] == @definition.network.labels.fetch('io.empeira.definition')
38
+
39
+ report['Infrastructure fingerprint'] = 'stale runtime definition'
40
+ report['Infrastructure'] = 'stale'
41
+ end
42
+
43
+ private
44
+
45
+ def network_status(resource)
46
+ return 'absent' unless resource
47
+
48
+ resource.isolated ? 'isolated' : 'unsafe isolation'
49
+ end
50
+
51
+ def fingerprint_status
52
+ return 'unrecorded' unless @state
53
+
54
+ @state.fetch('fingerprint') == @definition.fingerprint ? 'current' : 'stale'
55
+ end
56
+
57
+ def missing_status
58
+ return 'down; persistent data retained' if @state&.dig('control_plane', 'stopped')
59
+
60
+ @state ? 'missing; reconciliation required' : 'down'
61
+ end
62
+
63
+ def infrastructure_status(resource)
64
+ return missing_status unless resource
65
+
66
+ return 'unsafe; isolation cannot be verified' unless resource.isolated
67
+ return 'recoverable; local state incomplete' unless @state&.dig('resources', 'network', 'id')
68
+
69
+ fingerprint_status == 'current' ? 'up' : 'stale'
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+ require 'time'
6
+
7
+ module Empeira
8
+ module Infrastructure
9
+ class StateError < Error; end
10
+ class Locked < Error; end
11
+
12
+ # Keep atomic persistence and the complete inventory envelope validator together.
13
+ # rubocop:disable-next Metrics/ClassLength
14
+ class Store
15
+ SCHEMA_VERSION = 2
16
+ attr_reader :directory
17
+
18
+ def initialize(context:, recovery: false)
19
+ @recovery = recovery
20
+ @workspace_id = context.workspace.id
21
+ @state_root = context.locations.state
22
+ @network = Network::Definition.new(workspace: context.workspace, policy: Network::Policy.new)
23
+ @directory = context.locations.workspace(context.workspace)
24
+ end
25
+
26
+ # rubocop:disable-next Metrics/MethodLength -- Stable lock ownership, acquisition and release share an ensure boundary.
27
+ def with_lock
28
+ verify_paths!(inventory: false)
29
+ FileUtils.mkdir_p(directory, mode: 0o700)
30
+ verify_paths!(inventory: false)
31
+ File.open(directory.join('mutation.lock'), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
32
+ verify_lock!(lock)
33
+ unless lock.flock(File::LOCK_EX | File::LOCK_NB)
34
+ raise Locked, "Another Empeira mutation owns workspace #{@workspace_id}. Wait and retry."
35
+ end
36
+
37
+ begin
38
+ yield
39
+ ensure
40
+ lock.flock(File::LOCK_UN)
41
+ end
42
+ end
43
+ rescue SystemCallError
44
+ raise StateError, 'Cannot access workspace mutation lock', cause: nil
45
+ end
46
+
47
+ def load
48
+ verify_paths!
49
+ data = JSON.parse(File.read(path))
50
+ validate!(data)
51
+ data
52
+ rescue Errno::ENOENT
53
+ raise StateError, 'Infrastructure state is an unreadable symlink' if path.symlink?
54
+
55
+ nil
56
+ rescue JSON::ParserError, SystemCallError
57
+ raise StateError, 'Cannot read infrastructure state. Preserve it and investigate before retrying.', cause: nil
58
+ end
59
+
60
+ def write(data)
61
+ verify_paths!
62
+ validate!(data)
63
+ Tempfile.create(['state-', '.json'], directory) do |file|
64
+ file.chmod(0o600)
65
+ file.write(JSON.pretty_generate(data))
66
+ file.flush
67
+ file.fsync
68
+ File.rename(file.path, path)
69
+ end
70
+ rescue SystemCallError, IOError
71
+ raise StateError, 'Cannot persist infrastructure state. Run status before retrying.', cause: nil
72
+ end
73
+
74
+ def clear
75
+ verify_paths!
76
+ File.unlink(path) if path.exist?
77
+ rescue SystemCallError
78
+ raise StateError, 'Cannot clear infrastructure state. Run status before retrying.', cause: nil
79
+ end
80
+
81
+ private
82
+
83
+ def verify_lock!(lock)
84
+ metadata = lock.stat
85
+ return if metadata.file? && metadata.uid == Process.uid && metadata.mode.nobits?(0o077)
86
+
87
+ raise StateError, 'Workspace mutation lock ownership is invalid'
88
+ end
89
+
90
+ def verify_paths!(inventory: true)
91
+ [@state_root, directory.dirname, directory].each do |entry|
92
+ verify_directory!(entry)
93
+ end
94
+ if directory.exist? && !directory.lstat.mode.nobits?(0o077)
95
+ raise StateError, 'Workspace state directory is not private'
96
+ end
97
+
98
+ verify_inventory_file! if inventory
99
+ end
100
+
101
+ def verify_directory!(entry)
102
+ return unless entry.exist? || entry.symlink?
103
+
104
+ raise StateError, 'Workspace state directory is an unsafe symlink' if entry.symlink?
105
+
106
+ metadata = entry.lstat
107
+ return if metadata.directory? && metadata.uid == Process.uid
108
+
109
+ raise StateError, 'Workspace state directory ownership or path is unsafe'
110
+ end
111
+
112
+ def verify_inventory_file!
113
+ return unless path.exist? || path.symlink?
114
+
115
+ raise StateError, 'Infrastructure state is an unsafe symlink' if path.symlink?
116
+
117
+ metadata = path.lstat
118
+ return if metadata.file? && metadata.uid == Process.uid && metadata.mode.nobits?(0o077)
119
+
120
+ raise StateError, 'Infrastructure state ownership or path is unsafe'
121
+ end
122
+
123
+ def path
124
+ directory.join('infrastructure.json')
125
+ end
126
+
127
+ def validate!(data)
128
+ return RecoveryInventory.validate!(data, workspace: @workspace_id) if @recovery
129
+
130
+ unless data.is_a?(Hash) && data['schema_version'] == SCHEMA_VERSION && data['workspace'] == @workspace_id
131
+ raise StateError,
132
+ 'Invalid infrastructure state schema or workspace. Preserve the state and inspect its ownership.'
133
+ end
134
+ if legacy_vm_network?(data)
135
+ raise StateError,
136
+ 'Legacy VM gateway state: use the previous Empeira version to destroy this workspace, then recreate it'
137
+ end
138
+ validate_vm_layouts!(data)
139
+ return if valid_inventory?(data)
140
+
141
+ raise StateError, 'Invalid infrastructure inventory. Preserve it for recovery.'
142
+ end
143
+
144
+ def validate_vm_layouts!(data)
145
+ nodes = data['nodes']
146
+ return unless nodes.is_a?(Hash)
147
+
148
+ nodes.each_value do |record|
149
+ VM::Management.validate!(record) if record.is_a?(Hash) && record['provider'] == 'vm'
150
+ end
151
+ end
152
+
153
+ def legacy_vm_network?(data)
154
+ return true if data.key?('vm_gateway')
155
+ return false if data.key?('peer_network') || !data['nodes'].is_a?(Hash)
156
+
157
+ data['nodes'].values.any? { |record| record.is_a?(Hash) && record['provider'] == 'vm' }
158
+ end
159
+
160
+ def valid_inventory?(data)
161
+ Runtime.registry.names.include?(data['runtime']) &&
162
+ data['resources'].is_a?(Hash) && data['resources'].keys == ['network'] &&
163
+ valid_network?(data['resources']['network']) &&
164
+ valid_definition?(data) && valid_extensions?(data)
165
+ end
166
+
167
+ def valid_extensions?(data)
168
+ valid_timestamps?(data) && valid_control_plane?(data) &&
169
+ (!data.key?('nodes') || Node::Inventory.valid?(data['nodes'])) && valid_peer_network?(data) &&
170
+ valid_bootstrap_proxy?(data['bootstrap_proxy']) && RecoveryInventory.valid_retired_certificates?(data)
171
+ end
172
+
173
+ def valid_peer_network?(data)
174
+ return true unless data.key?('peer_network')
175
+
176
+ entry = data['peer_network']
177
+ return false unless entry.is_a?(Hash) && entry.keys == ['subnet']
178
+
179
+ layout = Network::Peer::Layout.new(entry['subnet'])
180
+ valid_peer_leases?(data, layout) && valid_container_leases?(data, layout)
181
+ rescue Infrastructure::StateError
182
+ false
183
+ end
184
+
185
+ def valid_peer_leases?(data, layout)
186
+ leases = data.fetch('nodes', {}).values.select { |record| record['provider'] == 'vm' }
187
+ .map { |record| record['peer'] }
188
+ leases.all? { |lease| layout.valid_lease?(lease) && valid_transport?(lease) } &&
189
+ leases.map { |lease| lease['ip'] }.uniq.size == leases.size
190
+ end
191
+
192
+ def valid_transport?(lease)
193
+ %w[LinuxPodman DockerAdapter PodmanMachine].include?(lease['backend']) &&
194
+ lease['dns'].is_a?(String) && IPAddr.new(lease['dns']).ipv4? &&
195
+ Node::Inventory.valid_id?(lease['machine']) &&
196
+ Node::Inventory.valid_certificate?(lease['helper_sha256'])
197
+ rescue IPAddr::InvalidAddressError
198
+ false
199
+ end
200
+
201
+ def valid_container_leases?(data, layout)
202
+ records = data.fetch('nodes', {}).values.select { |record| record['provider'] == 'container' }
203
+ addresses = records.map { |record| record.dig('definition', 'ip') }
204
+ addresses.uniq.size == addresses.size && addresses.all? do |ip|
205
+ (96..127).any? { |offset| layout.address(offset) == ip }
206
+ end
207
+ end
208
+
209
+ def valid_bootstrap_proxy?(entry)
210
+ entry.nil? || (entry.is_a?(Hash) && entry.keys == ['id'] && valid_resource_id?(entry['id']))
211
+ end
212
+
213
+ def valid_control_plane?(data)
214
+ plane = data['control_plane']
215
+ return true if plane.nil?
216
+ return false unless plane.is_a?(Hash)
217
+
218
+ !plane.key?('provider') &&
219
+ valid_records?(plane['services'], /\A[a-z][a-z0-9]*(?:-[a-z0-9]+)*\z/) &&
220
+ valid_records?(plane['volumes'],
221
+ /\Aopenvox-(?:ca|ssl|server-data|postgres-data|puppetdb-data)\z/) &&
222
+ valid_resource_id?(plane['egress']) &&
223
+ valid_plane_metadata?(plane)
224
+ end
225
+
226
+ def valid_plane_metadata?(plane)
227
+ flags = [plane['stopped'], plane['bridge_prepared']]
228
+ flags.all? { |flag| [nil, true, false].include?(flag) } &&
229
+ (plane['bridge_image'].nil? || plane['bridge_image'].is_a?(String)) &&
230
+ (plane['gateway_policy'].nil? || valid_code_digest?(plane['gateway_policy'])) &&
231
+ valid_proxy_reload?(plane['proxy_reload'])
232
+ end
233
+
234
+ def valid_proxy_reload?(checkpoint)
235
+ return true if checkpoint.nil?
236
+
237
+ checkpoint.is_a?(Hash) && checkpoint.keys.sort == %w[fingerprint id] &&
238
+ checkpoint['id'].is_a?(String) && valid_resource_id?(checkpoint['id']) &&
239
+ valid_code_digest?(checkpoint['fingerprint'])
240
+ end
241
+
242
+ def valid_code_digest?(value)
243
+ value.is_a?(String) && value.match?(/\A[a-f0-9]{64}\z/)
244
+ end
245
+
246
+ def valid_records?(records, pattern)
247
+ records.is_a?(Hash) && records.all? do |key, value|
248
+ key.is_a?(String) && key.match?(pattern) && value.is_a?(Hash) &&
249
+ value.keys == ['id'] && valid_resource_id?(value['id'])
250
+ end
251
+ end
252
+
253
+ def valid_definition?(data)
254
+ data['definition'].is_a?(Hash) && data['definition']['revision'].is_a?(Integer) &&
255
+ data['definition']['runtime'] == data['runtime'] &&
256
+ data['fingerprint'] == Definition.fingerprint(data['definition'])
257
+ end
258
+
259
+ def valid_timestamps?(data)
260
+ %w[created_at reconciled_at].all? { |key| data[key].is_a?(String) }
261
+ end
262
+
263
+ def valid_resource_id?(id)
264
+ id.nil? || (id.is_a?(String) && id.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]*\z/))
265
+ end
266
+
267
+ def valid_network?(network)
268
+ network.is_a?(Hash) && network['logical_identity'] == @network.identity &&
269
+ network['name'] == @network.backend_name &&
270
+ valid_resource_id?(network['id'])
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Modules
5
+ # Only controlled worker records affect counts; ordinary tool output stays buffered.
6
+ class Events
7
+ attr_reader :name
8
+
9
+ def initialize(progress:)
10
+ @progress = progress
11
+ @pending = +''.b
12
+ @completed = []
13
+ end
14
+
15
+ def observe(chunk)
16
+ @pending << chunk
17
+ while (line = @pending.slice!(/.*?\n/m))
18
+ event = line.match(/\AEMPEIRA_MODULE_(TOTAL|START|DONE):([a-z0-9_]+)\n\z/)
19
+ receive(*event.captures) if event
20
+ end
21
+ @pending.clear if @pending.bytesize > 1024
22
+ end
23
+
24
+ def start(name)
25
+ @name = name
26
+ show
27
+ end
28
+
29
+ def finish!
30
+ raise Error, 'Incomplete Puppetfile module progress protocol' unless @total == @completed.size
31
+ end
32
+
33
+ private
34
+
35
+ def receive(kind, value)
36
+ case kind
37
+ when 'TOTAL' then total(value)
38
+ when 'START' then start(value)
39
+ when 'DONE' then done(value)
40
+ end
41
+ end
42
+
43
+ def total(value)
44
+ raise Error, 'Invalid Puppetfile module total' unless @total.nil? && value.match?(/\A\d+\z/)
45
+
46
+ @total = Integer(value, 10)
47
+ @name = 'Puppetfile modules'
48
+ show
49
+ end
50
+
51
+ def done(name)
52
+ unless name == @name && @total && @completed.size < @total && !@completed.include?(name)
53
+ raise Error, 'Invalid Puppetfile module completion record'
54
+ end
55
+
56
+ @completed << name
57
+ show
58
+ end
59
+
60
+ def show
61
+ return @progress.heartbeat(@name) unless @total
62
+
63
+ @progress.count(@completed.size, @total, "#{@name} (#{@completed.size}/#{@total})")
64
+ end
65
+ end
66
+ end
67
+ end