ace-assign 0.64.0 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/CHANGELOG.md +25 -0
- data/docs/protected-authority.md +23 -7
- data/docs/usage.md +117 -4
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
- 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 +169 -2
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +156 -6
- data/lib/ace/assign/authority/deployment.rb +201 -39
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +131 -18
- 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 +3 -280
- 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 +36 -18
- 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 +221 -18
- data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
- 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 +298 -19
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
- 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 +0 -7
- data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
- data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
- data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
- 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 +5 -2
- 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/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/router.rb +24 -2
- data/lib/ace/assign/authority/server.rb +103 -33
- 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 +243 -12
- 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 +44 -12
- 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 +58 -6
- data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
- data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
- 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/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 +2 -0
- data/lib/ace/assign/cli/commands/delivery.rb +54 -4
- 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 +12 -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 +36 -5
- 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 +3 -3
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
- 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 +82 -13
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -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 +21 -15
- 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 +4 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
- 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 +4 -2
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -112
- metadata +90 -11
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lifecycle_exclusion"
|
|
4
|
+
require_relative "../authority/posix_acl"
|
|
5
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Molecules
|
|
10
|
+
class LifecycleExclusion
|
|
11
|
+
# A retained reader lease only. It cannot provision, reset or publish.
|
|
12
|
+
# Source-owned original PreparedInput selects its closed projection.
|
|
13
|
+
class WorkspaceReader
|
|
14
|
+
attr_reader :projection
|
|
15
|
+
class Protection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
|
|
16
|
+
def initialize(projection:, acl: Authority::PosixAcl.new, **options)
|
|
17
|
+
super(**options)
|
|
18
|
+
@projection, @acl = projection, acl
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def verify!(path, handle, directory:)
|
|
22
|
+
super
|
|
23
|
+
unless (handle.stat.mode & 0o7000).zero? && @acl.entries(path).nil? &&
|
|
24
|
+
(!directory || @acl.entries(path, attribute: "system.posix_acl_default").nil?)
|
|
25
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader protection differs"
|
|
26
|
+
end
|
|
27
|
+
root = @projection.fetch("root_resource")
|
|
28
|
+
if path == root_path(root)
|
|
29
|
+
actual = handle.stat
|
|
30
|
+
unless directory && [actual.dev, actual.ino, actual.uid, actual.gid, actual.mode & 0o7777] ==
|
|
31
|
+
root.values_at("device", "inode", "uid", "gid") + [0o755]
|
|
32
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader root identity differs"
|
|
33
|
+
end
|
|
34
|
+
verify_root_mount!(path, handle, root)
|
|
35
|
+
elsif !directory
|
|
36
|
+
unless handle.stat.nlink == 1 && (handle.stat.mode & 0o7777) == 0o644
|
|
37
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader file protection differs"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def root_path(root) = root.fetch("view_path")
|
|
45
|
+
|
|
46
|
+
def verify_root_mount!(path, handle, root)
|
|
47
|
+
mount = @mounts.mount_identity(handle)
|
|
48
|
+
table = Ace::Runtime::Molecules::LinuxMountInfo.new(
|
|
49
|
+
@mounts.read("/proc/self/mountinfo", limit: Ace::Runtime::Molecules::LinuxMountInfo::LIMIT))
|
|
50
|
+
selected = table.by_id(mount.fetch("mount_id"))
|
|
51
|
+
unless mount == selected.slice(*mount.keys) && selected.fetch("mountpoint") == path &&
|
|
52
|
+
selected.fetch("filesystem_type") == root.fetch("filesystem_type") &&
|
|
53
|
+
selected.fetch("options").include?("ro") && !selected.fetch("options").include?("rw")
|
|
54
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader view is not the selected readonly mount"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def trusted_owner?(stat)
|
|
59
|
+
[0, @projection.fetch("authority_uid")].include?(stat.uid)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(projection:, protection: nil, files: File)
|
|
64
|
+
@projection = JSON.parse(JSON.generate(projection))
|
|
65
|
+
validate_projection!
|
|
66
|
+
pending = [@projection]
|
|
67
|
+
until pending.empty?
|
|
68
|
+
value = pending.pop
|
|
69
|
+
pending.concat(value.values) if value.is_a?(Hash)
|
|
70
|
+
pending.concat(value) if value.is_a?(Array)
|
|
71
|
+
value.freeze
|
|
72
|
+
end
|
|
73
|
+
@root = @projection.fetch("root_resource").fetch("view_path")
|
|
74
|
+
@protection = protection || Protection.new(projection: @projection)
|
|
75
|
+
@files = files
|
|
76
|
+
@handles, @lock, @active = {}, nil, false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def acquire!
|
|
80
|
+
raise AttemptErrors::Conflict, "Lifecycle reader already acquired" if @active || !@handles.empty?
|
|
81
|
+
parts = @root.split("/").reject(&:empty?)
|
|
82
|
+
(["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }).each do |path|
|
|
83
|
+
pin!(path, directory: true)
|
|
84
|
+
end
|
|
85
|
+
@lock = pin!(path_for("lock"), directory: false)
|
|
86
|
+
stat = @lock.stat
|
|
87
|
+
unless stat.size.zero? && [stat.uid, stat.gid] == @projection.values_at("authority_uid", "authority_gid")
|
|
88
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader immutable lock differs"
|
|
89
|
+
end
|
|
90
|
+
unless @lock.flock(lock_mode | File::LOCK_NB)
|
|
91
|
+
raise AttemptErrors::MaintenanceBusy, "Lifecycle workspace is under maintenance"
|
|
92
|
+
end
|
|
93
|
+
@active = true
|
|
94
|
+
marker = pin!(path_for("state.json"), directory: false)
|
|
95
|
+
state = read_marker!(marker)
|
|
96
|
+
owner = [marker.stat.uid, marker.stat.gid]
|
|
97
|
+
unless owner == @projection.values_at("authority_uid", "authority_gid") || state.fetch("removed") && owner == [0, 0]
|
|
98
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker selected owner differs"
|
|
99
|
+
end
|
|
100
|
+
require_live_marker!(state)
|
|
101
|
+
verify_unchanged!
|
|
102
|
+
self
|
|
103
|
+
rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError, JSON::ParserError, KeyError, TypeError, ArgumentError
|
|
104
|
+
close!
|
|
105
|
+
raise AttemptErrors::EvidenceUnavailable, "Original lifecycle reader admission is unavailable"
|
|
106
|
+
rescue Exception
|
|
107
|
+
close!
|
|
108
|
+
raise
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def verify_unchanged!
|
|
112
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader is not held" unless @active
|
|
113
|
+
@handles.each do |path, entry|
|
|
114
|
+
unless identity(entry.fetch(:handle).stat) == entry.fetch(:identity) &&
|
|
115
|
+
identity(@files.lstat(path)) == entry.fetch(:identity)
|
|
116
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader retained object changed"
|
|
117
|
+
end
|
|
118
|
+
@protection.verify!(path, entry.fetch(:handle), directory: entry.fetch(:directory))
|
|
119
|
+
end
|
|
120
|
+
true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The maintained creator calls this only after confirmed completion.
|
|
124
|
+
# Unknown child ownership retains the lease until its owner exits.
|
|
125
|
+
def close!
|
|
126
|
+
begin
|
|
127
|
+
@lock.flock(File::LOCK_UN) if @active && @lock && !@lock.closed?
|
|
128
|
+
ensure
|
|
129
|
+
close_error = nil
|
|
130
|
+
@handles.values.reverse_each do |entry|
|
|
131
|
+
begin
|
|
132
|
+
entry.fetch(:handle).close unless entry.fetch(:handle).closed?
|
|
133
|
+
rescue IOError, SystemCallError => error
|
|
134
|
+
close_error ||= error
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
@handles.clear
|
|
138
|
+
@active = false
|
|
139
|
+
raise close_error if close_error
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def require_live_marker!(state)
|
|
146
|
+
raise AttemptErrors::Conflict, "Original lifecycle workspace was fenced for removal" if state.fetch("removed")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def lock_mode = File::LOCK_SH
|
|
150
|
+
|
|
151
|
+
def validate_projection!
|
|
152
|
+
fields = %w[authority_gid authority_uid key root_resource worker_cwd_resource]
|
|
153
|
+
resource_fields = %w[device filesystem_type gid host_path inode mount_id uid view_path]
|
|
154
|
+
unless @projection.is_a?(Hash)
|
|
155
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader projection differs"
|
|
156
|
+
end
|
|
157
|
+
root, cwd = @projection.values_at("root_resource", "worker_cwd_resource")
|
|
158
|
+
unless @projection.keys.sort == fields && [root, cwd].all? { |value| value.is_a?(Hash) && value.keys.sort == resource_fields } &&
|
|
159
|
+
%w[authority_uid authority_gid].all? { |key| @projection[key].is_a?(Integer) && @projection[key].positive? }
|
|
160
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader projection differs"
|
|
161
|
+
end
|
|
162
|
+
unless [root, cwd].all? { |value|
|
|
163
|
+
%w[host_path view_path].all? { |key| value[key].is_a?(String) && value[key].valid_encoding? &&
|
|
164
|
+
value[key].bytesize.between?(1, 4096) && !value[key].include?("\0") && value[key].start_with?("/") &&
|
|
165
|
+
File.expand_path(value[key]) == value[key] } &&
|
|
166
|
+
%w[device inode uid gid].all? { |key| value[key].is_a?(Integer) && value[key] >= 0 } &&
|
|
167
|
+
value["mount_id"].is_a?(Integer) && value["mount_id"].positive? &&
|
|
168
|
+
%w[ext4 xfs btrfs tmpfs].include?(value["filesystem_type"]) }
|
|
169
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader resource differs"
|
|
170
|
+
end
|
|
171
|
+
mapping = root.fetch("view_path").delete_prefix("/run/ace/lifecycle-exclusion/")
|
|
172
|
+
digest = Atoms::EvidenceDigest.digest(cwd)
|
|
173
|
+
unless mapping.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/) &&
|
|
174
|
+
@projection.fetch("key") == "workspace:#{mapping}:#{digest}" &&
|
|
175
|
+
root.fetch("host_path").end_with?("/workspaces/#{mapping}/#{digest}") &&
|
|
176
|
+
root.values_at("uid", "gid") == @projection.values_at("authority_uid", "authority_gid")
|
|
177
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle original reader association differs"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def pin!(path, directory:)
|
|
182
|
+
file = @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
183
|
+
@protection.verify!(path, file, directory: directory)
|
|
184
|
+
stat = file.stat
|
|
185
|
+
unless (directory ? stat.directory? : stat.file?) && identity(stat) == identity(@files.lstat(path))
|
|
186
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle reader object is substituted"
|
|
187
|
+
end
|
|
188
|
+
@handles[path] = {handle: file, identity: identity(stat), directory: directory}
|
|
189
|
+
file
|
|
190
|
+
ensure
|
|
191
|
+
file&.close unless @handles.key?(path)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def identity(stat)
|
|
195
|
+
core = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
|
|
196
|
+
stat.directory? ? core : core + [stat.nlink, stat.size, stat.mtime, stat.ctime]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def path_for(suffix)
|
|
200
|
+
File.join(@root, "#{Digest::SHA256.hexdigest(@projection.fetch('key'))}.#{suffix}")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def read_marker!(file)
|
|
204
|
+
size = file.stat.size
|
|
205
|
+
unless size.between?(1, 8192)
|
|
206
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker bytes differ"
|
|
207
|
+
end
|
|
208
|
+
bytes = file.read(8193).force_encoding(Encoding::UTF_8)
|
|
209
|
+
unless bytes.valid_encoding? && bytes.bytesize == size
|
|
210
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker is not bounded UTF8"
|
|
211
|
+
end
|
|
212
|
+
state = LifecycleExclusion.workspace_marker!(bytes: bytes, key: @projection.fetch("key"))
|
|
213
|
+
@marker_bytes = bytes.freeze
|
|
214
|
+
state
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
# Native activation owns this host lease for the original scope lifetime.
|
|
218
|
+
# An admission deadline must not become a provider lifetime ceiling.
|
|
219
|
+
class WorkspaceNativeReader < WorkspaceReader
|
|
220
|
+
def initialize(projection:, protection: nil, files: File)
|
|
221
|
+
super(projection: projection, protection: protection, files: files)
|
|
222
|
+
@root = @projection.fetch("root_resource").fetch("host_path")
|
|
223
|
+
@protection = protection || WorkspaceHostReader::Protection.new(projection: @projection)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
class WorkspaceHostReader < WorkspaceReader
|
|
228
|
+
class Protection < WorkspaceReader::Protection
|
|
229
|
+
private
|
|
230
|
+
def root_path(root) = root.fetch("host_path")
|
|
231
|
+
def verify_root_mount!(_path, handle, root)
|
|
232
|
+
unless @mounts.mount_identity(handle).fetch("filesystem_type") == root.fetch("filesystem_type")
|
|
233
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle writer filesystem differs"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def initialize(projection:, protection: nil, files: File)
|
|
239
|
+
super(projection: projection, protection: protection, files: files)
|
|
240
|
+
@root = @projection.fetch("root_resource").fetch("host_path")
|
|
241
|
+
@protection = protection || Protection.new(projection: @projection)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def acquire!(deadline:)
|
|
245
|
+
@deadline = deadline
|
|
246
|
+
deadline!(deadline)
|
|
247
|
+
super()
|
|
248
|
+
deadline!(deadline)
|
|
249
|
+
self
|
|
250
|
+
rescue Exception
|
|
251
|
+
close!
|
|
252
|
+
raise
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def verify_unchanged!
|
|
256
|
+
deadline!(@deadline) unless @deadline.nil?
|
|
257
|
+
super
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
def deadline!(value)
|
|
262
|
+
unless value.is_a?(Numeric) && value.finite? && value > Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
263
|
+
raise AttemptErrors::MaintenanceBusy, "Lifecycle maintenance deadline elapsed"
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Inspection evidence only: never permits creation or publication.
|
|
269
|
+
class WorkspaceFenceReader < WorkspaceHostReader
|
|
270
|
+
def fence_evidence!
|
|
271
|
+
verify_unchanged!
|
|
272
|
+
bytes = @marker_bytes
|
|
273
|
+
reference = {"path" => path_for("state.json").freeze,
|
|
274
|
+
"sha256" => Digest::SHA256.hexdigest(bytes).freeze, "bytes" => bytes.bytesize}.freeze
|
|
275
|
+
{"reference" => reference, "bytes" => bytes}.freeze
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
private
|
|
279
|
+
def require_live_marker!(_state); end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
class WorkspaceWriter < WorkspaceHostReader
|
|
283
|
+
# Refusal fence only; canonical physical proof is owned separately.
|
|
284
|
+
def publish_removed!(removed_at:)
|
|
285
|
+
verify_unchanged!
|
|
286
|
+
unless removed_at.is_a?(String) && Time.iso8601(removed_at).utc.iso8601 == removed_at
|
|
287
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle fence timestamp differs"
|
|
288
|
+
end
|
|
289
|
+
bytes = Atoms::EvidenceDigest.canonical_json({"key" => @projection.fetch("key"),
|
|
290
|
+
"removed" => true, "removed_at" => removed_at}).freeze
|
|
291
|
+
target = path_for("state.json")
|
|
292
|
+
temporary = File.join(@root, ".#{SecureRandom.hex(16)}.fence")
|
|
293
|
+
begin
|
|
294
|
+
@files.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o644) do |file|
|
|
295
|
+
file.chmod(0o644)
|
|
296
|
+
file.write(bytes)
|
|
297
|
+
file.flush
|
|
298
|
+
unless file.stat.file? && file.stat.nlink == 1 && file.stat.size == bytes.bytesize &&
|
|
299
|
+
[file.stat.uid, file.stat.gid, file.stat.mode & 0o7777] == [0, 0, 0o644]
|
|
300
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle temporary fence protection differs"
|
|
301
|
+
end
|
|
302
|
+
file.fsync
|
|
303
|
+
end
|
|
304
|
+
verify_unchanged!
|
|
305
|
+
@files.rename(temporary, target)
|
|
306
|
+
@handles.fetch(@root).fetch(:handle).fsync
|
|
307
|
+
previous = @handles.delete(target)
|
|
308
|
+
previous.fetch(:handle).close
|
|
309
|
+
marker = pin!(target, directory: false)
|
|
310
|
+
unless [marker.stat.uid, marker.stat.gid] == [0, 0] && read_marker!(marker).fetch("removed")
|
|
311
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle published fence owner differs"
|
|
312
|
+
end
|
|
313
|
+
verify_unchanged!
|
|
314
|
+
reference = {"path" => target.freeze, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
|
|
315
|
+
"bytes" => bytes.bytesize}.freeze
|
|
316
|
+
{"reference" => reference, "bytes" => bytes}.freeze
|
|
317
|
+
ensure
|
|
318
|
+
@files.unlink(temporary) if @files.exist?(temporary)
|
|
319
|
+
end
|
|
320
|
+
rescue SystemCallError, IOError, ArgumentError
|
|
321
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle removal fence publication is unavailable"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
private
|
|
325
|
+
def lock_mode = File::LOCK_EX
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_workspace_exclusion"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Molecules
|
|
8
|
+
class LifecycleExclusion
|
|
9
|
+
# Original full-privilege operator phase, before publication/start.
|
|
10
|
+
# It only provisions derived exclusion artifacts, never a workspace.
|
|
11
|
+
class WorkspaceProvisioner
|
|
12
|
+
class Protection < WorkspaceHostReader::Protection
|
|
13
|
+
private
|
|
14
|
+
def root_path(_root) = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(mapping_id:, project_id:, authority:, cwd_resource:, files: File, directories: Dir, protection: nil)
|
|
18
|
+
@selection = LifecycleExclusion.workspace_selection(mapping_id: mapping_id, project_id: project_id,
|
|
19
|
+
authority: authority, cwd_resource: cwd_resource)
|
|
20
|
+
@authority = authority.slice("state_root", "uid", "gid").freeze
|
|
21
|
+
@files, @directories = files, directories
|
|
22
|
+
@protection = protection || Protection.new(projection: {"authority_uid" => authority.fetch("uid"),
|
|
23
|
+
"authority_gid" => authority.fetch("gid"), "root_resource" => {}})
|
|
24
|
+
@handles = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def provision!
|
|
28
|
+
root = @authority.fetch("state_root")
|
|
29
|
+
parts = root.split("/").reject(&:empty?)
|
|
30
|
+
(["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }).each do |path|
|
|
31
|
+
hold_directory!(path)
|
|
32
|
+
end
|
|
33
|
+
selected_directory!(@handles.fetch(root), mode: 0o700)
|
|
34
|
+
relative = @selection.fetch("host_path").delete_prefix(root + "/").split("/")
|
|
35
|
+
parent = root
|
|
36
|
+
relative.each_with_index do |part, index|
|
|
37
|
+
path = File.join(parent, part)
|
|
38
|
+
mode = index == relative.length - 1 ? 0o755 : 0o700
|
|
39
|
+
provision_directory!(path, parent, mode)
|
|
40
|
+
verify_unchanged!
|
|
41
|
+
parent = path
|
|
42
|
+
end
|
|
43
|
+
project = File.dirname(File.dirname(File.dirname(@selection.fetch("host_path"))))
|
|
44
|
+
provision_directory!(File.join(project, "control"), project, 0o700)
|
|
45
|
+
root_stat = @handles.fetch(@selection.fetch("host_path")).stat
|
|
46
|
+
host_identity = {"device" => root_stat.dev, "inode" => root_stat.ino,
|
|
47
|
+
"uid" => root_stat.uid, "gid" => root_stat.gid}.freeze
|
|
48
|
+
# Resource mount identity is observed by the existing boundary owner;
|
|
49
|
+
# provision returns only association/host inode, not a namespace pin.
|
|
50
|
+
provision_file!("lock", "")
|
|
51
|
+
marker = provision_file!("state.json", LifecycleExclusion.workspace_initial_marker(key: @selection.fetch("key")))
|
|
52
|
+
bytes = marker.read(8193)
|
|
53
|
+
state = LifecycleExclusion.workspace_marker!(bytes: bytes, key: @selection.fetch("key"))
|
|
54
|
+
raise AttemptErrors::Conflict, "Original lifecycle workspace is already fenced" if state.fetch("removed")
|
|
55
|
+
unless [marker.stat.uid, marker.stat.gid] == @authority.values_at("uid", "gid")
|
|
56
|
+
raise AttemptErrors::EvidenceUnavailable, "Initial lifecycle marker owner differs"
|
|
57
|
+
end
|
|
58
|
+
verify_unchanged!
|
|
59
|
+
result = @selection.merge("host_identity" => host_identity)
|
|
60
|
+
result.fetch("host_identity").freeze
|
|
61
|
+
result.each_value(&:freeze)
|
|
62
|
+
result.freeze
|
|
63
|
+
rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
|
|
64
|
+
raise AttemptErrors::EvidenceUnavailable, "Original lifecycle provisioning is unavailable"
|
|
65
|
+
ensure
|
|
66
|
+
close!
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def provision_directory!(path, parent, mode)
|
|
72
|
+
created = begin
|
|
73
|
+
@directories.mkdir(path, 0o700)
|
|
74
|
+
true
|
|
75
|
+
rescue Errno::EEXIST
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
handle = hold_directory!(path)
|
|
79
|
+
if created
|
|
80
|
+
handle.chown(@authority.fetch("uid"), @authority.fetch("gid"))
|
|
81
|
+
handle.chmod(mode)
|
|
82
|
+
handle.fsync
|
|
83
|
+
@handles.fetch(parent).fsync
|
|
84
|
+
end
|
|
85
|
+
selected_directory!(handle, mode: mode)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def hold_directory!(path)
|
|
89
|
+
handle = @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
90
|
+
unless handle.stat.directory? && identity(handle.stat) == identity(@files.lstat(path))
|
|
91
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning directory differs"
|
|
92
|
+
end
|
|
93
|
+
@protection.verify!(path, handle, directory: true)
|
|
94
|
+
@handles[path] = handle
|
|
95
|
+
ensure
|
|
96
|
+
handle&.close unless @handles[path] == handle
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def selected_directory!(handle, mode:)
|
|
100
|
+
stat = handle.stat
|
|
101
|
+
unless [stat.uid, stat.gid, stat.mode & 0o7777] == @authority.values_at("uid", "gid") + [mode]
|
|
102
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning selected directory differs"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def provision_file!(suffix, bytes)
|
|
107
|
+
root = @selection.fetch("host_path")
|
|
108
|
+
path = File.join(root, "#{Digest::SHA256.hexdigest(@selection.fetch('key'))}.#{suffix}")
|
|
109
|
+
created = true
|
|
110
|
+
handle = begin
|
|
111
|
+
@files.open(path, File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o600)
|
|
112
|
+
rescue Errno::EEXIST
|
|
113
|
+
created = false
|
|
114
|
+
@files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
115
|
+
end
|
|
116
|
+
if created
|
|
117
|
+
handle.write(bytes)
|
|
118
|
+
handle.flush
|
|
119
|
+
handle.chown(@authority.fetch("uid"), @authority.fetch("gid"))
|
|
120
|
+
handle.chmod(0o644)
|
|
121
|
+
handle.fsync
|
|
122
|
+
@handles.fetch(root).fsync
|
|
123
|
+
end
|
|
124
|
+
stat = handle.stat
|
|
125
|
+
unless stat.file? && stat.nlink == 1 && (stat.mode & 0o7777) == 0o644 &&
|
|
126
|
+
identity(stat) == identity(@files.lstat(path)) && stat.size <= 8192 &&
|
|
127
|
+
(suffix != "lock" || stat.size.zero? && [stat.uid, stat.gid] == @authority.values_at("uid", "gid"))
|
|
128
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning file differs"
|
|
129
|
+
end
|
|
130
|
+
@protection.verify!(path, handle, directory: false)
|
|
131
|
+
handle.rewind
|
|
132
|
+
@handles[path] = handle
|
|
133
|
+
ensure
|
|
134
|
+
handle&.close unless @handles[path] == handle
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def verify_unchanged!
|
|
138
|
+
@handles.each do |path, handle|
|
|
139
|
+
unless identity(handle.stat) == identity(@files.lstat(path))
|
|
140
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioned object was replaced"
|
|
141
|
+
end
|
|
142
|
+
@protection.verify!(path, handle, directory: handle.stat.directory?)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def identity(stat) = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
|
|
147
|
+
|
|
148
|
+
def close!
|
|
149
|
+
error = nil
|
|
150
|
+
@handles.values.reverse_each do |handle|
|
|
151
|
+
begin
|
|
152
|
+
handle.close unless handle.closed?
|
|
153
|
+
rescue SystemCallError, IOError => caught
|
|
154
|
+
error ||= caught
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
@handles.clear
|
|
158
|
+
raise error if error
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -53,19 +53,42 @@ module Ace
|
|
|
53
53
|
end.compact
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
# Source-authenticated callers retain exact step/report bytes through
|
|
57
|
+
# validation and parsing. No pathname reopen chooses executable work.
|
|
58
|
+
def scan_captured(steps:, assignment:, reports:)
|
|
59
|
+
parsed = Atoms::StepSorter.sort(steps.keys).map do |filename|
|
|
60
|
+
path = File.join(assignment.steps_dir, filename)
|
|
61
|
+
info = Atoms::StepFileParser.parse_filename(filename)
|
|
62
|
+
report_name = Atoms::StepFileParser.generate_report_filename(info.fetch(:number), info.fetch(:name))
|
|
63
|
+
report = reports[report_name]
|
|
64
|
+
report = Atoms::StepFileParser.parse(report).fetch(:body).strip if report
|
|
65
|
+
parse_step_content(path, steps.fetch(filename), report)
|
|
66
|
+
end
|
|
67
|
+
Models::QueueState.new(steps: parsed, assignment: assignment)
|
|
68
|
+
end
|
|
69
|
+
|
|
56
70
|
private
|
|
57
71
|
|
|
58
72
|
def parse_step_file(file_path)
|
|
59
73
|
content = File.read(file_path)
|
|
74
|
+
filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
|
|
75
|
+
report = load_report(file_path, filename_info[:number], filename_info[:name])
|
|
76
|
+
parse_step_content(file_path, content, report)
|
|
77
|
+
rescue ArgumentError => e
|
|
78
|
+
warn "Invalid step file #{file_path}: #{e.message}"
|
|
79
|
+
nil
|
|
80
|
+
rescue => e
|
|
81
|
+
warn "Failed to parse step file #{file_path}: #{e.message}" if Ace::Assign.debug?
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_step_content(file_path, content, report)
|
|
60
86
|
parsed = Atoms::StepFileParser.parse(content)
|
|
61
87
|
fields = Atoms::StepFileParser.extract_fields(parsed)
|
|
62
88
|
|
|
63
89
|
# Extract number and name from filename
|
|
64
90
|
filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
|
|
65
91
|
|
|
66
|
-
# Load report from separate file if it exists
|
|
67
|
-
report = load_report(file_path, filename_info[:number], filename_info[:name])
|
|
68
|
-
|
|
69
92
|
Models::Step.new(
|
|
70
93
|
number: filename_info[:number],
|
|
71
94
|
name: fields[:name] || filename_info[:name],
|
|
@@ -93,14 +116,6 @@ module Ace
|
|
|
93
116
|
stall_reason: fields[:stall_reason],
|
|
94
117
|
file_path: file_path
|
|
95
118
|
)
|
|
96
|
-
rescue ArgumentError => e
|
|
97
|
-
# ArgumentError indicates invalid data (e.g., invalid context value)
|
|
98
|
-
# Surface these errors visibly to help users fix configuration
|
|
99
|
-
warn "Invalid step file #{file_path}: #{e.message}"
|
|
100
|
-
nil
|
|
101
|
-
rescue => e
|
|
102
|
-
warn "Failed to parse step file #{file_path}: #{e.message}" if Ace::Assign.debug?
|
|
103
|
-
nil
|
|
104
119
|
end
|
|
105
120
|
|
|
106
121
|
# Load report from the reports/ directory
|
|
@@ -24,12 +24,16 @@ module Ace
|
|
|
24
24
|
# @param identity_resolver [ExecutionIdentityResolver] Trust boundary
|
|
25
25
|
# @param artifact_reader [#call, nil] Source-owned canonical byte
|
|
26
26
|
# reader for protected composition; nil selects explicit local files
|
|
27
|
-
def initialize(identity_resolver: nil, artifact_reader: nil)
|
|
27
|
+
def initialize(identity_resolver: nil, artifact_reader: nil, campaign_verifier: nil)
|
|
28
28
|
@identity_resolver = identity_resolver || ExecutionIdentityResolver.new
|
|
29
29
|
unless artifact_reader.nil? || artifact_reader.respond_to?(:call)
|
|
30
30
|
raise ArgumentError, "artifact reader must be a source-owned callable"
|
|
31
31
|
end
|
|
32
32
|
@artifact_reader = artifact_reader
|
|
33
|
+
unless campaign_verifier.nil? || campaign_verifier.respond_to?(:call)
|
|
34
|
+
raise ArgumentError, "campaign verifier must be a source-owned callable"
|
|
35
|
+
end
|
|
36
|
+
@campaign_verifier = campaign_verifier
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
# Validate a submitted receipt against an attempt.
|
|
@@ -93,7 +97,11 @@ module Ace
|
|
|
93
97
|
# readers always reverify imported bytes, including historical reads.
|
|
94
98
|
def verify_accepted_evidence!(data, live_head:, repo_root:, historical: false)
|
|
95
99
|
verify_head(data, live_head)
|
|
96
|
-
|
|
100
|
+
artifacts = data["artifacts"]
|
|
101
|
+
reject_unless(artifacts.is_a?(Array), "accepted artifacts must be an array")
|
|
102
|
+
if (@artifact_reader || !historical) && (data["verdict"] == "succeeded" || !artifacts.empty?)
|
|
103
|
+
verify_artifacts(data, repo_root)
|
|
104
|
+
end
|
|
97
105
|
verify_checks(data)
|
|
98
106
|
verify_review(data)
|
|
99
107
|
verify_campaign(data, repo_root: repo_root, live_head: live_head) if @artifact_reader
|
|
@@ -239,20 +247,18 @@ module Ace
|
|
|
239
247
|
end
|
|
240
248
|
result = JSON.parse(artifact_bytes(data, reference, repo_root))
|
|
241
249
|
reject_unless(result.is_a?(Hash), "campaign result must be an object")
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
result["dry_run"] == false && current["accepted"] == true &&
|
|
246
|
-
result["result_identity"] == current["result_identity"] &&
|
|
247
|
-
result.dig("evidence", "current_head") == live_head && current.dig("evidence", "current_head") == live_head
|
|
248
|
-
reject("campaign result is stale, incomplete, blocked or does not match live authority")
|
|
249
|
-
end
|
|
250
|
-
approval = current["rounds"].last["approval"]
|
|
251
|
-
unless approval["producer"] == data.dig("producer", "actor") &&
|
|
252
|
-
approval["reviewer"] == data.dig("review", "reviewer", "actor") &&
|
|
253
|
-
approval["head"] == data["head"]
|
|
254
|
-
reject("campaign producer/reviewer attribution does not match independently validated receipt")
|
|
250
|
+
if @campaign_verifier
|
|
251
|
+
@campaign_verifier.call(data, result, live_head)
|
|
252
|
+
return
|
|
255
253
|
end
|
|
254
|
+
require "ace/review"
|
|
255
|
+
manager = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root)
|
|
256
|
+
current = manager.status(campaign["id"])
|
|
257
|
+
reject("campaign result ID differs from receipt") unless result["campaign_id"] == campaign["id"]
|
|
258
|
+
manager.with_verified_result!(result: result, subject: current.fetch("subject"),
|
|
259
|
+
contract_identity: current.fetch("contract_identity"), policy: current.fetch("effective_policy"),
|
|
260
|
+
head: live_head, base: current.fetch("evidence").fetch("current_base"),
|
|
261
|
+
producer: data.dig("producer", "actor"), reviewer: data.dig("review", "reviewer", "actor")) { true }
|
|
256
262
|
rescue JSON::ParserError, ArgumentError, TypeError, KeyError => e
|
|
257
263
|
reject("invalid campaign result: #{e.message}")
|
|
258
264
|
end
|