ace-assign 0.63.0 → 0.64.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/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +51 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +266 -0
- data/docs/usage.md +91 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
- data/lib/ace/assign/authority/client.rb +107 -0
- data/lib/ace/assign/authority/deployment.rb +465 -0
- data/lib/ace/assign/authority/endcap.rb +342 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
- data/lib/ace/assign/authority/endcap_results.rb +380 -0
- data/lib/ace/assign/authority/endcap_services.rb +395 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
- data/lib/ace/assign/authority/launch_driver.rb +114 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
- data/lib/ace/assign/authority/posix_acl.rb +56 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +54 -0
- data/lib/ace/assign/authority/server.rb +230 -0
- data/lib/ace/assign/authority/service_evidence.rb +55 -0
- data/lib/ace/assign/authority/transfer_codec.rb +155 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/create.rb +9 -1
- data/lib/ace/assign/cli/commands/delivery.rb +57 -0
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
- data/lib/ace/assign/cli/commands/resume.rb +26 -0
- data/lib/ace/assign/cli.rb +22 -1
- data/lib/ace/assign/models/evidence_event.rb +3 -1
- data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
- data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +4 -1
- data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +13 -0
- metadata +119 -6
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/runtime/molecules/cgroup_observation"
|
|
4
|
+
require "ace/runtime/molecules/systemd_scope_manager"
|
|
5
|
+
require "ace/runtime/molecules/linux_mount_info"
|
|
6
|
+
require "ace/runtime/molecules/execution_unit_installation"
|
|
7
|
+
require_relative "../molecules/execution_scope_lineage"
|
|
8
|
+
require_relative "posix_acl"
|
|
9
|
+
require "digest"
|
|
10
|
+
require "json"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Assign
|
|
14
|
+
module Authority
|
|
15
|
+
# Joins already canonical scope facts to current native owner objects.
|
|
16
|
+
# This does not manufacture readiness, a network-policy assertion, or a
|
|
17
|
+
# canonical no-writer event from a population snapshot.
|
|
18
|
+
class ExecutionScopeObservation
|
|
19
|
+
LOCAL_FILESYSTEMS = %w[ext4 xfs btrfs tmpfs].freeze
|
|
20
|
+
BOUNDARY_SCHEMA = "ace.execution-boundary-manifest/v1"
|
|
21
|
+
|
|
22
|
+
class Files
|
|
23
|
+
def boot_id
|
|
24
|
+
value = File.read("/proc/sys/kernel/random/boot_id", 128).strip
|
|
25
|
+
unless value.match?(Molecules::ExecutionScopeLineage::BOOT)
|
|
26
|
+
raise Ace::Runtime::RuntimeUnavailableError, "kernel boot identity is malformed"
|
|
27
|
+
end
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def resource_identity(path)
|
|
32
|
+
unless path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path && File.realpath(path) == path
|
|
33
|
+
raise Ace::Runtime::RuntimeUnavailableError, "scope resource path is substituted"
|
|
34
|
+
end
|
|
35
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |directory|
|
|
36
|
+
stat = directory.stat
|
|
37
|
+
raise Ace::Runtime::RuntimeUnavailableError, "scope resource is not a directory" unless stat.directory?
|
|
38
|
+
info = File.read("/proc/self/fdinfo/#{directory.fileno}", 16_385)
|
|
39
|
+
ids = info.lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
|
|
40
|
+
raise Ace::Runtime::RuntimeUnavailableError, "resource mount identity is ambiguous" unless ids.size == 1
|
|
41
|
+
bytes = File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1)
|
|
42
|
+
mount = Ace::Runtime::Molecules::LinuxMountInfo.new(bytes).by_id(Integer(ids.first, 10))
|
|
43
|
+
current = File.stat(path)
|
|
44
|
+
unless current.dev == stat.dev && current.ino == stat.ino && File.realpath(path) == path
|
|
45
|
+
raise Ace::Runtime::RuntimeUnavailableError, "scope resource changed while observing it"
|
|
46
|
+
end
|
|
47
|
+
{"device" => stat.dev, "inode" => stat.ino, "uid" => stat.uid, "gid" => stat.gid,
|
|
48
|
+
"filesystem_type" => mount.fetch("filesystem_type"), "mount_id" => mount.fetch("mount_id")}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def namespace_identity
|
|
53
|
+
stat = File.stat("/proc/self/ns/mnt")
|
|
54
|
+
{"device" => stat.dev, "inode" => stat.ino}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pin_network_namespace(path)
|
|
58
|
+
unless RUBY_PLATFORM.include?("linux") && path.is_a?(String) && path.start_with?("/") &&
|
|
59
|
+
path.bytesize.between?(1, 4096) && !path.include?("\0") && File.expand_path(path) == path && File.realpath(path) == path
|
|
60
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace path is substituted"
|
|
61
|
+
end
|
|
62
|
+
parts = path.split("/").reject(&:empty?)
|
|
63
|
+
paths = ["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }
|
|
64
|
+
paths.each do |ancestor|
|
|
65
|
+
stat = File.lstat(ancestor)
|
|
66
|
+
unless stat.uid.zero? && !stat.symlink? && (stat.mode & 0o022).zero? &&
|
|
67
|
+
(ancestor == path || stat.directory?)
|
|
68
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace ancestry is writable"
|
|
69
|
+
end
|
|
70
|
+
# The leaf is authenticated as nsfs below. nsfs has no access
|
|
71
|
+
# ACL/xattr owner and exposes only namespace ioctls, not writes.
|
|
72
|
+
next if ancestor == path
|
|
73
|
+
acl = PosixAcl.new.entries(ancestor)
|
|
74
|
+
if acl && acl.any? { |tag, permissions, uid| (permissions & 2) != 0 && !(tag == 1 || tag == 2 && uid.zero?) }
|
|
75
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace ACL permits untrusted mutation"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
handle = File.open(path, File::RDONLY | File::NOFOLLOW)
|
|
79
|
+
stat = handle.stat
|
|
80
|
+
info = File.read("/proc/self/fdinfo/#{handle.fileno}", 16_385)
|
|
81
|
+
ids = info.lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
|
|
82
|
+
table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
|
|
83
|
+
unless ids.size == 1 && table.by_id(Integer(ids.first, 10)).fetch("filesystem_type") == "nsfs" &&
|
|
84
|
+
handle.ioctl(0xb703) == 0x40000000 && [File.stat(path).dev, File.stat(path).ino] == [stat.dev, stat.ino]
|
|
85
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed object is not the exact nsfs network namespace"
|
|
86
|
+
end
|
|
87
|
+
{handle: handle, identity: {"device" => stat.dev, "inode" => stat.ino}}
|
|
88
|
+
rescue StandardError
|
|
89
|
+
handle&.close
|
|
90
|
+
raise
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def resource_boundary_policy(path)
|
|
94
|
+
stat = File.lstat(path)
|
|
95
|
+
unless stat.directory? && File.realpath(path) == path
|
|
96
|
+
raise Ace::Runtime::RuntimeUnavailableError, "protected root policy object is substituted"
|
|
97
|
+
end
|
|
98
|
+
{"device" => stat.dev, "inode" => stat.ino, "uid" => stat.uid, "gid" => stat.gid,
|
|
99
|
+
"mode" => stat.mode & 0o7777, "acl" => PosixAcl.new.entries(path)}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def resource_aliases(path, identity:)
|
|
103
|
+
table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
|
|
104
|
+
original = table.by_id(identity.fetch("mount_id"))
|
|
105
|
+
physical = table.filesystem_path(original, path)
|
|
106
|
+
table.records.filter_map do |mount|
|
|
107
|
+
next unless mount.fetch("major_minor") == original.fetch("major_minor") && mount.fetch("options").include?("rw")
|
|
108
|
+
root = mount.fetch("root")
|
|
109
|
+
if physical == root || root == "/" || physical.start_with?(root + "/")
|
|
110
|
+
suffix = physical.delete_prefix(root).delete_prefix("/")
|
|
111
|
+
File.join(mount.fetch("mountpoint"), suffix)
|
|
112
|
+
elsif root.start_with?(physical + "/")
|
|
113
|
+
mount.fetch("mountpoint")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def worker_uid_quiescent!(uid)
|
|
119
|
+
table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
|
|
120
|
+
proc_mounts = table.records.select { |record| record.fetch("mountpoint") == "/proc" }
|
|
121
|
+
unless proc_mounts.size == 1 && proc_mounts.first.fetch("filesystem_type") == "proc" && proc_mounts.first.fetch("root") == "/" &&
|
|
122
|
+
(proc_mounts.first.fetch("options") + proc_mounts.first.fetch("super_options")).none? { |option| option.start_with?("hidepid=") && option != "hidepid=0" }
|
|
123
|
+
raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline requires a fully visible authority proc view"
|
|
124
|
+
end
|
|
125
|
+
pids = Dir.children("/proc").select { |name| name.match?(/\A[1-9][0-9]*\z/) }
|
|
126
|
+
raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline is oversized" if pids.size > 100_000
|
|
127
|
+
pids.each do |pid|
|
|
128
|
+
begin
|
|
129
|
+
bytes = File.open("/proc/#{pid}/status", File::RDONLY | File::NOFOLLOW) { |file| file.read(65_537) }
|
|
130
|
+
rescue Errno::ENOENT, Errno::ESRCH
|
|
131
|
+
next # A dead process cannot retain a writable userspace handle.
|
|
132
|
+
end
|
|
133
|
+
rows = bytes.lines.select { |line| line.start_with?("Uid:") }
|
|
134
|
+
match = rows.size == 1 && /\AUid:\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s*\z/.match(rows.first)
|
|
135
|
+
unless bytes.bytesize <= 65_536 && match
|
|
136
|
+
raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline has unreadable credential evidence"
|
|
137
|
+
end
|
|
138
|
+
if match.captures.map { |value| Integer(value, 10) }.include?(uid)
|
|
139
|
+
raise Ace::Runtime::RuntimeUnavailableError, "worker UID has a process outside the never-admitted generation"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
true
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def boundary_manifest(scope)
|
|
146
|
+
path = "/etc/ace/execution-slots/#{scope.fetch('slot_id')}/boundary-manifest.json"
|
|
147
|
+
bytes = Ace::Runtime::Molecules::ExecutionUnitInstallation::Files.new.read(path, limit: 65_536)
|
|
148
|
+
unless Digest::SHA256.hexdigest(bytes) == scope.fetch("boundary_manifest_sha256")
|
|
149
|
+
raise Ace::Runtime::RuntimeUnavailableError, "boundary inventory differs from fixed deployment"
|
|
150
|
+
end
|
|
151
|
+
JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
|
|
152
|
+
rescue JSON::ParserError, EncodingError
|
|
153
|
+
raise Ace::Runtime::RuntimeUnavailableError, "fixed boundary inventory is not strict UTF-8 JSON"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def initialize(mapping_id:, deployment:, kernel:, manager: nil, cgroups: Ace::Runtime::Molecules::CgroupObservation.new, files: Files.new)
|
|
158
|
+
@mapping_id, @deployment, @kernel, @cgroups, @files = mapping_id, deployment, kernel, cgroups, files
|
|
159
|
+
@map = deployment.mapping(mapping_id)
|
|
160
|
+
@scope = @map.fetch("execution_scope")
|
|
161
|
+
@manager = manager || Ace::Runtime::Molecules::SystemdScopeManager.new(
|
|
162
|
+
slice_unit: @scope.fetch("slice_unit"), service_unit: @scope.fetch("service_unit"))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def observe(lineage)
|
|
166
|
+
@deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
|
|
167
|
+
binding = lineage.binding
|
|
168
|
+
unless binding && binding.fetch("slot_id") == @scope.fetch("slot_id") && binding.fetch("boot_id") == @files.boot_id &&
|
|
169
|
+
binding.fetch("deployment_digest") == Digest::SHA256.hexdigest(JSON.generate(canonical(@map))) &&
|
|
170
|
+
binding.fetch("resource_mount_namespace_identity") == @files.namespace_identity
|
|
171
|
+
unavailable!("retained scope deployment or boot changed")
|
|
172
|
+
end
|
|
173
|
+
network = @files.pin_network_namespace(@scope.fetch("network_namespace_path"))
|
|
174
|
+
unless network.fetch(:identity) == binding.fetch("network_namespace_identity") &&
|
|
175
|
+
network_selection! == binding.fetch("network_installation_selection")
|
|
176
|
+
unavailable!("retained installed network namespace or selection changed")
|
|
177
|
+
end
|
|
178
|
+
before = @manager.inspect_activation
|
|
179
|
+
verify_parent!(binding, before.fetch("slice"))
|
|
180
|
+
pinned = @cgroups.pin(binding.fetch("cgroup_identity").fetch("path"), expected: binding.fetch("cgroup_identity"))
|
|
181
|
+
unless parent_resources! == binding.fetch("resource_identities")
|
|
182
|
+
unavailable!("retained parent inventory changed")
|
|
183
|
+
end
|
|
184
|
+
verify_resources!(binding.fetch("resource_identities"))
|
|
185
|
+
value = @cgroups.observe(pinned)
|
|
186
|
+
after = @manager.inspect_activation
|
|
187
|
+
unless before == after && @files.boot_id == binding.fetch("boot_id")
|
|
188
|
+
unavailable!("scope activation changed while observing it")
|
|
189
|
+
end
|
|
190
|
+
{"populated" => value.fetch("populated"), "activation" => after}
|
|
191
|
+
rescue SystemCallError, IOError, KeyError, TypeError, ArgumentError
|
|
192
|
+
unavailable!("retained scope observation is unavailable")
|
|
193
|
+
ensure
|
|
194
|
+
pinned&.fetch(:handle)&.close
|
|
195
|
+
network&.fetch(:handle)&.close
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Called only by the fresh reservation winner with slot exclusion held.
|
|
199
|
+
# It creates no service or payload. Original activation facts are taken
|
|
200
|
+
# after this exact successful StartUnit, never adopted on a later retry.
|
|
201
|
+
def activate_parent!(context)
|
|
202
|
+
@deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
|
|
203
|
+
inventory = parent_resources!
|
|
204
|
+
selection = network_selection!
|
|
205
|
+
network = @files.pin_network_namespace(@scope.fetch("network_namespace_path"))
|
|
206
|
+
@files.worker_uid_quiescent!(@map.fetch("worker_uid"))
|
|
207
|
+
boot = @files.boot_id
|
|
208
|
+
namespace = @files.namespace_identity
|
|
209
|
+
before = @manager.inspect_activation
|
|
210
|
+
service = before.fetch("service")
|
|
211
|
+
slice = before.fetch("slice")
|
|
212
|
+
unless %w[inactive failed].include?(slice.fetch("ActiveState")) && slice.fetch("Job") == [0, "/"] &&
|
|
213
|
+
%w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
|
|
214
|
+
service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?
|
|
215
|
+
unavailable!("fresh parent activation has retained or pending native ownership")
|
|
216
|
+
end
|
|
217
|
+
@manager.start_slice
|
|
218
|
+
activation = @manager.inspect_activation
|
|
219
|
+
parent = activation.fetch("slice")
|
|
220
|
+
unless parent.fetch("ActiveState") == "active" && parent.fetch("SubState") == "active" &&
|
|
221
|
+
Molecules::ExecutionScopeLineage::INVOCATION.match?(parent.fetch("InvocationID")) && parent.fetch("Job") == [0, "/"] &&
|
|
222
|
+
!parent.fetch("ControlGroup").empty? && activation.fetch("service") == service
|
|
223
|
+
unavailable!("fresh parent job did not produce its exact independent activation")
|
|
224
|
+
end
|
|
225
|
+
pinned = @cgroups.pin(Ace::Runtime::Molecules::CgroupObservation::ROOT + parent.fetch("ControlGroup"))
|
|
226
|
+
unless @cgroups.observe(pinned).fetch("populated").zero? && @files.boot_id == boot &&
|
|
227
|
+
@files.namespace_identity == namespace && @manager.inspect_activation == activation
|
|
228
|
+
unavailable!("fresh parent changed or acquired processes before binding")
|
|
229
|
+
end
|
|
230
|
+
verify_resources!(inventory)
|
|
231
|
+
context.merge("slot_id" => @scope.fetch("slot_id"), "deployment_digest" => Digest::SHA256.hexdigest(JSON.generate(canonical(@map))),
|
|
232
|
+
"boot_id" => boot, "slice_invocation_id" => parent.fetch("InvocationID"), "cgroup_identity" => pinned.fetch(:identity),
|
|
233
|
+
"resource_mount_namespace_identity" => namespace, "resource_identities" => inventory,
|
|
234
|
+
"network_installation_selection" => selection, "network_namespace_identity" => network.fetch(:identity))
|
|
235
|
+
rescue SystemCallError, IOError, KeyError, TypeError, ArgumentError, JSON::ParserError
|
|
236
|
+
unavailable!("fresh parent inventory or activation is unavailable")
|
|
237
|
+
ensure
|
|
238
|
+
pinned&.fetch(:handle)&.close
|
|
239
|
+
network&.fetch(:handle)&.close
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def verify_closed!(lineage)
|
|
243
|
+
value = observe(lineage)
|
|
244
|
+
service = value.fetch("activation").fetch("service")
|
|
245
|
+
unless lineage.sealed? && lineage.proof_event && value.fetch("populated").zero? &&
|
|
246
|
+
%w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("MainPID").zero? &&
|
|
247
|
+
service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
|
|
248
|
+
unavailable!("canonical closure has a live or pending native activation")
|
|
249
|
+
end
|
|
250
|
+
if lineage.native_event
|
|
251
|
+
native = lineage.native_event.fetch("payload")
|
|
252
|
+
invocation = service.fetch("InvocationID")
|
|
253
|
+
unless invocation.empty? || invocation == native.fetch("service_invocation_id")
|
|
254
|
+
unavailable!("closed native incarnation was replaced")
|
|
255
|
+
end
|
|
256
|
+
verify_resources!(native.fetch("resource_identities"), allow_runtime_absence: true, same_namespace: false)
|
|
257
|
+
end
|
|
258
|
+
true
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def native_admission_ready!(lineage)
|
|
262
|
+
value = observe(lineage)
|
|
263
|
+
service = value.fetch("activation").fetch("service")
|
|
264
|
+
unless value.fetch("populated").zero? && %w[inactive failed].include?(service.fetch("ActiveState")) &&
|
|
265
|
+
service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
|
|
266
|
+
unavailable!("native admission has an occupied or pending generation")
|
|
267
|
+
end
|
|
268
|
+
# Effective firewall evidence cannot be obtained through this fixed
|
|
269
|
+
# unprivileged owner. The reviewed evidence/lifetime contract is still
|
|
270
|
+
# open: unit hashes and /proc routes must never substitute for it.
|
|
271
|
+
unavailable!("required effective network boundary evidence is unavailable")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def start_admitted_service!
|
|
275
|
+
@manager.start_service
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def sealed_service_stop_required?(lineage)
|
|
279
|
+
unavailable!("service stop requires the canonical original seal") unless lineage.sealed?
|
|
280
|
+
service = observe(lineage).fetch("activation").fetch("service")
|
|
281
|
+
!(%w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
|
|
282
|
+
service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def stop_sealed_service!
|
|
286
|
+
@manager.stop_service
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def retire_released_parent!(lineages)
|
|
290
|
+
@deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
|
|
291
|
+
activation = @manager.inspect_activation
|
|
292
|
+
service = activation.fetch("service")
|
|
293
|
+
unless %w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
|
|
294
|
+
service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?
|
|
295
|
+
unavailable!("released parent retirement has a live or pending service")
|
|
296
|
+
end
|
|
297
|
+
slice = activation.fetch("slice")
|
|
298
|
+
unless slice.fetch("Job") == [0, "/"]
|
|
299
|
+
unavailable!("released parent retirement has a pending parent job")
|
|
300
|
+
end
|
|
301
|
+
if %w[inactive failed].include?(slice.fetch("ActiveState"))
|
|
302
|
+
unless slice.fetch("InvocationID").empty? || lineages.any? { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
|
|
303
|
+
unavailable!("stopped parent metadata belongs to an unknown generation")
|
|
304
|
+
end
|
|
305
|
+
return {"state" => "already_retired"}
|
|
306
|
+
end
|
|
307
|
+
exact = lineages.select { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
|
|
308
|
+
unavailable!("active parent has no unique released canonical owner") unless exact.size == 1
|
|
309
|
+
verify_closed!(exact.first)
|
|
310
|
+
@manager.stop_slice
|
|
311
|
+
after = @manager.inspect_activation
|
|
312
|
+
unless %w[inactive failed].include?(after.fetch("slice").fetch("ActiveState")) &&
|
|
313
|
+
after.fetch("slice").fetch("Job") == [0, "/"] && after.fetch("service") == service
|
|
314
|
+
unavailable!("released parent retirement outcome is unavailable")
|
|
315
|
+
end
|
|
316
|
+
{"state" => "retired", "scope_binding_event_id" => exact.first.binding_event.fetch("digest")}
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def closed_observation_for_proof!(lineage, events:)
|
|
320
|
+
value = observe(lineage)
|
|
321
|
+
service = value.fetch("activation").fetch("service")
|
|
322
|
+
unless lineage.sealed? && value.fetch("populated").zero? && %w[inactive failed].include?(service.fetch("ActiveState")) &&
|
|
323
|
+
service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
|
|
324
|
+
unavailable!("sealed parent still has writers or pending activation")
|
|
325
|
+
end
|
|
326
|
+
admitted = events.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_service_admission" }
|
|
327
|
+
if admitted && !lineage.native_event
|
|
328
|
+
unavailable!("admitted native activation lacks verified writer-boundary evidence")
|
|
329
|
+
end
|
|
330
|
+
if admitted || lineage.native_event || lineage.child_event || events.any? { |event|
|
|
331
|
+
%w[process_start service_claim service_transition inbox_binding].include?(event["type"]) ||
|
|
332
|
+
event["type"] == "authority_mutation" && %w[record_launch bind_process release_launch].include?(event.dig("payload", "operation")) }
|
|
333
|
+
unavailable!("post-native writer-boundary revalidation is unavailable")
|
|
334
|
+
end
|
|
335
|
+
# This bounded path proves a generation that never admitted native,
|
|
336
|
+
# child or effect creation. It is not post-native readiness proof.
|
|
337
|
+
current = parent_resources!
|
|
338
|
+
unless current == lineage.binding.fetch("resource_identities") &&
|
|
339
|
+
@files.namespace_identity == lineage.binding.fetch("resource_mount_namespace_identity")
|
|
340
|
+
unavailable!("never-admitted parent resources or observation namespace changed")
|
|
341
|
+
end
|
|
342
|
+
@files.worker_uid_quiescent!(@map.fetch("worker_uid"))
|
|
343
|
+
# Rejoin object and population after the credential baseline, keeping
|
|
344
|
+
# the snapshot bounded by the same retained activation and exclusion.
|
|
345
|
+
unless observe(lineage).fetch("populated").zero?
|
|
346
|
+
unavailable!("never-admitted parent acquired a writer during baseline observation")
|
|
347
|
+
end
|
|
348
|
+
lineage.binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity").merge(
|
|
349
|
+
"scope_binding_event_id" => lineage.binding_event.fetch("digest"), "seal_event_id" => lineage.seal_event.fetch("digest"), "populated" => 0)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
private
|
|
353
|
+
|
|
354
|
+
# Inventory only: no configurable security booleans or alternative
|
|
355
|
+
# policy backend. Native-only objects are observed at their later stage.
|
|
356
|
+
def network_selection!
|
|
357
|
+
manifest = @files.boundary_manifest(@scope)
|
|
358
|
+
Molecules::ExecutionScopeLineage.validate_network_selection!(manifest.fetch("network_installation"))
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def parent_resources!
|
|
362
|
+
manifest = @files.boundary_manifest(@scope)
|
|
363
|
+
unless manifest.is_a?(Hash) && manifest.keys.sort == %w[network_installation resources schema slot_id] &&
|
|
364
|
+
manifest["schema"] == BOUNDARY_SCHEMA && manifest["slot_id"] == @scope.fetch("slot_id") &&
|
|
365
|
+
manifest["resources"].is_a?(Array) && manifest["resources"].size.between?(1, 64)
|
|
366
|
+
unavailable!("fixed boundary resource inventory differs")
|
|
367
|
+
end
|
|
368
|
+
pairs = []
|
|
369
|
+
resources = manifest.fetch("resources").filter_map do |resource|
|
|
370
|
+
unless resource.is_a?(Hash) && resource.keys.sort == %w[host_path stage view_path] && %w[parent native].include?(resource["stage"])
|
|
371
|
+
unavailable!("boundary resource inventory is not closed")
|
|
372
|
+
end
|
|
373
|
+
paths = resource.values_at("host_path", "view_path")
|
|
374
|
+
unless paths.all? { |path| path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") &&
|
|
375
|
+
!path.include?("\0") && File.expand_path(path) == path } && !pairs.include?(paths)
|
|
376
|
+
unavailable!("boundary resource paths are substituted or repeated")
|
|
377
|
+
end
|
|
378
|
+
pairs << paths
|
|
379
|
+
next if resource.fetch("stage") == "native"
|
|
380
|
+
actual = @files.resource_identity(resource.fetch("host_path"))
|
|
381
|
+
unless LOCAL_FILESYSTEMS.include?(actual.fetch("filesystem_type"))
|
|
382
|
+
unavailable!("parent resource backing is not local kernel-managed storage")
|
|
383
|
+
end
|
|
384
|
+
resource.slice("host_path", "view_path").merge(actual)
|
|
385
|
+
end
|
|
386
|
+
unavailable!("parent resource inventory is empty") if resources.empty?
|
|
387
|
+
verify_parent_access_boundary!(resources)
|
|
388
|
+
resources
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def verify_parent_access_boundary!(resources)
|
|
392
|
+
worker = @map.fetch("worker_uid")
|
|
393
|
+
authority = @deployment.authority(@map.fetch("authority_id")).fetch("uid")
|
|
394
|
+
readers = @deployment.project(@map.fetch("project_id")).fetch("supervisor_uids")
|
|
395
|
+
trusted = [worker, authority, @map.fetch("launcher_uid"), *readers].uniq
|
|
396
|
+
policies = resources.to_h do |resource|
|
|
397
|
+
path = resource.fetch("host_path")
|
|
398
|
+
policy = @files.resource_boundary_policy(path)
|
|
399
|
+
unless %w[device inode uid gid].all? { |key| policy.fetch(key) == resource.fetch(key) }
|
|
400
|
+
unavailable!("parent access policy belongs to a replaced object")
|
|
401
|
+
end
|
|
402
|
+
[path, policy]
|
|
403
|
+
end
|
|
404
|
+
barriers = policies.select do |_path, policy|
|
|
405
|
+
next false unless policy.fetch("uid").zero? && (policy.fetch("mode") & 0o7022).zero?
|
|
406
|
+
acl = policy.fetch("acl")
|
|
407
|
+
if acl.nil?
|
|
408
|
+
next (policy.fetch("mode") & 0o077).zero?
|
|
409
|
+
end
|
|
410
|
+
acl.all? do |tag, permissions, uid|
|
|
411
|
+
case tag
|
|
412
|
+
when 1 then permissions == 7
|
|
413
|
+
when 2 then trusted.include?(uid) && (permissions & 2).zero?
|
|
414
|
+
when 4, 8, 32 then permissions.zero?
|
|
415
|
+
when 16 then (permissions & 2).zero?
|
|
416
|
+
else false
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
unless resources.any? { |resource| resource.fetch("uid") == worker } && resources.all? { |resource|
|
|
421
|
+
path = resource.fetch("host_path")
|
|
422
|
+
[0, worker].include?(resource.fetch("uid")) && barriers.keys.any? { |ancestor| path == ancestor || path.start_with?(ancestor + "/") } }
|
|
423
|
+
unavailable!("parent roots lack an immutable exclusive host traversal boundary")
|
|
424
|
+
end
|
|
425
|
+
resources.each do |resource|
|
|
426
|
+
aliases = @files.resource_aliases(resource.fetch("host_path"), identity: resource)
|
|
427
|
+
unless aliases.all? { |path| barriers.keys.any? { |ancestor| path == ancestor || path.start_with?(ancestor + "/") } }
|
|
428
|
+
unavailable!("protected parent backing is exposed through an outside writable mount")
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def verify_parent!(binding, slice)
|
|
434
|
+
expected = binding.fetch("cgroup_identity").fetch("path").delete_prefix(Ace::Runtime::Molecules::CgroupObservation::ROOT)
|
|
435
|
+
unless slice.fetch("Id") == @scope.fetch("slice_unit") && slice.fetch("ActiveState") == "active" && slice.fetch("SubState") == "active" &&
|
|
436
|
+
slice.fetch("InvocationID") == binding.fetch("slice_invocation_id") && slice.fetch("ControlGroup") == expected &&
|
|
437
|
+
slice.fetch("Job") == [0, "/"]
|
|
438
|
+
unavailable!("retained exact parent slice changed")
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def verify_resources!(resources, allow_runtime_absence: false, same_namespace: true)
|
|
443
|
+
resources.each do |resource|
|
|
444
|
+
path = resource.fetch("host_path")
|
|
445
|
+
begin
|
|
446
|
+
actual = @files.resource_identity(path)
|
|
447
|
+
rescue Errno::ENOENT
|
|
448
|
+
runtime = @scope.fetch("runtime_directory")
|
|
449
|
+
# Fixed stopped RuntimeDirectoryPreserve=no cleanup may remove
|
|
450
|
+
# native-stage descendants. Absence never supplies the proof:
|
|
451
|
+
# parent identity/population and settled activation did above.
|
|
452
|
+
next if allow_runtime_absence && (path == runtime || path.start_with?(runtime + "/"))
|
|
453
|
+
raise
|
|
454
|
+
end
|
|
455
|
+
unless LOCAL_FILESYSTEMS.include?(actual.fetch("filesystem_type")) &&
|
|
456
|
+
(%w[device inode uid gid filesystem_type] + (same_namespace ? ["mount_id"] : [])).all? { |key| actual.fetch(key) == resource.fetch(key) }
|
|
457
|
+
unavailable!("retained resource backing object changed")
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def canonical(value)
|
|
463
|
+
case value
|
|
464
|
+
when Hash then value.sort.to_h.transform_values { |item| canonical(item) }
|
|
465
|
+
when Array then value.map { |item| canonical(item) }
|
|
466
|
+
else value
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def unavailable!(message)
|
|
471
|
+
raise Ace::Runtime::RuntimeUnavailableError, message
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "digest"
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "ace/herdr/molecules/protected_native_control"
|
|
6
|
+
require_relative "client"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Assign
|
|
10
|
+
module Authority
|
|
11
|
+
class LaunchDriver
|
|
12
|
+
def initialize(mapping_id:, deployment: Deployment.load,
|
|
13
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, client: nil, native: nil)
|
|
14
|
+
@mapping_id, @deployment, @kernel = mapping_id, deployment, kernel
|
|
15
|
+
@map = deployment.mapping(mapping_id)
|
|
16
|
+
@client = client || Client.new(mapping_id: mapping_id, deployment: deployment, kernel: kernel)
|
|
17
|
+
@native = native
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def preflight
|
|
21
|
+
@deployment.verify!(@mapping_id, kernel: @kernel)
|
|
22
|
+
@client.call("launch_preflight", {}).data
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def launch(assignment_id:, definition_bytes:, scope:, base_head:, mutation_id: SecureRandom.hex(16))
|
|
26
|
+
preflight
|
|
27
|
+
registration = @client.call("registration_status", {"assignment_id" => assignment_id}).data
|
|
28
|
+
digest = Digest::SHA256.hexdigest(definition_bytes)
|
|
29
|
+
registered = if registration["definition_digest"] == digest
|
|
30
|
+
registration
|
|
31
|
+
else
|
|
32
|
+
@client.call("register_assignment", {"assignment_id" => assignment_id,
|
|
33
|
+
"definition_bytes" => definition_bytes, "definition_digest" => digest,
|
|
34
|
+
"expected_generation" => registration.fetch("generation", 0)}, mutation_id: "#{mutation_id}-register").data
|
|
35
|
+
end
|
|
36
|
+
reserved = @client.call("reserve_attempt", {"assignment_id" => assignment_id, "scope" => scope,
|
|
37
|
+
"worker_uid" => @map.fetch("worker_uid"), "runtime" => "herdr", "base_head" => base_head,
|
|
38
|
+
"launcher_process_binding" => @kernel.capture(Process.pid),
|
|
39
|
+
"expected_generation" => registered.fetch("definition_generation")}, mutation_id: "#{mutation_id}-reserve")
|
|
40
|
+
# Only this actual CAS winner may create. A cached canonical reply is
|
|
41
|
+
# evidence of ownership, never evidence that native creation is fresh.
|
|
42
|
+
return reserved.data.merge("required_action" => "inspect_retained_reservation_no_creation_permission") if reserved.replayed
|
|
43
|
+
state = reserved.data
|
|
44
|
+
inspected = @client.call("inspect_launch", state.slice("assignment_id", "attempt_id")).data
|
|
45
|
+
state = inspected
|
|
46
|
+
origin = inspected.fetch("native_binding")
|
|
47
|
+
return close_before_native_release(state, mutation_id) unless origin
|
|
48
|
+
fixed = JSON.parse(JSON.generate(@map))
|
|
49
|
+
fixed.fetch("native").merge!(origin.slice("server_identity", "socket_identity", "workspace_id"))
|
|
50
|
+
@native ||= Ace::Herdr::Molecules::ProtectedNativeControl.new(mapping: fixed, kernel: @kernel)
|
|
51
|
+
binding = @native.create(mapping_id: @mapping_id, ticket: state.fetch("launch_ticket"))
|
|
52
|
+
child_handle = @kernel.pin(binding.fetch("process_identity"))
|
|
53
|
+
recorded = @client.call("record_launch", lifecycle_params(state, binding), mutation_id: "#{mutation_id}-record").data
|
|
54
|
+
bound = @client.call("bind_process", lifecycle_params(recorded, binding), mutation_id: "#{mutation_id}-bind").data
|
|
55
|
+
# Exact original child is reobserved after canonical bind, before release.
|
|
56
|
+
@native.observe(binding.fetch("native_origin").merge("process_binding" => binding))
|
|
57
|
+
@kernel.live!(binding.fetch("process_identity"))
|
|
58
|
+
issued = @client.call("release_launch", lifecycle_params(bound, binding), mutation_id: "#{mutation_id}-release").data
|
|
59
|
+
issued
|
|
60
|
+
rescue StandardError
|
|
61
|
+
# Response loss or partial creation never retries native creation.
|
|
62
|
+
# Canonical state is retained by the authority. Expose its ticket if
|
|
63
|
+
# it was received, but do not fabricate an accepted abort/termination.
|
|
64
|
+
raise if state.nil?
|
|
65
|
+
state.slice("assignment_id", "attempt_id", "launch_ticket", "journal_commit", "generation").merge(
|
|
66
|
+
"phase" => "uncertain", "required_action" => "inspect_exact_native_child_and_canonical_release_state")
|
|
67
|
+
ensure
|
|
68
|
+
child_handle&.close
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def terminate(state:, binding:, evidence:, mutation_id: SecureRandom.hex(16))
|
|
72
|
+
observed = state["termination_observation"]
|
|
73
|
+
unless observed.is_a?(Hash) && observed["status"] == "exited" && observed["process_identity"] == binding["process_identity"]
|
|
74
|
+
handle = @kernel.pin(binding.fetch("process_identity"))
|
|
75
|
+
exited = @native.terminate(binding, handle: handle)
|
|
76
|
+
raise AttemptErrors::EvidenceUnavailable, "native close did not prove exact child exit" unless exited
|
|
77
|
+
end
|
|
78
|
+
@client.call("abort_launch", {"assignment_id" => state.fetch("assignment_id"), "attempt_id" => state.fetch("attempt_id"),
|
|
79
|
+
"launch_ticket" => state.fetch("launch_ticket"), "expected_generation" => state.fetch("generation"),
|
|
80
|
+
"failure_evidence" => evidence, "failure_digest" => Digest::SHA256.hexdigest(evidence)}, mutation_id: mutation_id).data
|
|
81
|
+
ensure
|
|
82
|
+
handle&.close
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
def close_before_native_release(state, mutation_id)
|
|
87
|
+
unless state["scope_binding_event_id"]
|
|
88
|
+
return state.merge("required_action" => "inspect_exact_scope")
|
|
89
|
+
end
|
|
90
|
+
selectors = state.slice("assignment_id", "attempt_id")
|
|
91
|
+
sealed = @client.call("close_execution_scope", selectors.merge("expected_generation" => state.fetch("generation")),
|
|
92
|
+
mutation_id: "#{mutation_id}-scope-seal").data
|
|
93
|
+
closed = @client.call("close_execution_scope", selectors.merge("expected_generation" => sealed.fetch("generation")),
|
|
94
|
+
mutation_id: "#{mutation_id}-scope-proof").data
|
|
95
|
+
unless closed["state"] == "closed_no_writers"
|
|
96
|
+
return state.merge("required_action" => "inspect_exact_scope")
|
|
97
|
+
end
|
|
98
|
+
inspected = @client.call("inspect_launch", selectors).data
|
|
99
|
+
failure = inspected.fetch("scope_failure_evidence")
|
|
100
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical scope failure selectors unavailable" unless failure
|
|
101
|
+
bytes = JSON.generate(failure)
|
|
102
|
+
@client.call("abort_launch", selectors.merge("launch_ticket" => state.fetch("launch_ticket"),
|
|
103
|
+
"expected_generation" => inspected.fetch("generation"), "failure_evidence" => bytes,
|
|
104
|
+
"failure_digest" => Digest::SHA256.hexdigest(bytes)), mutation_id: "#{mutation_id}-scope-abort").data
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def lifecycle_params(state, binding)
|
|
108
|
+
state.slice("assignment_id", "attempt_id", "launch_ticket").merge("process_binding" => binding,
|
|
109
|
+
"expected_generation" => state.fetch("generation"))
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|