ace-assign 0.63.1 → 0.66.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/auto-merge.step.yml +11 -0
- 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 +62 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +282 -0
- data/docs/usage.md +205 -3
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
- data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
- data/lib/ace/assign/authority/campaign_execution.rb +119 -0
- data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +257 -0
- data/lib/ace/assign/authority/deployment.rb +627 -0
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +455 -0
- data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
- data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
- data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
- data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
- data/lib/ace/assign/authority/endcap_publication.rb +207 -0
- data/lib/ace/assign/authority/endcap_results.rb +398 -0
- data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
- data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
- data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
- data/lib/ace/assign/authority/endcap_services.rb +598 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
- data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
- data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
- data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
- data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
- data/lib/ace/assign/authority/launch_driver.rb +393 -0
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
- data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
- data/lib/ace/assign/authority/launch_review.rb +91 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
- data/lib/ace/assign/authority/launch_steering.rb +353 -0
- data/lib/ace/assign/authority/launch_stop.rb +254 -0
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
- data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
- data/lib/ace/assign/authority/posix_acl.rb +59 -0
- data/lib/ace/assign/authority/prepared_input.rb +130 -0
- data/lib/ace/assign/authority/prepared_queue.rb +185 -0
- data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
- data/lib/ace/assign/authority/prepared_work.rb +304 -0
- data/lib/ace/assign/authority/prepared_worker.rb +69 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +76 -0
- data/lib/ace/assign/authority/server.rb +300 -0
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
- data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
- data/lib/ace/assign/authority/service_evidence.rb +286 -0
- data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
- data/lib/ace/assign/authority/task_context_entry.rb +32 -0
- data/lib/ace/assign/authority/transfer_codec.rb +187 -0
- data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
- data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
- data/lib/ace/assign/cli/commands/create.rb +11 -1
- data/lib/ace/assign/cli/commands/delivery.rb +107 -0
- data/lib/ace/assign/cli/commands/fail.rb +6 -0
- data/lib/ace/assign/cli/commands/finish.rb +23 -2
- data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
- data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
- data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
- data/lib/ace/assign/cli/commands/resume.rb +38 -0
- data/lib/ace/assign/cli/commands/select.rb +2 -0
- data/lib/ace/assign/cli/commands/start.rb +2 -0
- data/lib/ace/assign/cli/commands/status.rb +4 -2
- data/lib/ace/assign/cli/commands/step.rb +3 -1
- data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
- data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
- data/lib/ace/assign/cli.rb +56 -4
- data/lib/ace/assign/errors.rb +139 -0
- data/lib/ace/assign/models/assignment.rb +26 -4
- data/lib/ace/assign/models/evidence_event.rb +4 -2
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
- data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
- data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
- data/lib/ace/assign/molecules/step_writer.rb +27 -5
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +4 -102
- metadata +186 -8
|
@@ -0,0 +1,903 @@
|
|
|
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 "ace/runtime/molecules/network_installation_evidence"
|
|
8
|
+
require "ace/runtime/molecules/execution_network_selection"
|
|
9
|
+
require "ace/runtime/molecules/execution_boot_baseline"
|
|
10
|
+
require "ace/runtime/molecules/kernel_view_topology"
|
|
11
|
+
require_relative "../molecules/execution_scope_lineage"
|
|
12
|
+
require_relative "../molecules/lifecycle_exclusion"
|
|
13
|
+
require_relative "posix_acl"
|
|
14
|
+
require_relative "maintenance_workspace_retirement"
|
|
15
|
+
require "digest"
|
|
16
|
+
require "json"
|
|
17
|
+
require "ace/herdr/molecules/protected_native_control"
|
|
18
|
+
|
|
19
|
+
module Ace
|
|
20
|
+
module Assign
|
|
21
|
+
module Authority
|
|
22
|
+
# Joins already canonical scope facts to current native owner objects.
|
|
23
|
+
# This does not manufacture readiness, a network-policy assertion, or a
|
|
24
|
+
# canonical no-writer event from a population snapshot.
|
|
25
|
+
class ExecutionScopeObservation
|
|
26
|
+
LOCAL_FILESYSTEMS = %w[ext4 xfs btrfs tmpfs].freeze
|
|
27
|
+
BOUNDARY_SCHEMA = "ace.execution-boundary-manifest/v1"
|
|
28
|
+
|
|
29
|
+
class Files
|
|
30
|
+
def boot_id
|
|
31
|
+
value = File.read("/proc/sys/kernel/random/boot_id", 128).strip
|
|
32
|
+
unless value.match?(Molecules::ExecutionScopeLineage::BOOT)
|
|
33
|
+
raise Ace::Runtime::RuntimeUnavailableError, "kernel boot identity is malformed"
|
|
34
|
+
end
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resource_identity(path)
|
|
39
|
+
unless path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path && File.realpath(path) == path
|
|
40
|
+
raise Ace::Runtime::RuntimeUnavailableError, "scope resource path is substituted"
|
|
41
|
+
end
|
|
42
|
+
File.open(path, File::RDONLY | File::NOFOLLOW) do |directory|
|
|
43
|
+
stat = directory.stat
|
|
44
|
+
raise Ace::Runtime::RuntimeUnavailableError, "scope resource is not a directory" unless stat.directory?
|
|
45
|
+
info = File.read("/proc/self/fdinfo/#{directory.fileno}", 16_385)
|
|
46
|
+
ids = info.lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
|
|
47
|
+
raise Ace::Runtime::RuntimeUnavailableError, "resource mount identity is ambiguous" unless ids.size == 1
|
|
48
|
+
bytes = File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1)
|
|
49
|
+
mount = Ace::Runtime::Molecules::LinuxMountInfo.new(bytes).by_id(Integer(ids.first, 10))
|
|
50
|
+
current = File.stat(path)
|
|
51
|
+
unless current.dev == stat.dev && current.ino == stat.ino && File.realpath(path) == path
|
|
52
|
+
raise Ace::Runtime::RuntimeUnavailableError, "scope resource changed while observing it"
|
|
53
|
+
end
|
|
54
|
+
{"device" => stat.dev, "inode" => stat.ino, "uid" => stat.uid, "gid" => stat.gid,
|
|
55
|
+
"filesystem_type" => mount.fetch("filesystem_type"), "mount_id" => mount.fetch("mount_id")}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def namespace_identity
|
|
60
|
+
File.open("/proc/self/ns/mnt", File::RDONLY) do |namespace|
|
|
61
|
+
stat = namespace.stat
|
|
62
|
+
{"device" => stat.dev, "inode" => stat.ino}
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def authority_socket_identity(authority)
|
|
67
|
+
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
68
|
+
wire.root_path!(File.dirname(authority.fetch("socket_path")), directory: true, owner: authority.fetch("uid"))
|
|
69
|
+
identity = wire.socket_identity(authority.fetch("socket_path"))
|
|
70
|
+
raise Ace::Runtime::RuntimeUnavailableError, "authority endpoint owner differs" unless identity.last == authority.fetch("uid")
|
|
71
|
+
identity
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def pin_network_namespace(path)
|
|
75
|
+
unless RUBY_PLATFORM.include?("linux") && path.is_a?(String) && path.start_with?("/") &&
|
|
76
|
+
path.bytesize.between?(1, 4096) && !path.include?("\0") && File.expand_path(path) == path && File.realpath(path) == path
|
|
77
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace path is substituted"
|
|
78
|
+
end
|
|
79
|
+
parts = path.split("/").reject(&:empty?)
|
|
80
|
+
paths = ["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }
|
|
81
|
+
paths.each do |ancestor|
|
|
82
|
+
stat = File.lstat(ancestor)
|
|
83
|
+
unless stat.uid.zero? && !stat.symlink? && (stat.mode & 0o022).zero? &&
|
|
84
|
+
(ancestor == path || stat.directory?)
|
|
85
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace ancestry is writable"
|
|
86
|
+
end
|
|
87
|
+
# The leaf is authenticated as nsfs below. nsfs has no access
|
|
88
|
+
# ACL/xattr owner and exposes only namespace ioctls, not writes.
|
|
89
|
+
next if ancestor == path
|
|
90
|
+
acl = PosixAcl.new.entries(ancestor)
|
|
91
|
+
if acl && acl.any? { |tag, permissions, uid| (permissions & 2) != 0 && !(tag == 1 || tag == 2 && uid.zero?) }
|
|
92
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed network namespace ACL permits untrusted mutation"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
handle = File.open(path, File::RDONLY | File::NOFOLLOW)
|
|
96
|
+
stat = handle.stat
|
|
97
|
+
info = File.read("/proc/self/fdinfo/#{handle.fileno}", 16_385)
|
|
98
|
+
ids = info.lines.filter_map { |line| /\Amnt_id:\s+([0-9]+)\s*\z/.match(line)&.[](1) }
|
|
99
|
+
table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
|
|
100
|
+
unless ids.size == 1 && table.by_id(Integer(ids.first, 10)).fetch("filesystem_type") == "nsfs" &&
|
|
101
|
+
handle.ioctl(0xb703) == 0x40000000 && [File.stat(path).dev, File.stat(path).ino] == [stat.dev, stat.ino]
|
|
102
|
+
raise Ace::Runtime::RuntimeUnavailableError, "installed object is not the exact nsfs network namespace"
|
|
103
|
+
end
|
|
104
|
+
{handle: handle, identity: {"device" => stat.dev, "inode" => stat.ino}}
|
|
105
|
+
rescue StandardError
|
|
106
|
+
handle&.close
|
|
107
|
+
raise
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def resource_topology(path, mount_id:)
|
|
111
|
+
table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
|
|
112
|
+
record = table.by_id(mount_id)
|
|
113
|
+
{"major_minor" => record.fetch("major_minor"), "filesystem_path" => table.filesystem_path(record, path)}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def resource_boundary_policy(path)
|
|
117
|
+
stat = File.lstat(path)
|
|
118
|
+
unless stat.directory? && File.realpath(path) == path
|
|
119
|
+
raise Ace::Runtime::RuntimeUnavailableError, "protected root policy object is substituted"
|
|
120
|
+
end
|
|
121
|
+
{"device" => stat.dev, "inode" => stat.ino, "uid" => stat.uid, "gid" => stat.gid,
|
|
122
|
+
"mode" => stat.mode & 0o7777, "acl" => PosixAcl.new.entries(path)}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def resource_aliases(path, identity:)
|
|
126
|
+
table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
|
|
127
|
+
original = table.by_id(identity.fetch("mount_id"))
|
|
128
|
+
physical = table.filesystem_path(original, path)
|
|
129
|
+
table.records.filter_map do |mount|
|
|
130
|
+
next unless mount.fetch("major_minor") == original.fetch("major_minor") && mount.fetch("options").include?("rw")
|
|
131
|
+
root = mount.fetch("root")
|
|
132
|
+
if physical == root || root == "/" || physical.start_with?(root + "/")
|
|
133
|
+
suffix = physical.delete_prefix(root).delete_prefix("/")
|
|
134
|
+
File.join(mount.fetch("mountpoint"), suffix)
|
|
135
|
+
elsif root.start_with?(physical + "/")
|
|
136
|
+
mount.fetch("mountpoint")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def lifecycle_resource_policy!(path, authority:, identity:)
|
|
142
|
+
state_root = authority.fetch("state_root")
|
|
143
|
+
unless path.start_with?(state_root + "/lifecycle-exclusion/") && File.expand_path(path) == path
|
|
144
|
+
raise Ace::Runtime::RuntimeUnavailableError, "lifecycle resource ancestry differs"
|
|
145
|
+
end
|
|
146
|
+
parts = path.split("/").reject(&:empty?)
|
|
147
|
+
paths = ["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }
|
|
148
|
+
paths.each do |ancestor|
|
|
149
|
+
policy = resource_boundary_policy(ancestor)
|
|
150
|
+
unless [0, authority.fetch("uid")].include?(policy.fetch("uid")) &&
|
|
151
|
+
(policy.fetch("mode") & 0o7022).zero? && policy.fetch("acl").nil? && resource_default_acl(ancestor).nil?
|
|
152
|
+
raise Ace::Runtime::RuntimeUnavailableError, "lifecycle resource ancestry is writable"
|
|
153
|
+
end
|
|
154
|
+
if ancestor == state_root || ancestor == path
|
|
155
|
+
mode = ancestor == path ? 0o755 : 0o700
|
|
156
|
+
unless policy.values_at("uid", "gid", "mode") == authority.values_at("uid", "gid") + [mode]
|
|
157
|
+
raise Ace::Runtime::RuntimeUnavailableError, "lifecycle resource selected ownership differs"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
if ancestor == path && policy.values_at("device", "inode", "uid", "gid") !=
|
|
161
|
+
identity.values_at("device", "inode", "uid", "gid")
|
|
162
|
+
raise Ace::Runtime::RuntimeUnavailableError, "lifecycle policy belongs to a replaced object"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def resource_default_acl(path)
|
|
169
|
+
PosixAcl.new.entries(path, attribute: "system.posix_acl_default")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def worker_uid_quiescent!(uid)
|
|
173
|
+
table = Ace::Runtime::Molecules::LinuxMountInfo.new(File.read("/proc/self/mountinfo", Ace::Runtime::Molecules::LinuxMountInfo::LIMIT + 1))
|
|
174
|
+
proc_mounts = table.records.select { |record| record.fetch("mountpoint") == "/proc" }
|
|
175
|
+
unless proc_mounts.size == 1 && proc_mounts.first.fetch("filesystem_type") == "proc" && proc_mounts.first.fetch("root") == "/" &&
|
|
176
|
+
(proc_mounts.first.fetch("options") + proc_mounts.first.fetch("super_options")).none? { |option| option.start_with?("hidepid=") && option != "hidepid=0" }
|
|
177
|
+
raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline requires a fully visible authority proc view"
|
|
178
|
+
end
|
|
179
|
+
pids = Dir.children("/proc").select { |name| name.match?(/\A[1-9][0-9]*\z/) }
|
|
180
|
+
raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline is oversized" if pids.size > 100_000
|
|
181
|
+
pids.each do |pid|
|
|
182
|
+
begin
|
|
183
|
+
bytes = File.open("/proc/#{pid}/status", File::RDONLY | File::NOFOLLOW) { |file| file.read(65_537) }
|
|
184
|
+
rescue Errno::ENOENT, Errno::ESRCH
|
|
185
|
+
next # A dead process cannot retain a writable userspace handle.
|
|
186
|
+
end
|
|
187
|
+
rows = bytes.lines.select { |line| line.start_with?("Uid:") }
|
|
188
|
+
match = rows.size == 1 && /\AUid:\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s*\z/.match(rows.first)
|
|
189
|
+
unless bytes.bytesize <= 65_536 && match
|
|
190
|
+
raise Ace::Runtime::RuntimeUnavailableError, "worker UID baseline has unreadable credential evidence"
|
|
191
|
+
end
|
|
192
|
+
if match.captures.map { |value| Integer(value, 10) }.include?(uid)
|
|
193
|
+
raise Ace::Runtime::RuntimeUnavailableError, "worker UID has a process outside the never-admitted generation"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
true
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def resource_present?(path)
|
|
200
|
+
File.lstat(path)
|
|
201
|
+
true
|
|
202
|
+
rescue Errno::ENOENT
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def boundary_manifest(scope)
|
|
207
|
+
path = "/etc/ace/execution-slots/#{scope.fetch('slot_id')}/boundary-manifest.json"
|
|
208
|
+
bytes = Ace::Runtime::Molecules::ExecutionUnitInstallation::Files.new.read(path, limit: 65_536)
|
|
209
|
+
unless Digest::SHA256.hexdigest(bytes) == scope.fetch("boundary_manifest_sha256")
|
|
210
|
+
raise Ace::Runtime::RuntimeUnavailableError, "boundary inventory differs from fixed deployment"
|
|
211
|
+
end
|
|
212
|
+
JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
|
|
213
|
+
rescue JSON::ParserError, EncodingError
|
|
214
|
+
raise Ace::Runtime::RuntimeUnavailableError, "fixed boundary inventory is not strict UTF-8 JSON"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def initialize(mapping_id:, deployment:, kernel:, manager: nil, cgroups: Ace::Runtime::Molecules::CgroupObservation.new, files: Files.new, network_evidence: Ace::Runtime::Molecules::NetworkInstallationEvidence.new, boot_evidence: Ace::Runtime::Molecules::ExecutionBootBaseline.new, network_selection: Ace::Runtime::Molecules::ExecutionNetworkSelection.new, workspace_reader_factory: nil)
|
|
219
|
+
@mapping_id, @deployment, @kernel, @cgroups, @files = mapping_id, deployment, kernel, cgroups, files
|
|
220
|
+
@network_evidence = network_evidence
|
|
221
|
+
@boot_evidence = boot_evidence
|
|
222
|
+
@network_selection = network_selection
|
|
223
|
+
@workspace_reader_factory = workspace_reader_factory || ->(projection:) {
|
|
224
|
+
Molecules::LifecycleExclusion::WorkspaceNativeReader.new(projection: projection)
|
|
225
|
+
}
|
|
226
|
+
@native_workspace_lifetime = nil
|
|
227
|
+
@map = deployment.mapping(mapping_id)
|
|
228
|
+
@scope = @map.fetch("execution_scope")
|
|
229
|
+
@manager = manager || Ace::Runtime::Molecules::SystemdScopeManager.new(
|
|
230
|
+
slice_unit: @scope.fetch("slice_unit"), service_unit: @scope.fetch("service_unit"))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def observe(lineage, retirement: nil)
|
|
234
|
+
@deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
|
|
235
|
+
binding = lineage.binding
|
|
236
|
+
unless binding && binding.fetch("slot_id") == @scope.fetch("slot_id") && binding.fetch("boot_id") == @files.boot_id &&
|
|
237
|
+
binding.fetch("deployment_digest") == Digest::SHA256.hexdigest(JSON.generate(canonical(@map))) &&
|
|
238
|
+
binding.fetch("resource_mount_namespace_identity") == @files.namespace_identity
|
|
239
|
+
unavailable!("retained scope deployment or boot changed")
|
|
240
|
+
end
|
|
241
|
+
network = @files.pin_network_namespace(@scope.fetch("network_namespace_path"))
|
|
242
|
+
unless network.fetch(:identity) == binding.fetch("network_namespace_identity") &&
|
|
243
|
+
network_selection! == binding.fetch("network_installation_selection")
|
|
244
|
+
unavailable!("retained installed network namespace or selection changed")
|
|
245
|
+
end
|
|
246
|
+
boot_baseline!(binding)
|
|
247
|
+
before = @manager.inspect_activation
|
|
248
|
+
verify_parent!(binding, before.fetch("slice"))
|
|
249
|
+
pinned = @cgroups.pin(binding.fetch("cgroup_identity").fetch("path"), expected: binding.fetch("cgroup_identity"))
|
|
250
|
+
unless parent_resources!(retirement: retirement) == binding.fetch("resource_identities")
|
|
251
|
+
unavailable!("retained parent inventory changed")
|
|
252
|
+
end
|
|
253
|
+
verify_resources!(binding.fetch("resource_identities"), retirement: retirement)
|
|
254
|
+
value = @cgroups.observe(pinned)
|
|
255
|
+
after = @manager.inspect_activation
|
|
256
|
+
unless before == after && @files.boot_id == binding.fetch("boot_id")
|
|
257
|
+
unavailable!("scope activation changed while observing it")
|
|
258
|
+
end
|
|
259
|
+
{"populated" => value.fetch("populated"), "activation" => after}
|
|
260
|
+
rescue SystemCallError, IOError, KeyError, TypeError, ArgumentError
|
|
261
|
+
unavailable!("retained scope observation is unavailable")
|
|
262
|
+
ensure
|
|
263
|
+
pinned&.fetch(:handle)&.close
|
|
264
|
+
network&.fetch(:handle)&.close
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Called only by the fresh reservation winner with slot exclusion held.
|
|
268
|
+
# It creates no service or payload. Original activation facts are taken
|
|
269
|
+
# after this exact successful StartUnit, never adopted on a later retry.
|
|
270
|
+
def activate_parent!(context)
|
|
271
|
+
@deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
|
|
272
|
+
inventory = parent_resources!
|
|
273
|
+
selection = network_selection!
|
|
274
|
+
network = @files.pin_network_namespace(@scope.fetch("network_namespace_path"))
|
|
275
|
+
@files.worker_uid_quiescent!(@map.fetch("worker_uid"))
|
|
276
|
+
boot = @files.boot_id
|
|
277
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical(@map)))
|
|
278
|
+
baseline = @boot_evidence.select!(expected: boot_baseline_expected(boot, digest, selection))
|
|
279
|
+
namespace = @files.namespace_identity
|
|
280
|
+
before = @manager.inspect_activation
|
|
281
|
+
service = before.fetch("service")
|
|
282
|
+
slice = before.fetch("slice")
|
|
283
|
+
unless %w[inactive failed].include?(slice.fetch("ActiveState")) && slice.fetch("Job") == [0, "/"] &&
|
|
284
|
+
%w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
|
|
285
|
+
service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?
|
|
286
|
+
unavailable!("fresh parent activation has retained or pending native ownership")
|
|
287
|
+
end
|
|
288
|
+
@manager.start_slice
|
|
289
|
+
activation = @manager.inspect_activation
|
|
290
|
+
parent = activation.fetch("slice")
|
|
291
|
+
unless parent.fetch("ActiveState") == "active" && parent.fetch("SubState") == "active" &&
|
|
292
|
+
Molecules::ExecutionScopeLineage::INVOCATION.match?(parent.fetch("InvocationID")) && parent.fetch("Job") == [0, "/"] &&
|
|
293
|
+
!parent.fetch("ControlGroup").empty? && activation.fetch("service") == service
|
|
294
|
+
unavailable!("fresh parent job did not produce its exact independent activation")
|
|
295
|
+
end
|
|
296
|
+
pinned = @cgroups.pin(Ace::Runtime::Molecules::CgroupObservation::ROOT + parent.fetch("ControlGroup"))
|
|
297
|
+
unless @cgroups.observe(pinned).fetch("populated").zero? && @files.boot_id == boot &&
|
|
298
|
+
@files.namespace_identity == namespace && @manager.inspect_activation == activation
|
|
299
|
+
unavailable!("fresh parent changed or acquired processes before binding")
|
|
300
|
+
end
|
|
301
|
+
verify_resources!(inventory)
|
|
302
|
+
context.merge("slot_id" => @scope.fetch("slot_id"), "deployment_digest" => digest,
|
|
303
|
+
"boot_id" => boot, "slice_invocation_id" => parent.fetch("InvocationID"), "cgroup_identity" => pinned.fetch(:identity),
|
|
304
|
+
"resource_mount_namespace_identity" => namespace, "resource_identities" => inventory,
|
|
305
|
+
"network_installation_selection" => selection, "network_namespace_identity" => network.fetch(:identity),
|
|
306
|
+
"boot_baseline_selection" => baseline.fetch("selection"))
|
|
307
|
+
rescue SystemCallError, IOError, KeyError, TypeError, ArgumentError, JSON::ParserError
|
|
308
|
+
unavailable!("fresh parent inventory or activation is unavailable")
|
|
309
|
+
ensure
|
|
310
|
+
pinned&.fetch(:handle)&.close
|
|
311
|
+
network&.fetch(:handle)&.close
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def verify_closed!(lineage, retirement: nil)
|
|
315
|
+
value = observe(lineage, retirement: retirement)
|
|
316
|
+
service = value.fetch("activation").fetch("service")
|
|
317
|
+
unless lineage.sealed? && lineage.proof_event && value.fetch("populated").zero? &&
|
|
318
|
+
%w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("MainPID").zero? &&
|
|
319
|
+
service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
|
|
320
|
+
unavailable!("canonical closure has a live or pending native activation")
|
|
321
|
+
end
|
|
322
|
+
native_cleanup_complete!
|
|
323
|
+
@files.worker_uid_quiescent!(@map.fetch("worker_uid"))
|
|
324
|
+
if lineage.native_event
|
|
325
|
+
native = lineage.native_event.fetch("payload")
|
|
326
|
+
invocation = service.fetch("InvocationID")
|
|
327
|
+
unless invocation.empty? || invocation == native.fetch("service_invocation_id")
|
|
328
|
+
unavailable!("closed native incarnation was replaced")
|
|
329
|
+
end
|
|
330
|
+
verify_resources!(native.fetch("resource_identities"), allow_runtime_absence: true, same_namespace: false, retirement: retirement)
|
|
331
|
+
end
|
|
332
|
+
native_cleanup_complete!
|
|
333
|
+
repeated = observe(lineage, retirement: retirement)
|
|
334
|
+
unless repeated.fetch("populated").zero? && repeated.fetch("activation") == value.fetch("activation")
|
|
335
|
+
unavailable!("scope activation changed during closure verification")
|
|
336
|
+
end
|
|
337
|
+
true
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def readiness_peer!(lineage, peer)
|
|
341
|
+
lineage.require_open!
|
|
342
|
+
@deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
|
|
343
|
+
activation = observe(lineage).fetch("activation")
|
|
344
|
+
service = activation.fetch("service")
|
|
345
|
+
profile = @manager.inspect_profile
|
|
346
|
+
command = profile.fetch("service").fetch("ExecStartPostEx").first
|
|
347
|
+
unless service.fetch("ControlPID").positive? && peer.fetch("pid") == service.fetch("ControlPID") &&
|
|
348
|
+
command.fetch(7) == peer.fetch("pid") && service.fetch("MainPID").positive? &&
|
|
349
|
+
service.fetch("MainPID") != peer.fetch("pid") &&
|
|
350
|
+
peer.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
|
|
351
|
+
unavailable!("peer is not the exact fixed readiness actor")
|
|
352
|
+
end
|
|
353
|
+
server = @kernel.capture(service.fetch("MainPID"))
|
|
354
|
+
unless server.values_at("uid", "gid", "groups") == peer.values_at("uid", "gid", "groups")
|
|
355
|
+
unavailable!("native server principal differs")
|
|
356
|
+
end
|
|
357
|
+
@kernel.live!(peer)
|
|
358
|
+
unavailable!("readiness activation changed") unless @manager.inspect_activation == activation
|
|
359
|
+
server
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def verify_readiness_report!(lineage, peer, report, challenge:)
|
|
363
|
+
server = readiness_peer!(lineage, peer)
|
|
364
|
+
unless report.is_a?(Hash) && report.keys.sort == %w[challenge_id kernel_view_topology mount_namespace_identity network_namespace_identity resource_identities resource_observer_identity resource_topology server_identity version] &&
|
|
365
|
+
report["version"] == 1 && report["challenge_id"] == challenge.fetch("challenge_id") &&
|
|
366
|
+
@kernel.same?(report.fetch("server_identity"), server) && @kernel.same?(report.fetch("resource_observer_identity"), peer)
|
|
367
|
+
unavailable!("private readiness report binding differs")
|
|
368
|
+
end
|
|
369
|
+
network = report.fetch("network_namespace_identity")
|
|
370
|
+
unless network.is_a?(Hash) && network.keys.sort == %w[device inode] &&
|
|
371
|
+
network.values.all? { |value| value.is_a?(Integer) && value.positive? } &&
|
|
372
|
+
network == lineage.binding.fetch("network_namespace_identity") &&
|
|
373
|
+
network == lineage.admission_event.dig("payload", "data", "network_installation", "namespace_identity")
|
|
374
|
+
unavailable!("original server network namespace differs from admitted installation")
|
|
375
|
+
end
|
|
376
|
+
entries = @files.boundary_manifest(@scope).fetch("resources").select { |entry| entry.fetch("worker_visible") }
|
|
377
|
+
resources = report.fetch("resource_identities")
|
|
378
|
+
unless resources.is_a?(Array) && resources.map { |entry| entry.values_at("host_path", "view_path") }.sort ==
|
|
379
|
+
entries.map { |entry| entry.values_at("host_path", "view_path") }.sort
|
|
380
|
+
unavailable!("readiness reachable resource union differs")
|
|
381
|
+
end
|
|
382
|
+
topology = report.fetch("resource_topology")
|
|
383
|
+
unless topology.is_a?(Array) && topology.size == resources.size &&
|
|
384
|
+
topology.map { |entry| entry.values_at("host_path", "view_path") }.sort ==
|
|
385
|
+
entries.map { |entry| entry.values_at("host_path", "view_path") }.sort
|
|
386
|
+
unavailable!("readiness resource mount topology differs")
|
|
387
|
+
end
|
|
388
|
+
resources.each do |resource|
|
|
389
|
+
declaration = entries.find { |entry| entry.values_at("host_path", "view_path") == resource.values_at("host_path", "view_path") }
|
|
390
|
+
view = topology.find { |entry| entry.values_at("host_path", "view_path") == resource.values_at("host_path", "view_path") }
|
|
391
|
+
unless view.keys.sort == %w[host_path major_minor mountpoint options root view_path] &&
|
|
392
|
+
view["options"].is_a?(Array) && view["options"].include?(declaration.fetch("read_only") ? "ro" : "rw") &&
|
|
393
|
+
view["mountpoint"].is_a?(String) && (resource.fetch("view_path") == view["mountpoint"] ||
|
|
394
|
+
resource.fetch("view_path").start_with?(view["mountpoint"].delete_suffix("/") + "/"))
|
|
395
|
+
unavailable!("readiness mount flags or projection differ")
|
|
396
|
+
end
|
|
397
|
+
host_identity = @files.resource_identity(resource.fetch("host_path"))
|
|
398
|
+
host = @files.resource_topology(resource.fetch("host_path"), mount_id: host_identity.fetch("mount_id"))
|
|
399
|
+
physical = File.expand_path(File.join(view.fetch("root"), resource.fetch("view_path").delete_prefix(view.fetch("mountpoint")).delete_prefix("/")))
|
|
400
|
+
unless host.fetch("major_minor") == view.fetch("major_minor") && host.fetch("filesystem_path") == physical
|
|
401
|
+
unavailable!("readiness mounted source or subtree differs")
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
verify_resources!(resources, same_namespace: false)
|
|
405
|
+
binding = lineage.binding
|
|
406
|
+
baseline = boot_baseline!(binding)
|
|
407
|
+
authority = @deployment.authority(@map.fetch("authority_id"))
|
|
408
|
+
endpoint = @files.authority_socket_identity(authority)
|
|
409
|
+
writable = entries.reject { |entry| entry.fetch("read_only") }.map do |entry|
|
|
410
|
+
host = @files.resource_identity(entry.fetch("host_path"))
|
|
411
|
+
@files.resource_topology(entry.fetch("host_path"), mount_id: host.fetch("mount_id"))
|
|
412
|
+
.merge("view_path" => entry.fetch("view_path"), "filesystem_type" => host.fetch("filesystem_type"))
|
|
413
|
+
end
|
|
414
|
+
Ace::Runtime::Molecules::KernelViewTopology.new.verify!(topology: report.fetch("kernel_view_topology"),
|
|
415
|
+
host_ipc: baseline.fetch("host_ipc_namespace_identity"), authority_socket: endpoint, writable_resources: writable,
|
|
416
|
+
host_devpts: baseline.fetch("host_devpts_identity"), selected_devpts: baseline.fetch("selected_devpts"))
|
|
417
|
+
unavailable!("authority endpoint changed during view verification") unless endpoint == @files.authority_socket_identity(authority)
|
|
418
|
+
report.slice("server_identity", "resource_observer_identity", "mount_namespace_identity", "resource_identities").merge(
|
|
419
|
+
"scope_generation" => binding.fetch("scope_generation"), "scope_binding_event_id" => lineage.binding_event.fetch("digest"),
|
|
420
|
+
"service_invocation_id" => @manager.inspect_activation.fetch("service").fetch("InvocationID"),
|
|
421
|
+
"workspace_id" => @map.fetch("native").fetch("workspace_id"),
|
|
422
|
+
"network_namespace_identity" => network.dup,
|
|
423
|
+
"network_admission_event_id" => lineage.admission_event.fetch("digest"))
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def complete_native_readiness!(lineage, payload)
|
|
427
|
+
unavailable!("completed activation lacks authenticated readiness") unless payload.is_a?(Hash)
|
|
428
|
+
activation = observe(lineage).fetch("activation")
|
|
429
|
+
service = activation.fetch("service")
|
|
430
|
+
unless service.fetch("ActiveState") == "active" && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"] &&
|
|
431
|
+
service.fetch("MainPID") == payload.dig("server_identity", "pid") &&
|
|
432
|
+
service.fetch("InvocationID") == payload.fetch("service_invocation_id")
|
|
433
|
+
unavailable!("native service has not completed the exact admitted activation")
|
|
434
|
+
end
|
|
435
|
+
@kernel.live!(payload.fetch("server_identity"))
|
|
436
|
+
socket = Ace::Runtime::Molecules::ProtectedSocket.socket_identity(@map.fetch("native").fetch("socket_path"))
|
|
437
|
+
value = payload.merge("socket_identity" => socket)
|
|
438
|
+
native_map = @map.merge("native" => @map.fetch("native").merge(value.slice("server_identity", "socket_identity")))
|
|
439
|
+
Ace::Herdr::Molecules::ProtectedNativeControl.new(mapping: native_map, kernel: @kernel).preflight!
|
|
440
|
+
verify_resources!(value.fetch("resource_identities"), same_namespace: false)
|
|
441
|
+
unavailable!("native activation changed during preflight") unless @manager.inspect_activation == activation
|
|
442
|
+
value
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def native_admission_ready!(lineage)
|
|
446
|
+
value = observe(lineage)
|
|
447
|
+
service = value.fetch("activation").fetch("service")
|
|
448
|
+
unless value.fetch("populated").zero? && %w[inactive failed].include?(service.fetch("ActiveState")) &&
|
|
449
|
+
service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
|
|
450
|
+
unavailable!("native admission has an occupied or pending generation")
|
|
451
|
+
end
|
|
452
|
+
binding = lineage.binding
|
|
453
|
+
selection = binding.fetch("network_installation_selection")
|
|
454
|
+
evidence = @network_evidence.verify!(selection: selection, expected: {
|
|
455
|
+
"slot_id" => @scope.fetch("slot_id"), "namespace_path" => @scope.fetch("network_namespace_path"),
|
|
456
|
+
"boot_id" => binding.fetch("boot_id"), "namespace_identity" => binding.fetch("network_namespace_identity"),
|
|
457
|
+
"installer_artifact_sha256" => selection.fetch("installer_artifact").fetch("sha256")})
|
|
458
|
+
selector = lineage.binding_event.fetch("digest")
|
|
459
|
+
if @native_workspace_lifetime
|
|
460
|
+
unavailable!("native workspace lifetime belongs to another scope") unless @native_workspace_lifetime.fetch(:selector) == selector
|
|
461
|
+
@native_workspace_lifetime.fetch(:reader).verify_unchanged!
|
|
462
|
+
else
|
|
463
|
+
reader = @workspace_reader_factory.call(projection: workspace_exclusion_projection!(lineage))
|
|
464
|
+
reader.acquire!
|
|
465
|
+
@native_workspace_lifetime = {selector: selector, reader: reader}
|
|
466
|
+
end
|
|
467
|
+
evidence
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def start_admitted_service!
|
|
471
|
+
unavailable!("native workspace lifetime is not retained") unless @native_workspace_lifetime
|
|
472
|
+
@native_workspace_lifetime.fetch(:reader).verify_unchanged!
|
|
473
|
+
@manager.start_service
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# The original source callback passes the maintained dedicated selection.
|
|
477
|
+
# It cannot choose a different slot, slice, principal or unit at launch.
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def sealed_service_stop_required?(lineage)
|
|
481
|
+
unavailable!("service stop requires the canonical original seal") unless lineage.sealed?
|
|
482
|
+
service = observe(lineage).fetch("activation").fetch("service")
|
|
483
|
+
!(%w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
|
|
484
|
+
service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def stop_sealed_service!
|
|
490
|
+
@manager.stop_service
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Read-only original owner check used before all-root retirement.
|
|
494
|
+
def verify_maintenance_closed!(lineages, retirement: nil)
|
|
495
|
+
@deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
|
|
496
|
+
activation = @manager.inspect_activation
|
|
497
|
+
service, slice = activation.values_at("service", "slice")
|
|
498
|
+
unless %w[inactive failed].include?(service.fetch("ActiveState")) &&
|
|
499
|
+
service.fetch("Job") == [0, "/"] && service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? &&
|
|
500
|
+
slice.fetch("Job") == [0, "/"]
|
|
501
|
+
unavailable!("maintenance has live or pending fixed-unit activation")
|
|
502
|
+
end
|
|
503
|
+
parent_resources!(retirement: retirement)
|
|
504
|
+
@files.worker_uid_quiescent!(@map.fetch("worker_uid"))
|
|
505
|
+
if %w[inactive failed].include?(slice.fetch("ActiveState"))
|
|
506
|
+
unless slice.fetch("InvocationID").empty? || lineages.any? { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
|
|
507
|
+
unavailable!("stopped maintenance parent is unknown")
|
|
508
|
+
end
|
|
509
|
+
unless slice.fetch("ControlGroup").empty?
|
|
510
|
+
pinned = @cgroups.pin(Ace::Runtime::Molecules::CgroupObservation::ROOT + slice.fetch("ControlGroup"))
|
|
511
|
+
unavailable!("stopped maintenance parent has writers") unless @cgroups.observe(pinned).fetch("populated").zero?
|
|
512
|
+
end
|
|
513
|
+
else
|
|
514
|
+
exact = lineages.select { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
|
|
515
|
+
unavailable!("maintenance parent has no unique canonical released owner") unless exact.size == 1
|
|
516
|
+
verify_closed!(exact.first, retirement: retirement)
|
|
517
|
+
end
|
|
518
|
+
unless @manager.inspect_activation == activation
|
|
519
|
+
unavailable!("maintenance fixed activation changed during observation")
|
|
520
|
+
end
|
|
521
|
+
true
|
|
522
|
+
ensure
|
|
523
|
+
pinned&.fetch(:handle)&.close
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def retire_released_parent!(lineages)
|
|
527
|
+
verify_maintenance_closed!(lineages)
|
|
528
|
+
@deployment.verify!(@mapping_id, kernel: @kernel, manager: @manager)
|
|
529
|
+
activation = @manager.inspect_activation
|
|
530
|
+
service = activation.fetch("service")
|
|
531
|
+
unless %w[inactive failed].include?(service.fetch("ActiveState")) && service.fetch("Job") == [0, "/"] &&
|
|
532
|
+
service.fetch("MainPID").zero? && service.fetch("ControlPID").zero?
|
|
533
|
+
unavailable!("released parent retirement has a live or pending service")
|
|
534
|
+
end
|
|
535
|
+
slice = activation.fetch("slice")
|
|
536
|
+
unless slice.fetch("Job") == [0, "/"]
|
|
537
|
+
unavailable!("released parent retirement has a pending parent job")
|
|
538
|
+
end
|
|
539
|
+
if %w[inactive failed].include?(slice.fetch("ActiveState"))
|
|
540
|
+
unless slice.fetch("InvocationID").empty? || lineages.any? { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
|
|
541
|
+
unavailable!("stopped parent metadata belongs to an unknown generation")
|
|
542
|
+
end
|
|
543
|
+
return {"state" => "already_retired"}
|
|
544
|
+
end
|
|
545
|
+
exact = lineages.select { |lineage| lineage.binding.fetch("slice_invocation_id") == slice.fetch("InvocationID") }
|
|
546
|
+
unavailable!("active parent has no unique released canonical owner") unless exact.size == 1
|
|
547
|
+
verify_closed!(exact.first)
|
|
548
|
+
@manager.stop_slice
|
|
549
|
+
after = @manager.inspect_activation
|
|
550
|
+
unless %w[inactive failed].include?(after.fetch("slice").fetch("ActiveState")) &&
|
|
551
|
+
after.fetch("slice").fetch("Job") == [0, "/"] && after.fetch("service") == service
|
|
552
|
+
unavailable!("released parent retirement outcome is unavailable")
|
|
553
|
+
end
|
|
554
|
+
{"state" => "retired", "scope_binding_event_id" => exact.first.binding_event.fetch("digest")}
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def closed_observation_for_proof!(lineage, events:)
|
|
558
|
+
value = observe(lineage)
|
|
559
|
+
service = value.fetch("activation").fetch("service")
|
|
560
|
+
unless lineage.sealed? && value.fetch("populated").zero? && %w[inactive failed].include?(service.fetch("ActiveState")) &&
|
|
561
|
+
service.fetch("MainPID").zero? && service.fetch("ControlPID").zero? && service.fetch("Job") == [0, "/"]
|
|
562
|
+
unavailable!("sealed parent still has writers or pending activation")
|
|
563
|
+
end
|
|
564
|
+
native_cleanup_complete!
|
|
565
|
+
# A stopped admitted-but-unbound start can have executed. Its proof
|
|
566
|
+
# therefore uses the same positive writer baseline as a native bound
|
|
567
|
+
# generation; absence of a binding is never a no-execution assertion.
|
|
568
|
+
if lineage.native_event
|
|
569
|
+
native = lineage.native_event.fetch("payload")
|
|
570
|
+
invocation = service.fetch("InvocationID")
|
|
571
|
+
unless invocation.empty? || invocation == native.fetch("service_invocation_id")
|
|
572
|
+
unavailable!("closed native incarnation was replaced")
|
|
573
|
+
end
|
|
574
|
+
verify_resources!(native.fetch("resource_identities"), allow_runtime_absence: true, same_namespace: false)
|
|
575
|
+
end
|
|
576
|
+
current = parent_resources!
|
|
577
|
+
unless current == lineage.binding.fetch("resource_identities") &&
|
|
578
|
+
@files.namespace_identity == lineage.binding.fetch("resource_mount_namespace_identity")
|
|
579
|
+
unavailable!("sealed parent resources or observation namespace changed")
|
|
580
|
+
end
|
|
581
|
+
@files.worker_uid_quiescent!(@map.fetch("worker_uid"))
|
|
582
|
+
native_cleanup_complete!
|
|
583
|
+
# Rejoin object and population after the credential baseline, keeping
|
|
584
|
+
# the snapshot bounded by the same retained activation and exclusion.
|
|
585
|
+
repeated = observe(lineage)
|
|
586
|
+
unless repeated.fetch("populated").zero? && repeated.fetch("activation") == value.fetch("activation")
|
|
587
|
+
unavailable!("sealed parent acquired a writer or pending activation during baseline observation")
|
|
588
|
+
end
|
|
589
|
+
proof = lineage.binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity").merge(
|
|
590
|
+
"scope_binding_event_id" => lineage.binding_event.fetch("digest"), "seal_event_id" => lineage.seal_event.fetch("digest"), "populated" => 0)
|
|
591
|
+
if @native_workspace_lifetime
|
|
592
|
+
unavailable!("closed proof differs from native workspace lifetime") unless @native_workspace_lifetime.fetch(:selector) == lineage.binding_event.fetch("digest")
|
|
593
|
+
@native_workspace_lifetime.fetch(:reader).verify_unchanged!
|
|
594
|
+
@native_workspace_lifetime.fetch(:reader).close!
|
|
595
|
+
@native_workspace_lifetime = nil
|
|
596
|
+
end
|
|
597
|
+
proof
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Declaration bytes are selected by this original observer's pinned
|
|
601
|
+
# deployment digest. A workspace cannot inherit eligibility merely
|
|
602
|
+
# because another parent resource has the same host pathname.
|
|
603
|
+
def maintenance_workspace_resource!(lineage)
|
|
604
|
+
manifest = @files.boundary_manifest(@scope)
|
|
605
|
+
declarations = manifest.fetch("resources").select { |entry| entry.fetch("host_path") == @map.fetch("worker_cwd") }
|
|
606
|
+
unless declarations.one? && declarations.first.values_at("stage", "worker_visible", "read_only") == ["parent", true, false]
|
|
607
|
+
unavailable!("workspace original declaration is not a writable parent view")
|
|
608
|
+
end
|
|
609
|
+
declaration = declarations.first
|
|
610
|
+
resources = lineage.binding.fetch("resource_identities").select do |entry|
|
|
611
|
+
entry.values_at("host_path", "view_path") == declaration.values_at("host_path", "view_path")
|
|
612
|
+
end
|
|
613
|
+
unless resources.one? && %w[mount_id device inode].all? { |key| resources.first.fetch(key).is_a?(Integer) && resources.first.fetch(key).positive? }
|
|
614
|
+
unavailable!("workspace original parent identity is unavailable")
|
|
615
|
+
end
|
|
616
|
+
resources.first
|
|
617
|
+
rescue KeyError, TypeError, ArgumentError, Ace::Runtime::RuntimeUnavailableError
|
|
618
|
+
unavailable!("workspace original boundary is unavailable")
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def maintenance_parent_resource_declarations!(lineage)
|
|
622
|
+
declarations = @files.boundary_manifest(@scope).fetch("resources").select { |entry| entry.fetch("stage") == "parent" }.map do |entry|
|
|
623
|
+
entry.slice("host_path", "view_path", "stage", "worker_visible", "read_only")
|
|
624
|
+
end
|
|
625
|
+
identities = lineage.binding.fetch("resource_identities")
|
|
626
|
+
selectors = declarations.map { |entry| entry.values_at("host_path", "view_path") }
|
|
627
|
+
bound = identities.map { |entry| entry.values_at("host_path", "view_path") }
|
|
628
|
+
unless selectors.uniq == selectors && bound.uniq == bound && selectors.sort == bound.sort &&
|
|
629
|
+
declarations.all? { |entry| entry.keys.sort == %w[host_path read_only stage view_path worker_visible] } &&
|
|
630
|
+
identities.all? { |entry| entry.keys.sort == Molecules::ExecutionScopeLineage::RESOURCE_FIELDS.sort }
|
|
631
|
+
unavailable!("original parent declarations differ from bound identities")
|
|
632
|
+
end
|
|
633
|
+
declarations
|
|
634
|
+
rescue KeyError, TypeError, ArgumentError, Ace::Runtime::RuntimeUnavailableError
|
|
635
|
+
unavailable!("original parent resource declarations are unavailable")
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def workspace_exclusion_projection!(lineage)
|
|
639
|
+
declarations = maintenance_parent_resource_declarations!(lineage)
|
|
640
|
+
cwd = maintenance_workspace_resource!(lineage)
|
|
641
|
+
authority = @deployment.authority(@map.fetch("authority_id"))
|
|
642
|
+
selection = Molecules::LifecycleExclusion.workspace_selection(mapping_id: @mapping_id,
|
|
643
|
+
project_id: @map.fetch("project_id"), authority: authority, cwd_resource: cwd)
|
|
644
|
+
matches = declarations.select do |entry|
|
|
645
|
+
entry.values_at("host_path", "view_path") == selection.values_at("host_path", "view_path")
|
|
646
|
+
end
|
|
647
|
+
roots = lineage.binding.fetch("resource_identities").select do |entry|
|
|
648
|
+
entry.values_at("host_path", "view_path") == selection.values_at("host_path", "view_path")
|
|
649
|
+
end
|
|
650
|
+
unless matches.one? && matches.first.values_at("stage", "worker_visible", "read_only") == ["parent", true, true] &&
|
|
651
|
+
roots.one? && roots.first.values_at("uid", "gid") == authority.values_at("uid", "gid")
|
|
652
|
+
unavailable!("original workspace lifecycle exclusion is unavailable")
|
|
653
|
+
end
|
|
654
|
+
value = JSON.parse(JSON.generate({"key" => selection.fetch("key"), "root_resource" => roots.first,
|
|
655
|
+
"worker_cwd_resource" => cwd, "authority_uid" => authority.fetch("uid"), "authority_gid" => authority.fetch("gid")}))
|
|
656
|
+
pending = [value]
|
|
657
|
+
until pending.empty?
|
|
658
|
+
entry = pending.pop
|
|
659
|
+
pending.concat(entry.values) if entry.is_a?(Hash)
|
|
660
|
+
pending.concat(entry) if entry.is_a?(Array)
|
|
661
|
+
entry.freeze
|
|
662
|
+
end
|
|
663
|
+
value
|
|
664
|
+
rescue KeyError, TypeError, ArgumentError
|
|
665
|
+
unavailable!("original workspace lifecycle projection differs")
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
private
|
|
669
|
+
|
|
670
|
+
def boot_baseline!(binding)
|
|
671
|
+
@boot_evidence.verify!(selection: binding.fetch("boot_baseline_selection"), expected:
|
|
672
|
+
boot_baseline_expected(binding.fetch("boot_id"), binding.fetch("deployment_digest"),
|
|
673
|
+
binding.fetch("network_installation_selection")))
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def boot_baseline_expected(boot, digest, selection)
|
|
677
|
+
{"slot_id" => @scope.fetch("slot_id"), "boot_id" => boot, "deployment_digest" => digest,
|
|
678
|
+
"installer_artifact" => selection.fetch("installer_artifact")}
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Inventory only: no configurable security booleans or alternative
|
|
682
|
+
# policy backend. Native-only objects are observed at their later stage.
|
|
683
|
+
def native_cleanup_complete!
|
|
684
|
+
@files.boundary_manifest(@scope).fetch("resources").each do |entry|
|
|
685
|
+
next unless entry.fetch("stage") == "native"
|
|
686
|
+
if @files.resource_present?(entry.fetch("host_path"))
|
|
687
|
+
unavailable!("fixed service cleanup retained a service-created resource")
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
true
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def network_selection!
|
|
694
|
+
manifest = @files.boundary_manifest(@scope)
|
|
695
|
+
@network_selection.select!(static_selection: manifest.fetch("network_installation"), slot_id: @scope.fetch("slot_id"))
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def parent_resources!(retirement: nil)
|
|
699
|
+
manifest = @files.boundary_manifest(@scope)
|
|
700
|
+
unless manifest.is_a?(Hash) && manifest.keys.sort == %w[network_installation resources schema slot_id] &&
|
|
701
|
+
manifest["schema"] == BOUNDARY_SCHEMA && manifest["slot_id"] == @scope.fetch("slot_id") &&
|
|
702
|
+
manifest["resources"].is_a?(Array) && manifest["resources"].size.between?(1, 64)
|
|
703
|
+
unavailable!("fixed boundary resource inventory differs")
|
|
704
|
+
end
|
|
705
|
+
pairs = []
|
|
706
|
+
resources = manifest.fetch("resources").filter_map do |resource|
|
|
707
|
+
unless resource.is_a?(Hash) && resource.keys.sort == %w[host_path read_only stage view_path worker_visible] &&
|
|
708
|
+
[true, false].include?(resource["worker_visible"]) && [true, false].include?(resource["read_only"]) &&
|
|
709
|
+
(resource["worker_visible"] || resource["stage"] == "parent" && resource["read_only"]) &&
|
|
710
|
+
(resource["stage"] != "native" || resource["worker_visible"]) && %w[parent native].include?(resource["stage"])
|
|
711
|
+
unavailable!("boundary resource inventory is not closed")
|
|
712
|
+
end
|
|
713
|
+
paths = resource.values_at("host_path", "view_path")
|
|
714
|
+
unless paths.all? { |path| path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") &&
|
|
715
|
+
!path.include?("\0") && File.expand_path(path) == path } && !pairs.include?(paths)
|
|
716
|
+
unavailable!("boundary resource paths are substituted or repeated")
|
|
717
|
+
end
|
|
718
|
+
pairs << paths
|
|
719
|
+
next if resource.fetch("stage") == "native"
|
|
720
|
+
actual = retirement_resource_identity(resource.fetch("host_path"), retirement) || @files.resource_identity(resource.fetch("host_path"))
|
|
721
|
+
unless LOCAL_FILESYSTEMS.include?(actual.fetch("filesystem_type"))
|
|
722
|
+
unavailable!("parent resource backing is not local kernel-managed storage")
|
|
723
|
+
end
|
|
724
|
+
resource.slice("host_path", "view_path").merge(actual)
|
|
725
|
+
end
|
|
726
|
+
unavailable!("parent resource inventory is empty") if resources.empty?
|
|
727
|
+
verify_parent_access_boundary!(resources, declarations: manifest.fetch("resources"), retirement: retirement)
|
|
728
|
+
resources
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def lifecycle_parent_resource!(resources, declarations)
|
|
732
|
+
view = "/run/ace/lifecycle-exclusion/#{@mapping_id}"
|
|
733
|
+
selected = declarations.select { |entry| entry.fetch("view_path") == view }
|
|
734
|
+
return nil if selected.empty?
|
|
735
|
+
unless selected.one? && selected.first.values_at("stage", "worker_visible", "read_only") == ["parent", true, true]
|
|
736
|
+
unavailable!("lifecycle resource is not the exact readonly parent view")
|
|
737
|
+
end
|
|
738
|
+
cwd = resources.select { |resource| resource.fetch("host_path") == @map.fetch("worker_cwd") }
|
|
739
|
+
cwd_declarations = declarations.select { |entry| entry.fetch("host_path") == @map.fetch("worker_cwd") }
|
|
740
|
+
unless cwd.one? && cwd_declarations.one? &&
|
|
741
|
+
cwd_declarations.first.values_at("stage", "worker_visible", "read_only") == ["parent", true, false]
|
|
742
|
+
unavailable!("lifecycle resource lacks original writable workspace identity")
|
|
743
|
+
end
|
|
744
|
+
authority = @deployment.authority(@map.fetch("authority_id"))
|
|
745
|
+
selection = Molecules::LifecycleExclusion.workspace_selection(mapping_id: @mapping_id,
|
|
746
|
+
project_id: @map.fetch("project_id"), authority: authority, cwd_resource: cwd.first)
|
|
747
|
+
expected = selection.fetch("host_path")
|
|
748
|
+
resource = resources.find { |entry| entry.values_at("host_path", "view_path") == [expected, view] }
|
|
749
|
+
unless selected.first.fetch("host_path") == expected && resource &&
|
|
750
|
+
resource.values_at("uid", "gid") == authority.values_at("uid", "gid")
|
|
751
|
+
unavailable!("lifecycle resource is not selected by original workspace and authority")
|
|
752
|
+
end
|
|
753
|
+
@files.lifecycle_resource_policy!(expected, authority: authority, identity: resource)
|
|
754
|
+
@files.resource_aliases(expected, identity: resource).each do |path|
|
|
755
|
+
@files.lifecycle_resource_policy!(path, authority: authority, identity: resource)
|
|
756
|
+
end
|
|
757
|
+
resource
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def verify_parent_access_boundary!(resources, declarations:, retirement: nil)
|
|
761
|
+
lifecycle = lifecycle_parent_resource!(resources, declarations)
|
|
762
|
+
resources = resources.reject { |resource| resource.equal?(lifecycle) }
|
|
763
|
+
covered = resources.select { |resource| retirement_covered_resource(resource, retirement) }
|
|
764
|
+
vanished = retirement && retirement.descriptor.fetch("phase") != "captured" ? covered : []
|
|
765
|
+
resources = resources - vanished
|
|
766
|
+
worker = @map.fetch("worker_uid")
|
|
767
|
+
authority = @deployment.authority(@map.fetch("authority_id")).fetch("uid")
|
|
768
|
+
readers = @deployment.project(@map.fetch("project_id")).fetch("supervisor_uids")
|
|
769
|
+
trusted = [worker, authority, @map.fetch("launcher_uid"), *readers].uniq
|
|
770
|
+
policies = resources.to_h do |resource|
|
|
771
|
+
path = resource.fetch("host_path")
|
|
772
|
+
observed_path = retirement_observation_path(resource, retirement)
|
|
773
|
+
policy = @files.resource_boundary_policy(observed_path)
|
|
774
|
+
unless %w[device inode uid gid].all? { |key| policy.fetch(key) == resource.fetch(key) }
|
|
775
|
+
unavailable!("parent access policy belongs to a replaced object")
|
|
776
|
+
end
|
|
777
|
+
[path, policy]
|
|
778
|
+
end
|
|
779
|
+
captured_ancestors = covered.flat_map do |resource|
|
|
780
|
+
path = retirement_observation_path(resource, retirement)
|
|
781
|
+
next [] if path == resource.fetch("host_path")
|
|
782
|
+
parts = File.dirname(path).split("/").reject(&:empty?)
|
|
783
|
+
["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }
|
|
784
|
+
end.uniq
|
|
785
|
+
captured_ancestors.each { |path| policies[path] = @files.resource_boundary_policy(path) }
|
|
786
|
+
barriers = policies.select do |_path, policy|
|
|
787
|
+
next false unless policy.fetch("uid").zero? && (policy.fetch("mode") & 0o7022).zero?
|
|
788
|
+
acl = policy.fetch("acl")
|
|
789
|
+
if acl.nil?
|
|
790
|
+
next (policy.fetch("mode") & 0o077).zero?
|
|
791
|
+
end
|
|
792
|
+
acl.all? do |tag, permissions, uid|
|
|
793
|
+
case tag
|
|
794
|
+
when 1 then permissions == 7
|
|
795
|
+
when 2 then trusted.include?(uid) && (permissions & 2).zero?
|
|
796
|
+
when 4, 8, 32 then permissions.zero?
|
|
797
|
+
when 16 then (permissions & 2).zero?
|
|
798
|
+
else false
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
end
|
|
802
|
+
unless (resources + vanished).any? { |resource| resource.fetch("uid") == worker } && resources.all? { |resource|
|
|
803
|
+
path = retirement_observation_path(resource, retirement)
|
|
804
|
+
[0, worker].include?(resource.fetch("uid")) && barriers.keys.any? { |ancestor| path == ancestor || path.start_with?(ancestor + "/") } }
|
|
805
|
+
unavailable!("parent roots lack an immutable exclusive host traversal boundary")
|
|
806
|
+
end
|
|
807
|
+
resources.each do |resource|
|
|
808
|
+
aliases = @files.resource_aliases(retirement_observation_path(resource, retirement), identity: resource)
|
|
809
|
+
unless aliases.all? { |path| barriers.keys.any? { |ancestor| path == ancestor || path.start_with?(ancestor + "/") } }
|
|
810
|
+
unavailable!("protected parent backing is exposed through an outside writable mount")
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
def verify_parent!(binding, slice)
|
|
816
|
+
expected = binding.fetch("cgroup_identity").fetch("path").delete_prefix(Ace::Runtime::Molecules::CgroupObservation::ROOT)
|
|
817
|
+
unless slice.fetch("Id") == @scope.fetch("slice_unit") && slice.fetch("ActiveState") == "active" && slice.fetch("SubState") == "active" &&
|
|
818
|
+
slice.fetch("InvocationID") == binding.fetch("slice_invocation_id") && slice.fetch("ControlGroup") == expected &&
|
|
819
|
+
slice.fetch("Job") == [0, "/"]
|
|
820
|
+
unavailable!("retained exact parent slice changed")
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
def verify_resources!(resources, allow_runtime_absence: false, same_namespace: true, retirement: nil)
|
|
825
|
+
resources.each do |resource|
|
|
826
|
+
path = resource.fetch("host_path")
|
|
827
|
+
begin
|
|
828
|
+
actual = retirement_resource_identity(path, retirement) || @files.resource_identity(path)
|
|
829
|
+
rescue Errno::ENOENT
|
|
830
|
+
runtime = @scope.fetch("runtime_directory")
|
|
831
|
+
# Fixed stopped RuntimeDirectoryPreserve=no cleanup may remove
|
|
832
|
+
# native-stage descendants. Absence never supplies the proof:
|
|
833
|
+
# parent identity/population and settled activation did above.
|
|
834
|
+
next if allow_runtime_absence && (path == runtime || path.start_with?(runtime + "/"))
|
|
835
|
+
raise
|
|
836
|
+
end
|
|
837
|
+
unless LOCAL_FILESYSTEMS.include?(actual.fetch("filesystem_type")) &&
|
|
838
|
+
(%w[device inode uid gid filesystem_type] + (same_namespace ? ["mount_id"] : [])).all? { |key| actual.fetch(key) == resource.fetch(key) }
|
|
839
|
+
unavailable!("retained resource backing object changed")
|
|
840
|
+
end
|
|
841
|
+
end
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def retirement_covered_resource(resource, retirement)
|
|
845
|
+
return nil unless retirement
|
|
846
|
+
unless retirement.is_a?(MaintenanceWorkspaceRetirement::Evidence)
|
|
847
|
+
unavailable!("maintenance retirement requires source-owned proof")
|
|
848
|
+
end
|
|
849
|
+
retirement.verify_consumption!
|
|
850
|
+
entry = retirement.covered_resource(resource.fetch("host_path"))
|
|
851
|
+
unavailable!("retirement row differs from original resource") if entry && entry.fetch("original") != resource
|
|
852
|
+
entry
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def retirement_resource_identity(path, retirement)
|
|
856
|
+
return nil unless retirement
|
|
857
|
+
unless retirement.is_a?(MaintenanceWorkspaceRetirement::Evidence)
|
|
858
|
+
unavailable!("maintenance retirement requires source-owned proof")
|
|
859
|
+
end
|
|
860
|
+
retirement.verify_consumption!
|
|
861
|
+
entry = retirement.covered_resource(path)
|
|
862
|
+
return nil unless entry
|
|
863
|
+
resource = entry.fetch("original")
|
|
864
|
+
return resource.reject { |key, _| %w[host_path view_path].include?(key) } unless retirement.descriptor.fetch("phase") == "captured"
|
|
865
|
+
observed_path = retirement_observation_path(resource, retirement)
|
|
866
|
+
actual = @files.resource_identity(observed_path)
|
|
867
|
+
unless %w[device inode uid gid mount_id filesystem_type].all? { |key| actual.fetch(key) == resource.fetch(key) }
|
|
868
|
+
unavailable!("retirement captured path differs from original resource")
|
|
869
|
+
end
|
|
870
|
+
actual
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def retirement_observation_path(resource, retirement)
|
|
874
|
+
entry = retirement_covered_resource(resource, retirement)
|
|
875
|
+
return resource.fetch("host_path") unless entry && retirement.descriptor.fetch("phase") == "captured"
|
|
876
|
+
held = retirement.owner.retirement_resource_handle!(evidence: retirement, resource: resource)
|
|
877
|
+
unless held.is_a?(Hash) && held.keys.sort == %i[captured_host_path handle].sort &&
|
|
878
|
+
held.fetch(:captured_host_path) == entry.fetch("captured_host_path")
|
|
879
|
+
unavailable!("retirement captured resource handle differs")
|
|
880
|
+
end
|
|
881
|
+
handle = held.fetch(:handle)
|
|
882
|
+
stat = handle.stat
|
|
883
|
+
unless !handle.closed? && stat.directory? && [stat.dev, stat.ino, stat.uid, stat.gid] == resource.values_at("device", "inode", "uid", "gid")
|
|
884
|
+
unavailable!("retirement captured resource is not original held inode")
|
|
885
|
+
end
|
|
886
|
+
held.fetch(:captured_host_path)
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
def canonical(value)
|
|
890
|
+
case value
|
|
891
|
+
when Hash then value.sort.to_h.transform_values { |item| canonical(item) }
|
|
892
|
+
when Array then value.map { |item| canonical(item) }
|
|
893
|
+
else value
|
|
894
|
+
end
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def unavailable!(message)
|
|
898
|
+
raise Ace::Runtime::RuntimeUnavailableError, message
|
|
899
|
+
end
|
|
900
|
+
end
|
|
901
|
+
end
|
|
902
|
+
end
|
|
903
|
+
end
|