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,763 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module ControlPlane
5
+ # Called under the infrastructure store's mutation lock.
6
+ # Keep one explicit transaction coordinator for the ordered service lifecycle.
7
+ # rubocop:disable-next Metrics/ClassLength
8
+ class Controller
9
+ def initialize(context:, runtime:, store:, runner: Execution::Runner.new, plan: nil, progress: Progress.new)
10
+ @progress = progress
11
+ @runner = runner
12
+ @context = context
13
+ @runtime = runtime
14
+ @store = store
15
+ @plan = plan || Plan.new(context: context)
16
+ @health = Health.new(runtime: runtime, plan: @plan)
17
+ logger = @runner.method(:debug) if @runner.respond_to?(:debug)
18
+ @direct_resolver = Network::DirectEgress::Resolver.new(logger: logger)
19
+ @observed = {}
20
+ end
21
+
22
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- Dependency-ordered reconciliation shares one transaction.
23
+ def up
24
+ preflight unless @desired_images
25
+ load_state
26
+ check_network_architecture!
27
+ check_service_names!
28
+ @gateway_reconciling = true
29
+ capture_services
30
+ lockdown_existing_gateway
31
+ prepare_files
32
+ @progress.stage(20, 'Preparing persistent storage...')
33
+ prepare_storage
34
+ prepare_network_services
35
+ @plan.environment.prepare
36
+ @progress.stage(45, 'Starting PostgreSQL...')
37
+ prepare_postgres
38
+ @progress.stage(60, 'Starting configuration server...')
39
+ prepare_server
40
+ prepare_puppetdb
41
+ @plan.additional_services.each_key { |key| reconcile(key) }
42
+ activate_dns
43
+ prepare_browser
44
+ refresh_redirects
45
+ @progress.stage(95, 'Verifying control plane...')
46
+ finish_reconciliation
47
+ @changed
48
+ rescue StandardError
49
+ lockdown_existing_gateway if @gateway_reconciling
50
+ raise
51
+ ensure
52
+ @desired_images = nil
53
+ @service_snapshot = nil
54
+ @discovery_nodes = nil
55
+ @gateway_locked_id = nil
56
+ @gateway_reconciling = false
57
+ end
58
+
59
+ # The browser lifecycle may change only the browser pair. Required workspace
60
+ # services and networks are inspected without being started or reconciled.
61
+ def browser
62
+ load_state
63
+ require_initialized_browser_workspace!
64
+ @plan.browser_enabled = true
65
+ check_service_names!
66
+ require_browser_access_network!
67
+ @dns = require_running_service!('dns')
68
+ definitions = Browser.new(@plan).definitions(dns: @dns)
69
+ check_images(definitions: definitions)
70
+ reconcile_browser('browser', definitions.fetch('browser'))
71
+ record_browser_address
72
+ reconcile_browser('browser-ui', definitions.fetch('browser-ui'))
73
+ browser_url
74
+ ensure
75
+ @desired_images = nil
76
+ end
77
+
78
+ def prepare_network_services
79
+ @progress.stage(30, 'Starting CoreDNS and gateways...')
80
+ ensure_egress
81
+ reconcile_bridge('apply')
82
+ reconcile_gateway
83
+ protect_existing_nodes
84
+ reconcile('dns')
85
+ @dns = @observed.fetch('dns').dig('networks', @plan.network, 'IPAddress')
86
+ raise Error, 'DNS service has no address in the isolated network' if @dns.nil? || @dns.empty?
87
+
88
+ @proxy_configuration_changed = @plan.files.bind_dns(@dns)
89
+ reconcile_proxy
90
+ end
91
+
92
+ def reconcile_bridge(action)
93
+ definition = Network::Definition.new(workspace: @context.workspace, policy: Network::Policy.new)
94
+ definition.allocation = @plan.subnet
95
+ if action == 'apply'
96
+ @inventory['bridge_prepared'] = true
97
+ @inventory['bridge_image'] = @plan.gateway_artifact.fetch(:image)
98
+ save
99
+ end
100
+ network_id = @state.dig('resources', 'network', 'id')
101
+ @runtime.reconcile_workspace_bridge(definition: definition, expected_id: network_id,
102
+ image: @inventory.fetch('bridge_image'), action: action)
103
+ end
104
+
105
+ def remove_workspace_bridge
106
+ reconcile_bridge('remove') if @inventory['bridge_prepared']
107
+ @inventory.delete('bridge_prepared')
108
+ @inventory.delete('bridge_image')
109
+ end
110
+
111
+ def protect_existing_nodes
112
+ return if @state.fetch('nodes', {}).empty?
113
+
114
+ definition = @plan.definitions.fetch('dns')
115
+ resource = service_resource(definition, expected_id: recorded('dns'))
116
+ return if resource && !image_changed?(resource, definition) &&
117
+ resource.dig('labels', 'io.empeira.definition') == definition.fingerprint
118
+
119
+ raise Error, 'DNS replacement would invalidate existing node resolver bindings; destroy nodes before empeira up'
120
+ end
121
+
122
+ def down
123
+ load_state
124
+ @progress.stage(25, 'Removing service containers...')
125
+ (@inventory.fetch('services').keys - %w[dns proxy gateway]).reverse_each { |key| remove(key) }
126
+ %w[proxy dns gateway].each { |key| remove(key) }
127
+ remove_workspace_bridge
128
+ remove_egress
129
+ @inventory['stopped'] = true
130
+ save if @state['control_plane']
131
+ @changed
132
+ end
133
+
134
+ def destroy
135
+ down
136
+ @progress.stage(75, 'Removing persistent volumes and credentials...')
137
+ @inventory.fetch('volumes').each_key do |key|
138
+ @runtime.remove_volume(identity(key), expected_id: @inventory.fetch('volumes').fetch(key).fetch('id'))
139
+ @inventory.fetch('volumes').delete(key)
140
+ save
141
+ end
142
+ cleanup_files
143
+ end
144
+
145
+ # rubocop:disable-next Metrics/AbcSize -- One bounded observation includes the central network health.
146
+ def status(state: @store.load)
147
+ load_state(state)
148
+ definitions = service_keys.to_h { |key| [key, identity(key)] }
149
+ expected = @inventory.fetch('services').transform_values { |record| record['id'] }
150
+ @observed = @runtime.inspect_services(definitions, expected_ids: expected)
151
+ report = @observed.to_h { |key, resource| [key.capitalize, readiness(key, resource)] }
152
+ report.merge(summary(report)).merge('Workspace network' =>
153
+ report.values.all? { |value| value == 'running' } ? 'healthy' : 'unhealthy')
154
+ end
155
+
156
+ def browser_url
157
+ Browser.url(@observed.fetch('browser-ui'))
158
+ end
159
+
160
+ def preflight
161
+ load_state
162
+ @plan.validate_server_mounts!
163
+ check_network_architecture!
164
+ check_service_names!
165
+ @plan.validate_dns_rewrites!
166
+ @plan.validate_network_redirects!
167
+ validate_redirect_routes!
168
+ @plan.module_request.warnings.each { |message| @progress.warning(message) }
169
+ @plan.module_request.verify_available!
170
+ lockdown_existing_gateway
171
+ check_images
172
+ end
173
+
174
+ private
175
+
176
+ def validate_redirect_routes!
177
+ entries = @plan.config.dig('network', 'redirects')
178
+ unless entries.any? { |entry| Network::DirectEgress::BENCHMARK.include?(entry.fetch('from').fetch('ip')) }
179
+ return
180
+ end
181
+
182
+ routes = Platform::Routes.new(platform: @context.platform, runner: @runner).ipv4 + @runtime.network_subnets
183
+ Network::Redirects.validate_routes!(entries, routes: routes)
184
+ end
185
+
186
+ def cleanup_files
187
+ @plan.files.cleanup
188
+ Server::RelayCertificate.new(context: @context).cleanup
189
+ @plan.environment.cleanup
190
+ @plan.additional_configurations.cleanup
191
+ end
192
+
193
+ def prepare_browser
194
+ return unless @plan.browser_enabled
195
+
196
+ reconcile('browser')
197
+ record_browser_address
198
+ reconcile('browser-ui')
199
+ end
200
+
201
+ def check_service_names!
202
+ names = @state.fetch('nodes', {}).keys.map { |name| name.delete_suffix('.empeira.internal') }
203
+ return unless names.intersect?(@plan.definitions.keys)
204
+
205
+ raise Error, 'Additional service name collides with an existing node; rename the service or destroy that node'
206
+ end
207
+
208
+ def prepare_files
209
+ @plan.additional_configurations.prepare
210
+ @plan.module_request
211
+ prepare_dns_resolution
212
+ @reload_dns = dns_reload_needed?
213
+ @bootstrap_dns = bootstrap_dns?(**@dns_resolution)
214
+ @direct_egress = resolve_direct_egress
215
+ @plan.files.prepare(**@dns_resolution, bootstrap: @bootstrap_dns)
216
+ prepare_gateway_plan
217
+ end
218
+
219
+ def prepare_dns_resolution
220
+ resolver = Platform::Resolvers.new(platform: @context.platform, runner: @runner)
221
+ upstreams = resolver.resolve(@plan.config.fetch('dns').fetch('upstream'))
222
+ @dns_resolution = { upstreams: upstreams, routes: resolver.routes }
223
+ end
224
+
225
+ def prepare_gateway_plan
226
+ @plan.validate_network_redirects!
227
+ @gateway_plan = Network::Gateway.plan(state: @state, resolved: @direct_egress,
228
+ additional: @plan.config.dig('dns', 'additional_resolver'),
229
+ redirects: resolved_redirects(existing_redirect_targets),
230
+ **@dns_resolution)
231
+ @plan.files.write('gateway.json', JSON.generate(@gateway_plan))
232
+ end
233
+
234
+ def existing_redirect_targets
235
+ definitions = @plan.definitions.slice(*@plan.config.dig('network', 'redirects').map do |entry|
236
+ Network::Redirects.target_key(entry)
237
+ end)
238
+ ids = definitions.to_h { |key, _| [key, recorded(key)] }
239
+ definitions.to_h { |key, definition| [key, service_resource(definition, expected_id: ids[key])] }
240
+ end
241
+
242
+ def resolved_redirects(resources)
243
+ Network::Redirects.new(entries: @plan.config.dig('network', 'redirects'), network: @plan.network,
244
+ subnet: @plan.subnet).resolve(definitions: @plan.definitions, resources: resources)
245
+ end
246
+
247
+ def refresh_redirects
248
+ redirects = resolved_redirects(@observed)
249
+ return if redirects == @gateway_plan.fetch('redirects')
250
+
251
+ @gateway_plan['redirects'] = redirects
252
+ @plan.files.write('gateway.json', JSON.generate(@gateway_plan))
253
+ apply_gateway_policy
254
+ end
255
+
256
+ def resolve_direct_egress
257
+ config = @plan.config.dig('network', 'egress')
258
+ @direct_resolver.resolve(config)
259
+ end
260
+
261
+ def bootstrap_dns?(upstreams:, routes:)
262
+ return false unless @plan.config.dig('dns', 'additional_resolver')
263
+ return true unless @plan.files.corefile_current?(upstreams: upstreams, routes: routes)
264
+
265
+ definition = @plan.definitions.fetch('dns')
266
+ resource = service_resource(definition, expected_id: recorded('dns'))
267
+ resource.nil? || resource['state'] != 'running' || stale?('dns', resource, definition)
268
+ end
269
+
270
+ def dns_reload_needed?
271
+ return false if @plan.files.corefile_current?(**@dns_resolution)
272
+
273
+ definition = @plan.definitions.fetch('dns')
274
+ service_resource(definition, expected_id: recorded('dns'))&.fetch('state') == 'running'
275
+ end
276
+
277
+ def activate_dns
278
+ return unless @bootstrap_dns || @reload_dns
279
+
280
+ @plan.files.activate_additional_resolver(**@dns_resolution) if @bootstrap_dns
281
+ @runtime.reload_service(@observed.fetch('dns'), signal: 'USR1')
282
+ @changed = true
283
+ end
284
+
285
+ def service_keys
286
+ @plan.definitions.keys | @inventory.fetch('services').keys
287
+ end
288
+
289
+ def summary(report)
290
+ { 'Control plane' => report.values.all? { |value| value == 'running' } ? 'running' : 'degraded',
291
+ 'Readiness' => 'not checked; empeira up verifies readiness',
292
+ 'Persistent volumes' => @inventory.fetch('volumes').size.to_s }
293
+ end
294
+
295
+ def readiness(key, resource)
296
+ return 'missing' unless resource
297
+
298
+ verify_networks!(key, resource)
299
+ definition = @plan.definitions[key]
300
+ return 'stale' if definition && resource.dig('labels', 'io.empeira.definition') != definition.fingerprint
301
+
302
+ return gateway_readiness(resource) if key == 'gateway' && resource['state'] == 'running'
303
+
304
+ resource['state']
305
+ end
306
+
307
+ def gateway_readiness(resource)
308
+ reconcile_bridge('check') if @inventory['bridge_prepared']
309
+ result = @runtime.service_exec(resource, [Network::Gateway::EXECUTABLE, 'check',
310
+ '/empeira-gateway/gateway.json'], timeout: 3)
311
+ result.success? ? 'running' : 'unhealthy; firewall/routing reconcile required'
312
+ rescue Providers::ExecutionError
313
+ 'unhealthy; workspace bridge attachment reconcile required'
314
+ end
315
+
316
+ def check_images(definitions: @plan.definitions)
317
+ @desired_images = {}
318
+ @runtime.with_local_images do
319
+ definitions.each_value do |definition|
320
+ image = definition.options.fetch('image')
321
+ @runtime.ensure_image(image, recipe: definition.options['recipe'],
322
+ files: definition.options.fetch('build_files', {}))
323
+ @desired_images[image] = @runtime.image_id(image)
324
+ end
325
+ end
326
+ end
327
+
328
+ def prepare_postgres
329
+ return unless @plan.database?
330
+
331
+ reconcile('postgres')
332
+ @health.wait('postgres', @observed)
333
+ result = @runtime.service_exec(@observed.fetch('postgres'),
334
+ ['psql', '-U', 'postgres', '-d', 'puppetdb', '-v', 'ON_ERROR_STOP=1',
335
+ '-f', '/empeira/setup.sql'])
336
+ raise Error, 'PostgreSQL roles and pg_trgm initialization failed' unless result.success?
337
+ end
338
+
339
+ def prepare_server
340
+ reconcile('server')
341
+ refresh_eyaml_keys if @plan.eyaml_staged?
342
+ Runtime::MountProbe.new(runtime: @runtime).verify(@observed.fetch('server'),
343
+ [@plan.repository_mounts, @plan.eyaml_mounts,
344
+ @plan.project_server_mounts].flatten)
345
+ @health.wait('server', @observed)
346
+ reconcile_server_environment
347
+ Server::RelayCertificate.new(context: @context).prepare(runtime: @runtime, server: @observed.fetch('server')) if
348
+ @plan.database?
349
+ end
350
+
351
+ def reconcile_server_environment
352
+ environment = Server::EnvironmentConfiguration.new(runtime: @runtime, plan: @plan)
353
+ return unless environment.reconcile(@observed.fetch('server'))
354
+
355
+ @changed = true
356
+ @health.wait('server', @observed)
357
+ environment.verify!(@observed.fetch('server'))
358
+ end
359
+
360
+ def refresh_eyaml_keys
361
+ result = @runtime.service_exec(@observed.fetch('server'),
362
+ ['/bin/sh', '/empeira-server/start.sh', 'refresh-eyaml'])
363
+ raise Error, 'Puppet Server EYAML key staging failed; inspect server logs' unless result.success?
364
+ end
365
+
366
+ def prepare_puppetdb
367
+ return unless @plan.database?
368
+
369
+ @progress.stage(85, 'Starting PuppetDB...')
370
+ reconcile('puppetdb-backend')
371
+ @health.wait('puppetdb-backend', @observed)
372
+ reconcile('puppetdb')
373
+ @health.wait('puppetdb', @observed)
374
+ end
375
+
376
+ def finish_reconciliation
377
+ remove_disabled
378
+ @health.wait('dns', @observed)
379
+ @health.wait('proxy', @observed) if @plan.proxy?
380
+ update_hosts(reload_proxy: true) if @plan.proxy?
381
+ @inventory['stopped'] = false
382
+ save
383
+ @plan.additional_configurations.prune
384
+ end
385
+
386
+ def load_state(state = @store.load)
387
+ @state = state || {}
388
+ @inventory = @state['control_plane'] || { 'services' => {}, 'volumes' => {}, 'egress' => nil }
389
+ @inventory.delete('environment_cache')
390
+ @plan.subnet = @state.dig('peer_network', 'subnet')
391
+ @plan.browser_enabled = @inventory.fetch('services').key?('browser')
392
+ @changed = false
393
+ @proxy_bindings_changed = false
394
+ end
395
+
396
+ def save
397
+ @state['control_plane'] = @inventory
398
+ @store.write(@state)
399
+ end
400
+
401
+ def check_network_architecture!
402
+ services = @inventory.fetch('services')
403
+ return if services.empty? || services.key?('gateway')
404
+
405
+ raise Error,
406
+ 'Workspace network architecture changed; destroy this alpha workspace with its previous version first'
407
+ end
408
+
409
+ def capture_services
410
+ definitions = service_keys.to_h { |key| [key, identity(key)] }
411
+ expected = @inventory.fetch('services').transform_values { |record| record['id'] }
412
+ @service_snapshot = @runtime.inspect_services(definitions, expected_ids: expected)
413
+ @observed = @service_snapshot.slice(*@plan.definitions.keys).compact
414
+ end
415
+
416
+ def service_resource(definition, expected_id:)
417
+ unless @service_snapshot&.key?(definition.key)
418
+ return @runtime.inspect_service(definition,
419
+ expected_id: expected_id)
420
+ end
421
+
422
+ resource = @service_snapshot[definition.key]
423
+ definition.verify!(resource, expected_id: expected_id)
424
+ resource
425
+ end
426
+
427
+ def prepare_storage
428
+ storage = Storage.new(runtime: @runtime, plan: @plan, inventory: @inventory, persist: method(:save))
429
+ @changed = storage.prepare || @changed
430
+ end
431
+
432
+ def reconcile(key)
433
+ definition = @plan.definitions(dns: @dns).fetch(key)
434
+ resource = service_resource(definition, expected_id: recorded(key))
435
+ if resource && stale?(key, resource, definition)
436
+ remove(key)
437
+ resource = nil
438
+ end
439
+ resource ||= create_service(definition)
440
+ start_service(resource)
441
+ observe_service(key, definition, resource.fetch('id'))
442
+ configure_route(key)
443
+ update_hosts
444
+ end
445
+
446
+ def reconcile_browser(key, definition)
447
+ resource = service_resource(definition, expected_id: recorded(key))
448
+ if resource && stale?(key, resource, definition)
449
+ remove(key)
450
+ resource = nil
451
+ end
452
+ resource ||= create_service(definition)
453
+ start_service(resource)
454
+ observe_service(key, definition, resource.fetch('id'))
455
+ configure_route(key)
456
+ end
457
+
458
+ def record_browser_address
459
+ address = @observed.fetch('browser').dig('networks', @plan.network, 'IPAddress')
460
+ raise Error, 'Browser service has no address in the isolated network' if address.nil? || address.empty?
461
+
462
+ IPAddr.new(address)
463
+ @plan.files.write('browser-address', "#{address}\n")
464
+ rescue IPAddr::InvalidAddressError
465
+ raise Error, 'Browser service has an invalid address in the isolated network'
466
+ end
467
+
468
+ def require_initialized_browser_workspace!
469
+ return if @state['control_plane']
470
+
471
+ raise Error, "Workspace control plane is not initialized. Start the workspace with 'empeira up' first."
472
+ end
473
+
474
+ def require_browser_access_network!
475
+ return if observed_egress
476
+
477
+ raise Error, "Required browser access network is not running. Start the workspace with 'empeira up' first."
478
+ end
479
+
480
+ def require_running_service!(key)
481
+ definition = @plan.definitions.fetch(key)
482
+ resource = service_resource(definition, expected_id: recorded(key))
483
+ require_service_running!(key, resource)
484
+ if resource.dig('labels', 'io.empeira.definition') != definition.fingerprint
485
+ raise Error, "Required service #{key} is stale. Run 'empeira up' first."
486
+ end
487
+
488
+ verify_networks!(key, resource)
489
+ @observed[key] = resource
490
+ address = resource.dig('networks', @plan.network, 'IPAddress')
491
+ require_service_address!(key, address)
492
+
493
+ address
494
+ end
495
+
496
+ def require_service_running!(key, resource)
497
+ return if resource && resource.fetch('state') == 'running'
498
+
499
+ raise Error, "Required service #{key} is not running. Start the workspace with 'empeira up' first."
500
+ end
501
+
502
+ def require_service_address!(key, address)
503
+ return unless address.nil? || address.empty?
504
+
505
+ raise Error, "Required service #{key} has no workspace address. Run 'empeira up' first."
506
+ end
507
+
508
+ def observe_service(key, definition, id)
509
+ @observed[key] = service_resource(definition, expected_id: id)
510
+ require_started!(key, @observed.fetch(key))
511
+ attach_browser_ui_if_needed(key, definition, id)
512
+ verify_networks!(key, @observed.fetch(key))
513
+ @service_snapshot[key] = @observed.fetch(key) if @service_snapshot
514
+ end
515
+
516
+ def attach_browser_ui_if_needed(key, definition, id)
517
+ return unless key == 'browser-ui' && !@observed.fetch(key).fetch('networks').key?(@plan.network)
518
+
519
+ @runtime.attach_egress(Network::Definition.new(workspace: @context.workspace, policy: Network::Policy.new),
520
+ @observed.fetch(key))
521
+ @service_snapshot&.delete(key)
522
+ @observed[key] = service_resource(definition, expected_id: id)
523
+ end
524
+
525
+ def require_started!(key, resource)
526
+ return if resource.fetch('state') == 'running'
527
+
528
+ raise Error, "Managed service #{key} is #{resource.fetch('state')} after start; " \
529
+ "inspect #{@context.container_engine} logs #{resource.fetch('id')}"
530
+ end
531
+
532
+ def update_hosts(reload_proxy: false)
533
+ # A running container can still be initializing Squid. Defer HUP until its listener is ready.
534
+ changed = Discovery.new(plan: @plan, runtime: @runtime, state: @state).refresh(
535
+ services: @observed, node_resources: discovery_nodes, reload_proxy: reload_proxy,
536
+ force_proxy_reload: @proxy_configuration_changed || @proxy_bindings_changed
537
+ )
538
+ @proxy_bindings_changed = changed || @proxy_bindings_changed
539
+ @changed = true if @plan.proxy? && (changed || @proxy_configuration_changed)
540
+ end
541
+
542
+ def discovery_nodes
543
+ return @discovery_nodes if @discovery_nodes
544
+
545
+ records = @state.fetch('nodes', {}).select { |_, record| record['provider'] == 'container' }
546
+ definitions = records.transform_values do |record|
547
+ Node::Definition.new(hostname: record.fetch('hostname'), workspace: @context.workspace)
548
+ end
549
+ ids = records.transform_values { |record| record['id'] }
550
+ @discovery_nodes = definitions.empty? ? {} : @runtime.inspect_services(definitions, expected_ids: ids)
551
+ end
552
+
553
+ def start_service(resource)
554
+ return if resource.fetch('state') == 'running'
555
+
556
+ @runtime.start_service(resource)
557
+ @service_snapshot&.delete(resource.dig('labels', 'io.empeira.purpose'))
558
+ @changed = true
559
+ end
560
+
561
+ def stale?(key, resource, definition)
562
+ resource.dig('labels', 'io.empeira.definition') != definition.fingerprint ||
563
+ image_changed?(resource, definition) || address_changed?(resource, definition) ||
564
+ (!%w[dns gateway].include?(key) && resource['dns'] != [@dns])
565
+ end
566
+
567
+ def address_changed?(resource, definition)
568
+ expected = definition.options['runtime_ip']
569
+ expected && resource.dig('networks', @plan.network, 'IPAddress') != expected
570
+ end
571
+
572
+ def image_changed?(resource, definition)
573
+ resource['image_id'] != @desired_images&.fetch(definition.options.fetch('image'), nil)
574
+ end
575
+
576
+ def create_service(definition)
577
+ @service_snapshot&.delete(definition.key)
578
+ @inventory.fetch('services')[definition.key] = { 'id' => nil }
579
+ save
580
+ resource = @runtime.create_service(definition)
581
+ @inventory.fetch('services')[definition.key] = { 'id' => resource.fetch('id') }
582
+ save
583
+ @changed = true
584
+ resource
585
+ end
586
+
587
+ def verify_networks!(key, resource)
588
+ allowed = [@plan.network]
589
+ allowed << egress.backend_name if %w[gateway browser-ui].include?(key)
590
+ unless resource.fetch('networks').key?(@plan.network) &&
591
+ resource.fetch('networks').keys.all? { |name| allowed.include?(name) } &&
592
+ valid_ports?(key, resource)
593
+ raise Providers::OwnershipError, "#{key} has unexpected network attachments or published ports"
594
+ end
595
+ end
596
+
597
+ def valid_ports?(key, resource)
598
+ return Browser.valid_ports?(resource) if key == 'browser-ui'
599
+
600
+ resource.fetch('ports').empty? && resource.fetch('published_ports', {}).values.all?(&:nil?)
601
+ end
602
+
603
+ def reconcile_proxy
604
+ return unless @plan.proxy?
605
+
606
+ ensure_egress
607
+ reconcile('proxy')
608
+ end
609
+
610
+ def configure_route(key)
611
+ return if %w[gateway browser-ui].include?(key)
612
+
613
+ Network::Gateway.new(context: @context, runtime: @runtime, state: @state).route(@observed.fetch(key))
614
+ rescue Providers::ExecutionError
615
+ resource = @runtime.inspect_service(identity(key), expected_id: recorded(key))
616
+ require_started!(key, resource) if resource
617
+ raise
618
+ end
619
+
620
+ def lockdown_existing_gateway
621
+ resource = @runtime.inspect_service(identity('gateway'), expected_id: recorded('gateway'))
622
+ return unless resource && resource['state'] == 'running'
623
+
624
+ lockdown_gateway(resource)
625
+ rescue Error
626
+ @runtime.stop_service(resource) if resource
627
+ raise
628
+ end
629
+
630
+ # rubocop:disable-next Metrics/AbcSize -- Uplink attachment is ordered behind verified lockdown.
631
+ def reconcile_gateway
632
+ resource = service_resource(identity('gateway'), expected_id: recorded('gateway'))
633
+ if resource && resource['state'] != 'running' && resource.fetch('networks').key?(egress.backend_name)
634
+ @runtime.detach_egress(egress, resource)
635
+ end
636
+ reconcile('gateway')
637
+ lockdown_gateway(@observed.fetch('gateway'))
638
+ require_redirect_capability
639
+ attach_gateway('gateway')
640
+ apply_gateway_policy
641
+ rescue StandardError
642
+ resource = @observed['gateway']
643
+ @runtime.stop_service(resource) if resource
644
+ raise
645
+ end
646
+
647
+ def lockdown_gateway(resource)
648
+ return if @gateway_locked_id == resource.fetch('id')
649
+
650
+ gateway_command(resource, 'lockdown')
651
+ @gateway_locked_id = resource.fetch('id')
652
+ end
653
+
654
+ # rubocop:disable-next Metrics/AbcSize -- Negotiate legacy TCP and extended DNS capability without fallback.
655
+ def require_redirect_capability
656
+ return if @plan.config.dig('network', 'redirects').empty?
657
+
658
+ result = @runtime.service_exec(@observed.fetch('gateway'),
659
+ [Network::Gateway::EXECUTABLE, 'redirects-capability'])
660
+ protocols = @plan.config.dig('network', 'redirects').map do |entry|
661
+ entry.fetch('from').fetch('protocol', 'tcp')
662
+ end
663
+ supported = protocols.include?('udp') ? ['tcp-udp-redirects-v2'] : %w[tcp-redirects-v1 tcp-udp-redirects-v2]
664
+ return if result.success? && supported.include?(result.stdout.strip)
665
+
666
+ raise Error, 'Gateway does not support transparent IPv4 TCP/UDP redirects; configure a compatible ' \
667
+ 'images.direct_egress image'
668
+ end
669
+
670
+ def apply_gateway_policy
671
+ # An uncertain apply must force fresh lockdown during failure recovery.
672
+ @gateway_locked_id = nil
673
+ gateway_command(@observed.fetch('gateway'), 'apply', '/empeira-gateway/gateway.json')
674
+ record_gateway_policy
675
+ end
676
+
677
+ def record_gateway_policy
678
+ fingerprint = Infrastructure::Definition.fingerprint(@gateway_plan)
679
+ @changed = true if @inventory['gateway_policy'] != fingerprint
680
+ @inventory['gateway_policy'] = fingerprint
681
+ save
682
+ end
683
+
684
+ def gateway_command(resource, *arguments)
685
+ result = @runtime.service_exec(resource, [Network::Gateway::EXECUTABLE, *arguments])
686
+ return if result.success?
687
+
688
+ raise Error, 'Gateway firewall reconcile failed; workspace egress remains blocked'
689
+ end
690
+
691
+ def attach_gateway(key)
692
+ resource = @observed.fetch(key)
693
+ return if resource.fetch('networks').key?(egress.backend_name)
694
+
695
+ @runtime.attach_egress(egress, resource)
696
+ @observed[key] = @runtime.inspect_service(identity(key), expected_id: recorded(key))
697
+ @service_snapshot[key] = @observed.fetch(key) if @service_snapshot
698
+ @changed = true
699
+ end
700
+
701
+ def ensure_egress
702
+ resource = observed_egress
703
+ unless resource
704
+ save
705
+ resource = @runtime.create_network(definition: egress).resource
706
+ @changed = true
707
+ end
708
+ @inventory['egress'] = resource.id
709
+ save
710
+ end
711
+
712
+ def observed_egress
713
+ expected = @inventory['egress']
714
+ resource = @runtime.inspect_network(identifier: egress.backend_name)
715
+ egress.verify_ownership!(resource, expected_id: expected)
716
+ if resource.nil? && expected && @runtime.inspect_network(identifier: expected)
717
+ raise Providers::OwnershipError, 'Recorded egress network exists under a different name'
718
+ end
719
+ return unless resource
720
+
721
+ egress.verify_definition!(resource)
722
+ egress.verify_isolation!(resource)
723
+ resource
724
+ end
725
+
726
+ def remove_disabled
727
+ (@inventory.fetch('services').keys - @plan.definitions.keys).each { |key| remove(key) }
728
+ update_hosts
729
+ end
730
+
731
+ def remove_egress
732
+ result = @runtime.remove_network(definition: egress, expected_id: @inventory['egress'])
733
+ @changed ||= result.changed
734
+ @inventory['egress'] = nil
735
+ save if @state['control_plane']
736
+ end
737
+
738
+ def remove(key)
739
+ return unless @inventory.fetch('services').key?(key)
740
+
741
+ @changed = @runtime.remove_service(identity(key), expected_id: recorded(key)) || @changed
742
+ @inventory.fetch('services').delete(key)
743
+ @inventory.delete('gateway_policy') if key == 'gateway'
744
+ @inventory.delete('proxy_reload') if key == 'proxy'
745
+ @observed.delete(key)
746
+ @service_snapshot&.delete(key)
747
+ save
748
+ end
749
+
750
+ def recorded(key)
751
+ @inventory.fetch('services').dig(key, 'id')
752
+ end
753
+
754
+ def identity(key)
755
+ Services::Definition.new(key: key, workspace: @context.workspace)
756
+ end
757
+
758
+ def egress
759
+ Network::Egress.new(workspace: @context.workspace, policy: Network::Policy.new)
760
+ end
761
+ end
762
+ end
763
+ end