ace-runtime 0.2.0 → 0.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/docs/usage.md +55 -0
- data/lib/ace/runtime/molecules/codex_runtime_installation.rb +130 -0
- data/lib/ace/runtime/molecules/execution_boot_baseline.rb +134 -0
- data/lib/ace/runtime/molecules/execution_network_selection.rb +85 -0
- data/lib/ace/runtime/molecules/execution_unit_installation.rb +375 -21
- data/lib/ace/runtime/molecules/kernel_view_topology.rb +133 -0
- data/lib/ace/runtime/molecules/protected_artifact_set.rb +74 -9
- data/lib/ace/runtime/molecules/protected_socket.rb +24 -2
- data/lib/ace/runtime/molecules/protected_task_context_entry.rb +162 -0
- data/lib/ace/runtime/molecules/protected_worker_entry.rb +32 -0
- data/lib/ace/runtime/molecules/readiness_configuration.rb +154 -0
- data/lib/ace/runtime/molecules/readiness_runtime.rb +94 -0
- data/lib/ace/runtime/molecules/server_resource_observation.rb +183 -0
- data/lib/ace/runtime/molecules/systemd_scope_manager.rb +103 -15
- data/lib/ace/runtime/version.rb +1 -1
- data/lib/ace/runtime.rb +1 -0
- data/native/README.md +61 -6
- data/native/build-worker-gate +4 -2
- data/native/worker_gate.c +206 -20
- metadata +11 -2
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "protected_worker_entry"
|
|
4
|
+
require_relative "execution_network_selection"
|
|
5
|
+
|
|
3
6
|
require "json"
|
|
4
7
|
require "digest"
|
|
5
8
|
require "etc"
|
|
6
9
|
require_relative "systemd_scope_manager"
|
|
10
|
+
require_relative "readiness_configuration"
|
|
7
11
|
|
|
8
12
|
module Ace
|
|
9
13
|
module Runtime
|
|
@@ -14,35 +18,55 @@ module Ace
|
|
|
14
18
|
class ExecutionUnitInstallation
|
|
15
19
|
SCHEMA = "ace.execution-unit-manifest/v1"
|
|
16
20
|
ROLES = %w[slice_fragment service_fragment unit_dropin native_executable native_configuration
|
|
17
|
-
readiness_executable bootstrap worker_executable runtime_dependency].freeze
|
|
21
|
+
readiness_executable readiness_configuration boundary_manifest bootstrap worker_executable runtime_dependency].freeze
|
|
18
22
|
REQUIRED_ROLES = %w[slice_fragment service_fragment native_executable native_configuration
|
|
19
|
-
readiness_executable bootstrap worker_executable].freeze
|
|
23
|
+
readiness_executable readiness_configuration boundary_manifest bootstrap worker_executable].freeze
|
|
24
|
+
CONTEXT_REQUIRED_ROLES = %w[slice_fragment service_fragment boundary_manifest bootstrap
|
|
25
|
+
context_executable context_configuration interpreter].freeze
|
|
26
|
+
CONTEXT_ROLES = (CONTEXT_REQUIRED_ROLES + %w[unit_dropin runtime_dependency]).freeze
|
|
27
|
+
CODEX_REQUIRED_ROLES = %w[slice_fragment service_fragment native_executable native_configuration boundary_manifest].freeze
|
|
28
|
+
CODEX_ROLES = (CODEX_REQUIRED_ROLES + %w[unit_dropin runtime_dependency]).freeze
|
|
20
29
|
SERVICE_REQUIRED = {"Type" => "exec", "Restart" => "no", "RestartForceExitStatus" => [[], []],
|
|
21
30
|
"KillMode" => "control-group", "SendSIGKILL" => true, "Delegate" => false,
|
|
22
31
|
"ProtectControlGroups" => true, "NoNewPrivileges" => true, "CapabilityBoundingSet" => 0,
|
|
23
32
|
"AmbientCapabilities" => 0, "RestrictNamespaces" => 0, "PrivateIPC" => true,
|
|
24
|
-
"PrivateDevices" => true, "
|
|
33
|
+
"PrivateDevices" => true, "PrivateTmp" => false, "MountAPIVFS" => true, "ProtectKernelTunables" => true, "BindLogSockets" => false,
|
|
34
|
+
"DevicePolicy" => "closed", "DeviceAllow" => [], "ProtectSystem" => "strict", "DynamicUser" => false, "PAMName" => "",
|
|
25
35
|
"EnvironmentFiles" => [], "PassEnvironment" => [], "UnsetEnvironment" => [],
|
|
26
|
-
"StandardOutput" => "journal", "StandardError" => "journal", "RuntimeDirectoryPreserve" => "no",
|
|
36
|
+
"StandardInput" => "null", "TTYPath" => "", "StandardOutput" => "journal", "StandardError" => "journal", "RuntimeDirectoryPreserve" => "no",
|
|
27
37
|
"RuntimeDirectoryMode" => 0o700, "UMask" => 0o077, "RootImage" => "", "RootImageOptions" => [], "RootEphemeral" => false, "ExtensionDirectories" => [],
|
|
28
38
|
"ExtensionImages" => [], "MountImages" => [], "TemporaryFileSystem" => [], "InaccessiblePaths" => []}.freeze
|
|
29
39
|
EMPTY_ACTIVATION = %w[Requisite BindsTo PartOf Upholds OnFailure OnSuccess OnFailureOf OnSuccessOf
|
|
30
40
|
TriggeredBy Triggers PropagatesStopTo StopPropagatedFrom JoinsNamespaceOf].freeze
|
|
31
41
|
NATIVE_ENVIRONMENT = %w[HERDR_CONFIG_PATH HERDR_SOCKET_PATH HOME SHELL PATH LANG LC_ALL TERM
|
|
32
42
|
TMPDIR TMP TEMP XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME XDG_RUNTIME_DIR CODEX_HOME CLAUDE_CONFIG_DIR].freeze
|
|
43
|
+
MANAGER_ENVIRONMENT = (NATIVE_ENVIRONMENT + %w[LANGUAGE LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY LC_MESSAGES
|
|
44
|
+
LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT LC_IDENTIFICATION]).freeze
|
|
33
45
|
IMPLICIT_NAMESPACE_ROOTS = %w[/dev /proc /sys /run /tmp /var/tmp].freeze
|
|
34
46
|
EMPTY_EXEC = %w[ExecConditionEx ExecStartPreEx ExecReloadEx ExecStopEx ExecStopPostEx].freeze
|
|
35
47
|
|
|
36
48
|
class Files
|
|
49
|
+
def authority_socket!(authority)
|
|
50
|
+
path = authority.fetch("socket_path")
|
|
51
|
+
ProtectedSocket.root_path!(File.dirname(path), directory: true, owner: authority.fetch("uid"))
|
|
52
|
+
identity = ProtectedSocket.socket_identity(path)
|
|
53
|
+
raise RuntimeUnavailableError, "selected authority socket owner differs" unless identity.last == authority.fetch("uid")
|
|
54
|
+
identity
|
|
55
|
+
end
|
|
37
56
|
def read(path, limit:)
|
|
38
57
|
ProtectedSocket.root_path!(path)
|
|
39
|
-
File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
|
|
58
|
+
File.open(path, File::RDONLY | File::NONBLOCK | File::NOFOLLOW) do |file|
|
|
40
59
|
stat = file.stat
|
|
41
|
-
unless stat.file? && stat.uid.zero? && (stat.mode &
|
|
60
|
+
unless stat.file? && stat.uid.zero? && (stat.mode & 0o6022).zero? && stat.size <= limit
|
|
42
61
|
raise RuntimeUnavailableError, "installed artifact descriptor ownership is unsafe"
|
|
43
62
|
end
|
|
44
63
|
bytes = file.read(limit + 1)
|
|
45
64
|
raise RuntimeUnavailableError, "installed artifact is oversized" if bytes.bytesize > limit
|
|
65
|
+
after = file.stat
|
|
66
|
+
unless [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode, stat.size, stat.mtime, stat.ctime] ==
|
|
67
|
+
[after.dev, after.ino, after.uid, after.gid, after.mode, after.size, after.mtime, after.ctime] && bytes.bytesize == stat.size
|
|
68
|
+
raise RuntimeUnavailableError, "installed held artifact changed"
|
|
69
|
+
end
|
|
46
70
|
bytes
|
|
47
71
|
end
|
|
48
72
|
end
|
|
@@ -101,18 +125,61 @@ module Ace
|
|
|
101
125
|
end
|
|
102
126
|
end
|
|
103
127
|
|
|
104
|
-
def initialize(scope:, native:, bootstrap:,
|
|
105
|
-
@scope, @native, @bootstrap, @
|
|
128
|
+
def initialize(scope:, native:, bootstrap:, worker_entry:, worker_uid:, worker_gid:, files: Files.new)
|
|
129
|
+
@scope, @native, @bootstrap, @worker_entry, @files = scope, native, bootstrap, worker_entry, files
|
|
130
|
+
ProtectedWorkerEntry.validate!(@worker_entry)
|
|
106
131
|
@worker_uid, @worker_gid = worker_uid, worker_gid
|
|
107
132
|
end
|
|
108
133
|
|
|
134
|
+
# Context entries have their own fixed protocol. The same effective
|
|
135
|
+
# installation, activation, mount and isolation checks still apply.
|
|
136
|
+
def self.for_inbox_context(service:, configuration:, load_paths:, authority:, owner_credentials:, files: Files.new)
|
|
137
|
+
unless owner_credentials.is_a?(Hash) && owner_credentials.keys.sort == %w[gid groups uid] &&
|
|
138
|
+
owner_credentials.values_at("uid", "gid").all? { |id| id.is_a?(Integer) && id.positive? } &&
|
|
139
|
+
owner_credentials["groups"].is_a?(Array) && owner_credentials["groups"].size <= 64 &&
|
|
140
|
+
owner_credentials["groups"].all? { |id| id.is_a?(Integer) && id >= 0 } &&
|
|
141
|
+
owner_credentials["groups"] == owner_credentials["groups"].sort.uniq &&
|
|
142
|
+
load_paths.is_a?(Array) && load_paths.size.between?(1, 64) && load_paths.uniq.size == load_paths.size
|
|
143
|
+
raise RuntimeUnavailableError, "context installation selection differs"
|
|
144
|
+
end
|
|
145
|
+
selected = allocate
|
|
146
|
+
selected.send(:initialize_context, service, configuration, load_paths, authority, owner_credentials, files)
|
|
147
|
+
selected
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Selection only. The original bootstrap authenticates mapping ownership;
|
|
151
|
+
# verify! still requires the actual fixed manager profile and full topology.
|
|
152
|
+
def verify_codex_runtime_selection!(service:, intent_reference:, manager:)
|
|
153
|
+
unless @codex_service && @codex_service == service && @codex_intent_reference == intent_reference
|
|
154
|
+
raise RuntimeUnavailableError, "Codex dedicated installation selection differs"
|
|
155
|
+
end
|
|
156
|
+
verify!(manager: manager)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.for_codex_runtime(service:, intent_reference:, original_mapping:, mapping_id:, authority:, files: Files.new)
|
|
160
|
+
require_relative "codex_runtime_installation"
|
|
161
|
+
selected = allocate
|
|
162
|
+
selected.send(:initialize_codex_runtime, service, intent_reference, original_mapping, mapping_id, authority, files)
|
|
163
|
+
selected
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Descriptor shape only; no effective unit observation or role grant.
|
|
167
|
+
def self.validate_inbox_context_service!(service)
|
|
168
|
+
selected = allocate
|
|
169
|
+
selected.send(:validate_context_service!, service)
|
|
170
|
+
true
|
|
171
|
+
end
|
|
172
|
+
|
|
109
173
|
def verify!(manager:)
|
|
110
174
|
slot = @scope.fetch("slot_id")
|
|
111
175
|
unless slot.is_a?(String) && SystemdScopeManager::UNIT.match?(slot)
|
|
112
176
|
raise RuntimeUnavailableError, "installed slot identity is invalid"
|
|
113
177
|
end
|
|
114
|
-
|
|
115
|
-
|
|
178
|
+
selected_service = @context || @codex_service
|
|
179
|
+
path = selected_service ? selected_service.fetch("unit_manifest").fetch("path") : "/etc/ace/execution-slots/#{slot}/unit-manifest.json"
|
|
180
|
+
bytes = @files.read(path, limit: 65_536)
|
|
181
|
+
unless Digest::SHA256.hexdigest(bytes) == @scope.fetch("unit_manifest_sha256") &&
|
|
182
|
+
(!selected_service || bytes.bytesize == selected_service.fetch("unit_manifest").fetch("bytes"))
|
|
116
183
|
raise RuntimeUnavailableError, "unit manifest bytes differ from deployment"
|
|
117
184
|
end
|
|
118
185
|
manifest = JSON.parse(bytes)
|
|
@@ -121,24 +188,154 @@ module Ace
|
|
|
121
188
|
raise RuntimeUnavailableError, "unit manifest schema/slot differs"
|
|
122
189
|
end
|
|
123
190
|
artifacts = verify_artifacts!(manifest.fetch("artifacts"))
|
|
124
|
-
profile = manager.inspect_profile
|
|
191
|
+
profile = @context ? manager.inspect_inbox_context_profile : manager.inspect_profile
|
|
125
192
|
verify_profile!(profile, manifest.fetch("properties"), artifacts)
|
|
126
193
|
manifest
|
|
127
194
|
rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError, NoMethodError
|
|
128
195
|
raise RuntimeUnavailableError, "installed execution units cannot be verified"
|
|
129
196
|
end
|
|
130
197
|
|
|
198
|
+
# Source validity only. The caller owns selected staged bytes; this
|
|
199
|
+
# neither observes a live authority nor grants installed readiness.
|
|
200
|
+
def validate_staged_boundary_topology!(service:, artifacts:, authority:)
|
|
201
|
+
validate_boundary_topology!(service, artifacts, authority: authority)
|
|
202
|
+
true
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def inbox_context_scope
|
|
206
|
+
raise RuntimeUnavailableError, "installation is not a context service" unless @context
|
|
207
|
+
@scope
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def inbox_context_configuration_reference
|
|
211
|
+
raise RuntimeUnavailableError, "installation is not a context service" unless @context
|
|
212
|
+
@context_configuration
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def inbox_context_service
|
|
216
|
+
raise RuntimeUnavailableError, "installation is not a context service" unless @context
|
|
217
|
+
@context
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Called from the held context configuration before service admission.
|
|
221
|
+
# Native startup bytes and IPC paths must be actual members of this
|
|
222
|
+
# SAME complete installed profile, not merely readable host selections.
|
|
223
|
+
def verify_inbox_native_projection!(references:, resources:, manager:)
|
|
224
|
+
raise RuntimeUnavailableError, "installation is not a context service" unless @context
|
|
225
|
+
manifest = verify!(manager: manager)
|
|
226
|
+
artifacts = manifest.fetch("artifacts")
|
|
227
|
+
profile = manager.inspect_inbox_context_profile.fetch("service")
|
|
228
|
+
unless references.is_a?(Array) && references.size.between?(3, 512) &&
|
|
229
|
+
resources.is_a?(Array) && resources.size <= 64
|
|
230
|
+
raise RuntimeUnavailableError, "context native projection exceeds bounds"
|
|
231
|
+
end
|
|
232
|
+
references.each do |ref|
|
|
233
|
+
unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] &&
|
|
234
|
+
ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 268_435_456) &&
|
|
235
|
+
artifacts.any? { |artifact| artifact.values_at("host_path", "view_path", "sha256", "role") ==
|
|
236
|
+
[ref.fetch("path"), ref.fetch("path"), ref.fetch("sha256"), "runtime_dependency"] }
|
|
237
|
+
raise RuntimeUnavailableError, "context native startup reference is not exactly projected"
|
|
238
|
+
end
|
|
239
|
+
bytes = @files.read(ref.fetch("path"), limit: ref.fetch("bytes"))
|
|
240
|
+
unless bytes.bytesize == ref.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == ref.fetch("sha256")
|
|
241
|
+
raise RuntimeUnavailableError, "context native startup reference differs"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
boundary = JSON.parse(@files.read(@context.fetch("boundary_manifest").fetch("path"), limit: 65_536))
|
|
245
|
+
resources.each do |resource|
|
|
246
|
+
unless resource.is_a?(Hash) && resource.keys.sort == %w[access gid kind mode path uid] &&
|
|
247
|
+
%w[read write].include?(resource["access"]) && path?(resource["path"])
|
|
248
|
+
raise RuntimeUnavailableError, "context native resource fields differ"
|
|
249
|
+
end
|
|
250
|
+
path = resource.fetch("path")
|
|
251
|
+
readonly = resource.fetch("access") == "read"
|
|
252
|
+
mounts = profile.fetch(readonly ? "BindReadOnlyPaths" : "BindPaths")
|
|
253
|
+
unless mounts.include?([path, path, false, 0]) &&
|
|
254
|
+
boundary.fetch("resources").any? { |entry| entry.values_at("host_path", "view_path", "stage", "worker_visible", "read_only") ==
|
|
255
|
+
[path, path, "parent", true, readonly] } &&
|
|
256
|
+
(!readonly || profile.fetch("BindPaths").none? { |mount| overlaps?(path, mount[1]) } &&
|
|
257
|
+
profile.fetch("ReadWritePaths").none? { |root| overlaps?(path, root) })
|
|
258
|
+
raise RuntimeUnavailableError, "context native resource is not exactly projected"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
verify!(manager: manager)
|
|
262
|
+
true
|
|
263
|
+
rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError, NoMethodError
|
|
264
|
+
raise RuntimeUnavailableError, "context native projection is unavailable"
|
|
265
|
+
end
|
|
266
|
+
|
|
131
267
|
private
|
|
132
268
|
|
|
269
|
+
def initialize_context(service, configuration, load_paths, authority, credentials, files)
|
|
270
|
+
validate_context_service!(service)
|
|
271
|
+
refs = service.slice("unit_manifest", "boundary_manifest", "entry", "interpreter", "bootstrap_manifest").merge("configuration" => configuration)
|
|
272
|
+
unless refs.values.all? { |ref| ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && path?(ref["path"]) &&
|
|
273
|
+
ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 268_435_456) &&
|
|
274
|
+
ref["sha256"].is_a?(String) && ref["sha256"].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
275
|
+
load_paths.all? { |path| path?(path) }
|
|
276
|
+
raise RuntimeUnavailableError, "context installation artifacts differ"
|
|
277
|
+
end
|
|
278
|
+
@scope = immutable_selection(service.fetch("execution_scope"))
|
|
279
|
+
@context = immutable_selection(service)
|
|
280
|
+
@context_configuration, @context_authority = immutable_selection(configuration), immutable_selection(authority)
|
|
281
|
+
@context_load_paths, @context_groups = immutable_selection(load_paths), immutable_selection(credentials.fetch("groups"))
|
|
282
|
+
@worker_uid, @worker_gid, @files = credentials.fetch("uid"), credentials.fetch("gid"), files
|
|
283
|
+
rescue KeyError, TypeError
|
|
284
|
+
raise RuntimeUnavailableError, "context installation selection is incomplete"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def validate_context_service!(service)
|
|
288
|
+
unless service.is_a?(Hash) && service.keys.sort == %w[bootstrap_manifest boundary_manifest entry execution_scope interpreter unit_manifest] &&
|
|
289
|
+
service.reject { |key, _| key == "execution_scope" }.values.all? { |ref|
|
|
290
|
+
ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && path?(ref["path"]) &&
|
|
291
|
+
ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 268_435_456) &&
|
|
292
|
+
ref["sha256"].is_a?(String) && ref["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
293
|
+
}
|
|
294
|
+
raise RuntimeUnavailableError, "context service descriptor differs"
|
|
295
|
+
end
|
|
296
|
+
scope = service.fetch("execution_scope")
|
|
297
|
+
unless scope.is_a?(Hash) && scope.keys.sort == %w[backend boundary_manifest_sha256 network_namespace_path root_directory runtime_directory service_unit slice_unit slot_id unit_manifest_sha256] &&
|
|
298
|
+
scope["backend"] == "linux_systemd_cgroup_v2" && scope["slot_id"].is_a?(String) && SystemdScopeManager::UNIT.match?(scope["slot_id"]) &&
|
|
299
|
+
%w[root_directory runtime_directory network_namespace_path].all? { |key| path?(scope[key]) } &&
|
|
300
|
+
scope.fetch("runtime_directory").start_with?("/run/") && !overlaps?(scope.fetch("root_directory"), scope.fetch("runtime_directory")) &&
|
|
301
|
+
{"slice_unit" => ".slice", "service_unit" => ".service"}.all? { |key, suffix|
|
|
302
|
+
scope[key].is_a?(String) && SystemdScopeManager::UNIT.match?(scope[key]) && scope[key].end_with?(suffix) && !scope[key].downcase.include?("overseer")
|
|
303
|
+
} &&
|
|
304
|
+
scope["unit_manifest_sha256"] == service.fetch("unit_manifest").fetch("sha256") &&
|
|
305
|
+
scope["boundary_manifest_sha256"] == service.fetch("boundary_manifest").fetch("sha256") &&
|
|
306
|
+
service.fetch("unit_manifest").fetch("bytes") <= 65_536 && service.fetch("boundary_manifest").fetch("bytes") <= 65_536
|
|
307
|
+
raise RuntimeUnavailableError, "context service scope differs"
|
|
308
|
+
end
|
|
309
|
+
rescue KeyError, TypeError, NoMethodError
|
|
310
|
+
raise RuntimeUnavailableError, "context service descriptor is incomplete"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def immutable_selection(value)
|
|
314
|
+
case value
|
|
315
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable_selection(item)] }.freeze
|
|
316
|
+
when Array then value.map { |item| immutable_selection(item) }.freeze
|
|
317
|
+
when String then value.dup.freeze
|
|
318
|
+
else value.freeze
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
133
322
|
def verify_artifacts!(artifacts)
|
|
134
|
-
|
|
323
|
+
minimum = @context ? CONTEXT_REQUIRED_ROLES.size : @codex_service ? CODEX_REQUIRED_ROLES.size : 9
|
|
324
|
+
unless artifacts.is_a?(Array) && artifacts.size.between?(minimum, 512) &&
|
|
135
325
|
artifacts.all? { |a| a.is_a?(Hash) && a.keys.sort == %w[host_path role sha256 view_path] } &&
|
|
136
326
|
artifacts.map { |a| a["host_path"] }.uniq.size == artifacts.size
|
|
137
327
|
raise RuntimeUnavailableError, "installed unit artifact declarations differ"
|
|
138
328
|
end
|
|
139
329
|
roles = artifacts.group_by { |a| a.fetch("role") }
|
|
140
|
-
|
|
141
|
-
|
|
330
|
+
required, allowed = if @context
|
|
331
|
+
[CONTEXT_REQUIRED_ROLES, CONTEXT_ROLES]
|
|
332
|
+
elsif @codex_service
|
|
333
|
+
[CODEX_REQUIRED_ROLES, CODEX_ROLES]
|
|
334
|
+
else
|
|
335
|
+
[REQUIRED_ROLES, ROLES]
|
|
336
|
+
end
|
|
337
|
+
unless required.all? { |role| roles.key?(role) } && (roles.keys - allowed).empty? &&
|
|
338
|
+
required.all? { |role| roles[role].size == 1 }
|
|
142
339
|
raise RuntimeUnavailableError, "installed unit artifacts are incomplete or ambiguous"
|
|
143
340
|
end
|
|
144
341
|
artifacts.each do |artifact|
|
|
@@ -147,12 +344,44 @@ module Ace
|
|
|
147
344
|
raise RuntimeUnavailableError, "installed unit artifact identity changed"
|
|
148
345
|
end
|
|
149
346
|
end
|
|
347
|
+
return verify_context_artifacts!(roles) if @context
|
|
348
|
+
return verify_codex_artifacts!(roles) if @codex_service
|
|
150
349
|
expected = {"native_executable" => @native.fetch("executable"), "bootstrap" => @bootstrap,
|
|
151
|
-
"worker_executable" => @
|
|
350
|
+
"worker_executable" => @worker_entry.fetch("wrapper").fetch("path"),
|
|
351
|
+
"readiness_configuration" => "/etc/ace/execution-slots/#{@scope.fetch('slot_id')}/readiness.json",
|
|
352
|
+
"boundary_manifest" => "/etc/ace/execution-slots/#{@scope.fetch('slot_id')}/boundary-manifest.json"}
|
|
152
353
|
unless expected.all? { |role, path| roles.fetch(role).first.fetch("view_path") == path } &&
|
|
153
|
-
roles.fetch("native_executable").first.fetch("sha256") == @native.fetch("executable_sha256")
|
|
354
|
+
roles.fetch("native_executable").first.fetch("sha256") == @native.fetch("executable_sha256") &&
|
|
355
|
+
roles.fetch("boundary_manifest").first.fetch("sha256") == @scope.fetch("boundary_manifest_sha256")
|
|
154
356
|
raise RuntimeUnavailableError, "installed executable declarations differ from deployment"
|
|
155
357
|
end
|
|
358
|
+
{"wrapper" => "worker_executable", "interpreter" => "runtime_dependency"}.each do |key, role|
|
|
359
|
+
reference = @worker_entry.fetch(key)
|
|
360
|
+
matches = roles.fetch(role).select { |artifact| artifact.fetch("view_path") == reference.fetch("path") }
|
|
361
|
+
unless matches.size == 1 && matches.first.fetch("sha256") == reference.fetch("sha256")
|
|
362
|
+
raise RuntimeUnavailableError, "installed worker entry role differs"
|
|
363
|
+
end
|
|
364
|
+
bytes = @files.read(matches.first.fetch("host_path"), limit: reference.fetch("bytes"))
|
|
365
|
+
unless bytes.bytesize == reference.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == reference.fetch("sha256")
|
|
366
|
+
raise RuntimeUnavailableError, "installed worker entry bytes differ"
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
roles
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def verify_context_artifacts!(roles)
|
|
373
|
+
refs = {"context_executable" => @context.fetch("entry"), "interpreter" => @context.fetch("interpreter"),
|
|
374
|
+
"bootstrap" => @context.fetch("bootstrap_manifest"), "boundary_manifest" => @context.fetch("boundary_manifest"),
|
|
375
|
+
"context_configuration" => @context_configuration}
|
|
376
|
+
refs.each do |role, ref|
|
|
377
|
+
artifact = roles.fetch(role).first
|
|
378
|
+
bytes = @files.read(artifact.fetch("host_path"), limit: ref.fetch("bytes"))
|
|
379
|
+
unless artifact.fetch("host_path") == ref.fetch("path") && artifact.fetch("sha256") == ref.fetch("sha256") &&
|
|
380
|
+
(!%w[bootstrap boundary_manifest context_configuration].include?(role) || artifact.fetch("view_path") == ref.fetch("path")) &&
|
|
381
|
+
bytes.bytesize == ref.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == ref.fetch("sha256")
|
|
382
|
+
raise RuntimeUnavailableError, "context host artifact differs from selected immutable reference"
|
|
383
|
+
end
|
|
384
|
+
end
|
|
156
385
|
roles
|
|
157
386
|
end
|
|
158
387
|
|
|
@@ -160,8 +389,13 @@ module Ace
|
|
|
160
389
|
unless expected.is_a?(Hash) && expected.keys.sort == %w[service slice]
|
|
161
390
|
raise RuntimeUnavailableError, "unit profile declarations differ"
|
|
162
391
|
end
|
|
392
|
+
environment = profile.fetch("manager_environment")
|
|
393
|
+
unless environment.is_a?(Array) && environment.all? { |entry| entry.is_a?(String) &&
|
|
394
|
+
MANAGER_ENVIRONMENT.include?(entry.split("=", 2).first) }
|
|
395
|
+
raise RuntimeUnavailableError, "effective manager environment changes the fixed startup surface"
|
|
396
|
+
end
|
|
163
397
|
{"slice" => SystemdScopeManager::UNIT_GRAPH_SIGNATURES.keys,
|
|
164
|
-
"service" => (SystemdScopeManager::UNIT_GRAPH_SIGNATURES.keys + SystemdScopeManager::SERVICE_EXEC_SIGNATURES.keys).uniq}.each do |kind, keys|
|
|
398
|
+
"service" => (SystemdScopeManager::UNIT_GRAPH_SIGNATURES.keys + SystemdScopeManager::SERVICE_EXEC_SIGNATURES.keys + (@context ? ["TimeoutStopUSec"] : [])).uniq}.each do |kind, keys|
|
|
165
399
|
wanted, actual = expected.fetch(kind), profile.fetch(kind)
|
|
166
400
|
projected = actual.dup
|
|
167
401
|
%w[ExecStartEx ExecStartPostEx].each do |key|
|
|
@@ -193,15 +427,35 @@ module Ace
|
|
|
193
427
|
credential_id(service.fetch("User"), :uid) == @worker_uid &&
|
|
194
428
|
credential_id(service.fetch("Group"), :gid) == @worker_gid &&
|
|
195
429
|
SERVICE_REQUIRED.all? { |key, value| service[key] == value } &&
|
|
196
|
-
service["SupplementaryGroups"] == [] &&
|
|
430
|
+
service["SupplementaryGroups"] == (@context ? @context_groups.map(&:to_s) : @codex_service ? @codex_groups.map(&:to_s) : []) &&
|
|
197
431
|
service["RestrictAddressFamilies"] == [true, %w[AF_INET AF_INET6 AF_UNIX]] &&
|
|
198
432
|
EMPTY_EXEC.all? { |key| service[key] == [] }
|
|
199
433
|
raise RuntimeUnavailableError, "execution unit lacks the required retained isolation profile"
|
|
200
434
|
end
|
|
435
|
+
if @context
|
|
436
|
+
verify_context_protocol!(service, artifacts)
|
|
437
|
+
verify_artifact_projection!(service, artifacts)
|
|
438
|
+
verify_boundary_topology!(service, artifacts, authority: @context_authority)
|
|
439
|
+
return
|
|
440
|
+
end
|
|
441
|
+
if @codex_service
|
|
442
|
+
verify_codex_protocol!(service, artifacts)
|
|
443
|
+
verify_artifact_projection!(service, artifacts)
|
|
444
|
+
verify_boundary_topology!(service, artifacts, authority: @codex_authority)
|
|
445
|
+
return
|
|
446
|
+
end
|
|
201
447
|
verify_command!(service.fetch("ExecStartEx"), artifacts.fetch("native_executable").first.fetch("view_path"))
|
|
202
|
-
|
|
448
|
+
config_artifact = artifacts.fetch("readiness_configuration").first
|
|
449
|
+
config = ReadinessConfiguration.decode(@files.read(config_artifact.fetch("host_path"), limit: 65_536),
|
|
450
|
+
slot: @scope.fetch("slot_id"))
|
|
451
|
+
interpreter = config.data.dig("runtime", "interpreter_path")
|
|
452
|
+
unless artifacts.fetch("runtime_dependency", []).any? { |artifact| artifact.fetch("view_path") == interpreter }
|
|
453
|
+
raise RuntimeUnavailableError, "readiness interpreter is not installed"
|
|
454
|
+
end
|
|
455
|
+
verify_command!(service.fetch("ExecStartPostEx"), interpreter)
|
|
203
456
|
unless service.fetch("ExecStartEx").first[1] == [@native.fetch("executable"), "server"] &&
|
|
204
|
-
service.fetch("ExecStartPostEx").first[1] == [
|
|
457
|
+
service.fetch("ExecStartPostEx").first[1] == [interpreter, "--disable=gems,rubyopt",
|
|
458
|
+
artifacts.fetch("readiness_executable").first.fetch("view_path"), @scope.fetch("slot_id")]
|
|
205
459
|
raise RuntimeUnavailableError, "unit commands differ from the fixed native/readiness protocol"
|
|
206
460
|
end
|
|
207
461
|
environment = service.fetch("Environment")
|
|
@@ -212,6 +466,19 @@ module Ace
|
|
|
212
466
|
raise RuntimeUnavailableError, "native configuration is not the exact immutable input"
|
|
213
467
|
end
|
|
214
468
|
verify_artifact_projection!(service, artifacts)
|
|
469
|
+
verify_boundary_topology!(service, artifacts, authority: config.data.fetch("authority"))
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def verify_context_protocol!(service, artifacts)
|
|
473
|
+
interpreter = artifacts.fetch("interpreter").first.fetch("view_path")
|
|
474
|
+
entry = artifacts.fetch("context_executable").first.fetch("view_path")
|
|
475
|
+
verify_command!(service.fetch("ExecStartEx"), interpreter)
|
|
476
|
+
arguments = [interpreter, "--disable=gems,rubyopt", *@context_load_paths.flat_map { |path| ["-I", path] }, entry]
|
|
477
|
+
unless service.fetch("ExecStartEx").first[1] == arguments && service.fetch("ExecStartPostEx") == [] &&
|
|
478
|
+
service.fetch("Environment") == [] && service.fetch("TimeoutStopUSec") == 35_000_000 &&
|
|
479
|
+
@context_load_paths.all? { |path| artifacts.fetch("runtime_dependency", []).any? { |artifact| covers?(path, artifact.fetch("view_path")) } }
|
|
480
|
+
raise RuntimeUnavailableError, "context commands differ from the fixed service protocol"
|
|
481
|
+
end
|
|
215
482
|
end
|
|
216
483
|
|
|
217
484
|
def verify_graph!(slice, service, ancestors, prerequisites)
|
|
@@ -243,7 +510,7 @@ module Ace
|
|
|
243
510
|
service["WantsMountsFor"] == [@scope.fetch("root_directory")] &&
|
|
244
511
|
service.fetch("RuntimeDirectory") == [@scope.fetch("runtime_directory").delete_prefix("/run/")] &&
|
|
245
512
|
service["RequiresMountsFor"].sort == service.fetch("RuntimeDirectory").map { |path| "/run/#{path}" }.sort &&
|
|
246
|
-
service["WorkingDirectory"] == "" && service["DefaultDependencies"] == false &&
|
|
513
|
+
service["WorkingDirectory"] == (@codex_service ? @codex_intent.fetch("cwd") : "") && service["DefaultDependencies"] == false &&
|
|
247
514
|
%w[RequiredBy RequisiteOf WantedBy BoundBy UpheldBy ConsistsOf].all? { |key| service[key] == [] } &&
|
|
248
515
|
%w[disabled static].include?(service["UnitFileState"]) &&
|
|
249
516
|
EMPTY_ACTIVATION.all? { |key| service[key] == [] }
|
|
@@ -280,6 +547,93 @@ module Ace
|
|
|
280
547
|
end
|
|
281
548
|
end
|
|
282
549
|
|
|
550
|
+
def verify_boundary_topology!(service, artifacts, authority:)
|
|
551
|
+
validate_staged_boundary_topology!(service: service, artifacts: artifacts, authority: authority)
|
|
552
|
+
@files.authority_socket!(authority)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def validate_boundary_topology!(service, artifacts, authority:)
|
|
556
|
+
artifact = artifacts.fetch("boundary_manifest").first
|
|
557
|
+
bytes = @files.read(artifact.fetch("host_path"), limit: 65_536).dup.force_encoding(Encoding::UTF_8)
|
|
558
|
+
raise RuntimeUnavailableError, "boundary content is not UTF-8" unless bytes.valid_encoding?
|
|
559
|
+
boundary = JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
|
|
560
|
+
unless boundary.is_a?(Hash) && boundary.keys.sort == %w[network_installation resources schema slot_id] &&
|
|
561
|
+
boundary["schema"] == "ace.execution-boundary-manifest/v1" && boundary["slot_id"] == @scope.fetch("slot_id") &&
|
|
562
|
+
boundary["resources"].is_a?(Array) && boundary["resources"].size.between?(1, 64)
|
|
563
|
+
raise RuntimeUnavailableError, "boundary lifecycle topology differs"
|
|
564
|
+
end
|
|
565
|
+
entries = boundary.fetch("resources")
|
|
566
|
+
ExecutionNetworkSelection.validate_static!(boundary.fetch("network_installation"), slot_id: @scope.fetch("slot_id"))
|
|
567
|
+
entries.each do |entry|
|
|
568
|
+
unless entry.is_a?(Hash) && entry.keys.sort == %w[host_path read_only stage view_path worker_visible] &&
|
|
569
|
+
path?(entry["host_path"]) && path?(entry["view_path"]) && %w[parent native].include?(entry["stage"]) &&
|
|
570
|
+
[true, false].include?(entry["worker_visible"]) && [true, false].include?(entry["read_only"]) &&
|
|
571
|
+
(entry["worker_visible"] || entry["stage"] == "parent" && entry["read_only"])
|
|
572
|
+
raise RuntimeUnavailableError, "boundary resource declaration differs"
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
unless entries.map { |entry| entry.values_at("host_path", "view_path") }.uniq.size == entries.size
|
|
576
|
+
raise RuntimeUnavailableError, "boundary resource projections repeat"
|
|
577
|
+
end
|
|
578
|
+
projections = service.fetch("BindPaths").map { |mount| [mount[0], mount[1], false] } +
|
|
579
|
+
service.fetch("BindReadOnlyPaths").map { |mount| [mount[0], mount[1], true] }
|
|
580
|
+
socket = authority.fetch("socket_path")
|
|
581
|
+
socket_projection = [socket, socket, true]
|
|
582
|
+
socket_mounts = projections.select { |host, view, _| overlaps?(host, socket) || overlaps?(view, socket) }
|
|
583
|
+
unless socket_mounts == [socket_projection]
|
|
584
|
+
raise RuntimeUnavailableError, "authority socket has no unique exact readonly projection"
|
|
585
|
+
end
|
|
586
|
+
devpts = "/run/ace/execution-slots/#{@scope.fetch('slot_id')}/devpts"
|
|
587
|
+
device_projections = @context ? [] : [[devpts, "/dev/pts", false], [devpts + "/ptmx", "/dev/pts/ptmx", true]]
|
|
588
|
+
unless projections.select { |_host, view, _| overlaps?(view, "/dev/pts") } == device_projections
|
|
589
|
+
raise RuntimeUnavailableError, "selected devpts has no exact root and ptmx projection"
|
|
590
|
+
end
|
|
591
|
+
api_roots = %w[/dev /proc /sys]
|
|
592
|
+
if (projections - device_projections).any? { |_host, view, _| api_roots.any? { |root| overlaps?(root, view) } }
|
|
593
|
+
raise RuntimeUnavailableError, "API namespace has an unsupported storage alias"
|
|
594
|
+
end
|
|
595
|
+
readonly_paths = service.fetch("ReadOnlyPaths")
|
|
596
|
+
unless readonly_paths.all? { |path| path?(path) } &&
|
|
597
|
+
%w[/dev /dev/shm].all? { |view| readonly_paths.any? { |path| covers?(path, view) } } &&
|
|
598
|
+
!service.fetch("ReadWritePaths").any? { |path| !path?(path) || overlaps?(path, "/dev/shm") }
|
|
599
|
+
raise RuntimeUnavailableError, "file-backed IPC is not positively read-only"
|
|
600
|
+
end
|
|
601
|
+
runtime = @scope.fetch("runtime_directory")
|
|
602
|
+
projections << [runtime, runtime, false]
|
|
603
|
+
service.fetch("ReadWritePaths").each do |view|
|
|
604
|
+
unless path?(view) && api_roots.none? { |root| overlaps?(root, view) }
|
|
605
|
+
raise RuntimeUnavailableError, "writable path directive is not exact"
|
|
606
|
+
end
|
|
607
|
+
projection = projections.select { |mount| covers?(mount[1], view) }.max_by { |mount| mount[1].length }
|
|
608
|
+
host = projection ? File.join(projection[0], view.delete_prefix(projection[1]).delete_prefix("/")) :
|
|
609
|
+
@scope.fetch("root_directory") + view
|
|
610
|
+
projections << [host, view, false]
|
|
611
|
+
end
|
|
612
|
+
unless projections.select { |host, view, _| overlaps?(host, socket) || overlaps?(view, socket) } == [socket_projection]
|
|
613
|
+
raise RuntimeUnavailableError, "authority socket is shadowed by an effective writable or parent projection"
|
|
614
|
+
end
|
|
615
|
+
projections.each do |host, view, readonly|
|
|
616
|
+
next if [host, view, readonly] == socket_projection
|
|
617
|
+
next if device_projections.include?([host, view, readonly])
|
|
618
|
+
next if readonly && artifacts.values.flatten.any? { |entry| entry["host_path"] == host && entry["view_path"] == view }
|
|
619
|
+
unless entries.any? { |entry| entry.values_at("host_path", "view_path", "worker_visible", "read_only") == [host, view, true, readonly] }
|
|
620
|
+
raise RuntimeUnavailableError, "effective projection is omitted from boundary inventory"
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
entries.each do |entry|
|
|
624
|
+
view = entry.fetch("view_path")
|
|
625
|
+
accessible = projections.any? { |host, target, _readonly| covers?(target, view) &&
|
|
626
|
+
File.expand_path(File.join(host, view.delete_prefix(target).delete_prefix("/"))) == entry.fetch("host_path") } ||
|
|
627
|
+
entry.fetch("host_path") == @scope.fetch("root_directory") + view
|
|
628
|
+
if entry.fetch("worker_visible") != accessible
|
|
629
|
+
raise RuntimeUnavailableError, "boundary visibility is not proven by installed topology"
|
|
630
|
+
end
|
|
631
|
+
if entry.fetch("stage") == "native" && !covers?(runtime, entry.fetch("host_path"))
|
|
632
|
+
raise RuntimeUnavailableError, "service-created resource has no fixed creation directive"
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
283
637
|
def covers?(root, path) = root == path || root == "/" || path.start_with?(root + "/")
|
|
284
638
|
def overlaps?(left, right) = covers?(left, right) || covers?(right, left)
|
|
285
639
|
|