ace-assign 0.63.1 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +43 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +266 -0
- data/docs/usage.md +91 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
- data/lib/ace/assign/authority/client.rb +107 -0
- data/lib/ace/assign/authority/deployment.rb +465 -0
- data/lib/ace/assign/authority/endcap.rb +342 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
- data/lib/ace/assign/authority/endcap_results.rb +380 -0
- data/lib/ace/assign/authority/endcap_services.rb +395 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
- data/lib/ace/assign/authority/launch_driver.rb +114 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
- data/lib/ace/assign/authority/posix_acl.rb +56 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +54 -0
- data/lib/ace/assign/authority/server.rb +230 -0
- data/lib/ace/assign/authority/service_evidence.rb +55 -0
- data/lib/ace/assign/authority/transfer_codec.rb +155 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/create.rb +9 -1
- data/lib/ace/assign/cli/commands/delivery.rb +57 -0
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
- data/lib/ace/assign/cli/commands/resume.rb +26 -0
- data/lib/ace/assign/cli.rb +22 -1
- data/lib/ace/assign/models/evidence_event.rb +3 -1
- data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
- data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
- data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +13 -0
- metadata +107 -8
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../models/evidence_event"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Molecules
|
|
9
|
+
# Canonical lineage only. Live owner/boundary observation and lifecycle
|
|
10
|
+
# exclusion are mandatory before recording or consuming a fresh proof.
|
|
11
|
+
# This reader never promotes cached population into a proof event.
|
|
12
|
+
class ExecutionScopeLineage
|
|
13
|
+
BINDING_FIELDS = %w[project_id assignment_id attempt_id mapping_id slot_id reservation_generation
|
|
14
|
+
scope_generation deployment_digest boot_id slice_invocation_id cgroup_identity resource_mount_namespace_identity resource_identities network_installation_selection network_namespace_identity].freeze
|
|
15
|
+
PROOF_FIELDS = %w[scope_generation scope_binding_event_id seal_event_id boot_id slice_invocation_id
|
|
16
|
+
cgroup_identity populated].freeze
|
|
17
|
+
PROCESS_FIELDS = %w[pid uid gid groups started_at host parent_pid].freeze
|
|
18
|
+
RESOURCE_FIELDS = %w[host_path view_path mount_id filesystem_type device inode uid gid].freeze
|
|
19
|
+
CGROUP_FIELDS = %w[path mount_id filesystem_type device inode].freeze
|
|
20
|
+
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
21
|
+
DIGEST = /\A[0-9a-f]{64}\z/
|
|
22
|
+
INVOCATION = /\A[0-9a-f]{32}\z/
|
|
23
|
+
BOOT = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
24
|
+
|
|
25
|
+
def self.validate_network_selection!(selection)
|
|
26
|
+
unless selection.is_a?(Hash) && selection.keys.sort == %w[installer_artifact policy_export profile report]
|
|
27
|
+
raise AttemptErrors::EvidenceUnavailable, "network installation selection is not closed"
|
|
28
|
+
end
|
|
29
|
+
selection.each_value do |ref|
|
|
30
|
+
unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && ref["path"].is_a?(String) &&
|
|
31
|
+
ref["path"].bytesize.between?(1, 4096) && ref["path"].start_with?("/") && !ref["path"].include?("\0") &&
|
|
32
|
+
File.expand_path(ref["path"]) == ref["path"] && ref["sha256"].is_a?(String) && DIGEST.match?(ref["sha256"]) &&
|
|
33
|
+
ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 1_048_576)
|
|
34
|
+
raise AttemptErrors::EvidenceUnavailable, "network installation artifact reference differs"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
selection
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :binding_event, :native_event, :child_event, :seal_event, :proof_event, :admission_event
|
|
41
|
+
|
|
42
|
+
def initialize(events:, project_id:, assignment_id:, attempt_id:, mapping_id:)
|
|
43
|
+
events = freeze_tree(JSON.parse(JSON.generate(events)))
|
|
44
|
+
unless events.is_a?(Array) && Models::EvidenceEvent.chain_valid?(events) &&
|
|
45
|
+
events.all? { |event| event["attempt_id"] == attempt_id }
|
|
46
|
+
unavailable!("scope requires its intact exact attempt chain")
|
|
47
|
+
end
|
|
48
|
+
@expected = {"project_id" => project_id, "assignment_id" => assignment_id,
|
|
49
|
+
"attempt_id" => attempt_id, "mapping_id" => mapping_id}
|
|
50
|
+
generation = 0
|
|
51
|
+
events.each do |event|
|
|
52
|
+
generation += 1 if event["type"] == "authority_mutation"
|
|
53
|
+
accept_reservation!(event, generation) if event["type"] == "authority_mutation" &&
|
|
54
|
+
event.dig("payload", "operation") == "reserve_attempt"
|
|
55
|
+
accept_admission!(event, generation) if event["type"] == "authority_mutation" &&
|
|
56
|
+
event.dig("payload", "operation") == "scope_service_admission"
|
|
57
|
+
case event["type"]
|
|
58
|
+
when "scope_bound" then accept_binding!(event, generation + 1)
|
|
59
|
+
when "scope_native_bound" then accept_native!(event)
|
|
60
|
+
when "scope_child_bound" then accept_child!(event)
|
|
61
|
+
when "scope_sealed" then accept_seal!(event)
|
|
62
|
+
when "scope_closed_no_writers" then accept_proof!(event)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
rescue KeyError, NoMethodError, TypeError
|
|
66
|
+
unavailable!("canonical scope lineage is incomplete")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def binding
|
|
70
|
+
binding_event&.fetch("payload")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def sealed?
|
|
74
|
+
!seal_event.nil?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def proof_id
|
|
78
|
+
proof_event&.fetch("digest")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def require_open!
|
|
82
|
+
unavailable!("canonical scope binding is missing") unless binding
|
|
83
|
+
raise AttemptErrors::Conflict, "execution generation is sealed" if sealed?
|
|
84
|
+
true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def require_launch_bound!
|
|
88
|
+
require_open!
|
|
89
|
+
unavailable!("original native and child stages are missing") unless native_event && child_event
|
|
90
|
+
child_event.fetch("payload").fetch("original_process_binding")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def require_positive!(scope_generation:, scope_binding_event_id:, seal_event_id:, proof_id:)
|
|
94
|
+
unless proof_event && binding.fetch("scope_generation") == scope_generation &&
|
|
95
|
+
binding_event.fetch("digest") == scope_binding_event_id && seal_event.fetch("digest") == seal_event_id &&
|
|
96
|
+
proof_event.fetch("digest") == proof_id
|
|
97
|
+
unavailable!("exact canonical scope proof is unavailable")
|
|
98
|
+
end
|
|
99
|
+
proof_event.fetch("payload")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def accept_reservation!(event, generation)
|
|
105
|
+
unavailable!("canonical reservation is repeated") if @reservation
|
|
106
|
+
value = event.dig("payload", "data")
|
|
107
|
+
unless value.is_a?(Hash) && value.slice(*@expected.keys) == @expected &&
|
|
108
|
+
value["reservation_generation"] == generation && value["generation"] == generation &&
|
|
109
|
+
value["launch_ticket"].is_a?(String) && ID.match?(value["launch_ticket"])
|
|
110
|
+
unavailable!("canonical reservation binding differs")
|
|
111
|
+
end
|
|
112
|
+
@reservation = value
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def freeze_tree(value)
|
|
116
|
+
case value
|
|
117
|
+
when Hash then value.each { |key, item| key.freeze; freeze_tree(item) }
|
|
118
|
+
when Array then value.each { |item| freeze_tree(item) }
|
|
119
|
+
end
|
|
120
|
+
value.freeze
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def accept_binding!(event, generation)
|
|
124
|
+
unavailable!("scope binding cannot be replaced") if binding_event
|
|
125
|
+
value = event.fetch("payload")
|
|
126
|
+
exact_fields!(value, BINDING_FIELDS)
|
|
127
|
+
unless value.slice(*@expected.keys) == @expected &&
|
|
128
|
+
value["slot_id"].is_a?(String) && ID.match?(value["slot_id"]) &&
|
|
129
|
+
value["scope_generation"] == generation && @reservation &&
|
|
130
|
+
value["reservation_generation"] == @reservation["reservation_generation"] &&
|
|
131
|
+
digest?(value["deployment_digest"]) && boot?(value["boot_id"]) &&
|
|
132
|
+
invocation?(value["slice_invocation_id"])
|
|
133
|
+
unavailable!("scope generation binding differs")
|
|
134
|
+
end
|
|
135
|
+
cgroup!(value.fetch("cgroup_identity"))
|
|
136
|
+
namespace!(value.fetch("resource_mount_namespace_identity"))
|
|
137
|
+
namespace!(value.fetch("network_namespace_identity"))
|
|
138
|
+
self.class.validate_network_selection!(value.fetch("network_installation_selection"))
|
|
139
|
+
resources = value.fetch("resource_identities")
|
|
140
|
+
resources!(resources)
|
|
141
|
+
@binding_event = event
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def stage_reference!(value)
|
|
145
|
+
unavailable!("scope stage requires its original open parent") unless binding && !sealed?
|
|
146
|
+
unless value["scope_generation"] == binding.fetch("scope_generation") &&
|
|
147
|
+
value["scope_binding_event_id"] == binding_event.fetch("digest")
|
|
148
|
+
unavailable!("scope stage references another parent")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def accept_admission!(event, generation)
|
|
153
|
+
unavailable!("native admission cannot replace an existing stage") if admission_event || native_event
|
|
154
|
+
value = event.fetch("payload").fetch("data")
|
|
155
|
+
stage_reference!(value)
|
|
156
|
+
unless value.slice(*@expected.keys) == @expected && value["generation"] == generation &&
|
|
157
|
+
value["native_admission"] == "issued_uncertain"
|
|
158
|
+
unavailable!("native admission does not name the exact current reserved owner")
|
|
159
|
+
end
|
|
160
|
+
installation = value.fetch("network_installation")
|
|
161
|
+
exact_fields!(installation, %w[report_id boot_id slot_id namespace_path namespace_identity profile_sha256 policy_export_sha256 report_sha256 installer_artifact_sha256])
|
|
162
|
+
namespace!(installation.fetch("namespace_identity"))
|
|
163
|
+
selection = binding.fetch("network_installation_selection")
|
|
164
|
+
unless boot?(installation["report_id"]) && installation["boot_id"] == binding["boot_id"] &&
|
|
165
|
+
installation["slot_id"] == binding["slot_id"] && installation["namespace_identity"] == binding["network_namespace_identity"] &&
|
|
166
|
+
installation["namespace_path"].is_a?(String) && installation["namespace_path"].start_with?("/") &&
|
|
167
|
+
!installation["namespace_path"].include?("\0") && File.expand_path(installation["namespace_path"]) == installation["namespace_path"] &&
|
|
168
|
+
%w[profile policy_export report installer_artifact].all? { |key| installation["#{key}_sha256"] == selection.fetch(key).fetch("sha256") }
|
|
169
|
+
unavailable!("native admission installation differs from original parent selection")
|
|
170
|
+
end
|
|
171
|
+
@admission_event = event
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def accept_native!(event)
|
|
175
|
+
unavailable!("native binding cannot be replaced") if native_event
|
|
176
|
+
value = event.fetch("payload")
|
|
177
|
+
exact_fields!(value, %w[scope_generation scope_binding_event_id service_invocation_id server_identity socket_identity workspace_id
|
|
178
|
+
mount_namespace_identity resource_observer_identity resource_identities network_namespace_identity network_admission_event_id])
|
|
179
|
+
stage_reference!(value)
|
|
180
|
+
namespace!(value.fetch("network_namespace_identity"))
|
|
181
|
+
unless admission_event && value["network_admission_event_id"] == admission_event.fetch("digest") &&
|
|
182
|
+
value["network_namespace_identity"] == binding.fetch("network_namespace_identity")
|
|
183
|
+
unavailable!("native server does not join the original admitted network installation")
|
|
184
|
+
end
|
|
185
|
+
process!(value.fetch("server_identity"), binding.fetch("boot_id"))
|
|
186
|
+
socket = value.fetch("socket_identity")
|
|
187
|
+
unless invocation?(value["service_invocation_id"]) && value["workspace_id"].is_a?(String) &&
|
|
188
|
+
value["workspace_id"].match?(/\Aw[1-9][0-9]{0,8}\z/) && socket.is_a?(Array) && socket.size == 3 &&
|
|
189
|
+
socket.all? { |part| part.is_a?(Integer) && part >= 0 } && socket.last == value.dig("server_identity", "uid")
|
|
190
|
+
unavailable!("scope native incarnation differs")
|
|
191
|
+
end
|
|
192
|
+
namespace!(value.fetch("mount_namespace_identity"))
|
|
193
|
+
observer = value.fetch("resource_observer_identity")
|
|
194
|
+
process!(observer, binding.fetch("boot_id"))
|
|
195
|
+
server = value.fetch("server_identity")
|
|
196
|
+
unless observer.values_at("uid", "gid", "groups", "host") == server.values_at("uid", "gid", "groups", "host") &&
|
|
197
|
+
observer["pid"] != server["pid"]
|
|
198
|
+
unavailable!("native resource observer is not the distinct fixed same-user hook")
|
|
199
|
+
end
|
|
200
|
+
resources!(value.fetch("resource_identities"))
|
|
201
|
+
parent_resources = binding.fetch("resource_identities").to_h { |resource| [resource.values_at("host_path", "view_path"), resource] }
|
|
202
|
+
value.fetch("resource_identities").each do |resource|
|
|
203
|
+
original = parent_resources[resource.values_at("host_path", "view_path")]
|
|
204
|
+
if original && original.slice("device", "inode", "filesystem_type", "uid", "gid") != resource.slice("device", "inode", "filesystem_type", "uid", "gid")
|
|
205
|
+
unavailable!("native view substitutes a parent-pinned backing object")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
@native_event = event
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def accept_child!(event)
|
|
212
|
+
unavailable!("child binding requires its single native predecessor") unless native_event && !child_event
|
|
213
|
+
value = event.fetch("payload")
|
|
214
|
+
exact_fields!(value, %w[scope_generation scope_binding_event_id native_binding_event_id original_process_binding])
|
|
215
|
+
stage_reference!(value)
|
|
216
|
+
unless value["native_binding_event_id"] == native_event.fetch("digest")
|
|
217
|
+
unavailable!("child references another native incarnation")
|
|
218
|
+
end
|
|
219
|
+
native = native_event.fetch("payload")
|
|
220
|
+
original = value.fetch("original_process_binding")
|
|
221
|
+
exact_fields!(original, %w[runtime session pane terminal_id process_identity shell_identity native_origin])
|
|
222
|
+
process!(original.fetch("process_identity"), binding.fetch("boot_id"))
|
|
223
|
+
socket = native.fetch("socket_identity")
|
|
224
|
+
server = native.fetch("server_identity")
|
|
225
|
+
child = original.fetch("process_identity")
|
|
226
|
+
origin = original.fetch("native_origin")
|
|
227
|
+
exact_fields!(origin, %w[workspace tab pane server_identity socket_identity command cwd])
|
|
228
|
+
command = origin.fetch("command")
|
|
229
|
+
unless child["parent_pid"] == server["pid"] &&
|
|
230
|
+
child.values_at("uid", "gid", "groups") == server.values_at("uid", "gid", "groups") &&
|
|
231
|
+
original["runtime"] == "herdr" && original["shell_identity"] == child &&
|
|
232
|
+
original["session"] == native["workspace_id"] && origin["workspace"] == native["workspace_id"] &&
|
|
233
|
+
origin["server_identity"] == server && origin["socket_identity"] == socket &&
|
|
234
|
+
origin["pane"] == original["pane"] && %w[pane terminal_id].all? { |key| original[key].is_a?(String) && !original[key].empty? } &&
|
|
235
|
+
origin["tab"].is_a?(String) && !origin["tab"].empty? && path?(origin["cwd"]) &&
|
|
236
|
+
command.is_a?(Array) && command.size == 3 && path?(command.first) && command[1] == binding["mapping_id"] &&
|
|
237
|
+
command.last == @reservation.fetch("launch_ticket")
|
|
238
|
+
unavailable!("scope original-child lineage differs")
|
|
239
|
+
end
|
|
240
|
+
@child_event = event
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def accept_seal!(event)
|
|
244
|
+
unavailable!("scope seal has no immutable binding or is repeated") unless binding && !seal_event
|
|
245
|
+
value = event.fetch("payload")
|
|
246
|
+
exact_fields!(value, %w[scope_generation scope_binding_event_id])
|
|
247
|
+
unless value == {"scope_generation" => binding.fetch("scope_generation"),
|
|
248
|
+
"scope_binding_event_id" => binding_event.fetch("digest")}
|
|
249
|
+
unavailable!("scope seal references another incarnation")
|
|
250
|
+
end
|
|
251
|
+
@seal_event = event
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def accept_proof!(event)
|
|
255
|
+
unavailable!("scope proof requires its original seal and single observation") unless sealed? && !proof_event
|
|
256
|
+
value = event.fetch("payload")
|
|
257
|
+
exact_fields!(value, PROOF_FIELDS)
|
|
258
|
+
expected = binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity")
|
|
259
|
+
expected.merge!("scope_binding_event_id" => binding_event.fetch("digest"),
|
|
260
|
+
"seal_event_id" => seal_event.fetch("digest"), "populated" => 0)
|
|
261
|
+
unavailable!("scope proof is not exact sealed empty population") unless value == expected
|
|
262
|
+
@proof_event = event
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def namespace!(value)
|
|
266
|
+
exact_fields!(value, %w[device inode])
|
|
267
|
+
unless value["device"].is_a?(Integer) && value["device"] >= 0 && positive_integer?(value["inode"])
|
|
268
|
+
unavailable!("scope mount namespace identity is malformed")
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def resources!(resources)
|
|
273
|
+
unless resources.is_a?(Array) && resources.size <= 64 &&
|
|
274
|
+
resources.all? { |resource| resource.is_a?(Hash) } &&
|
|
275
|
+
resources.map { |resource| resource.values_at("host_path", "view_path") }.uniq.size == resources.size &&
|
|
276
|
+
resources.map { |resource| resource["view_path"] }.uniq.size == resources.size
|
|
277
|
+
unavailable!("scope resource observations are duplicate or oversized")
|
|
278
|
+
end
|
|
279
|
+
resources.each do |resource|
|
|
280
|
+
exact_fields!(resource, RESOURCE_FIELDS)
|
|
281
|
+
unless path?(resource["host_path"]) && path?(resource["view_path"]) && positive_integer?(resource["mount_id"]) &&
|
|
282
|
+
%w[device inode uid gid].all? { |key| resource[key].is_a?(Integer) && resource[key] >= 0 } &&
|
|
283
|
+
resource["filesystem_type"].is_a?(String) && resource["filesystem_type"].bytesize.between?(1, 64) &&
|
|
284
|
+
resource["filesystem_type"].ascii_only? && !resource["filesystem_type"].match?(/[\s\0]/)
|
|
285
|
+
unavailable!("scope resource identity is malformed")
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def cgroup!(value)
|
|
291
|
+
exact_fields!(value, CGROUP_FIELDS)
|
|
292
|
+
unless path?(value["path"]) && value["path"].start_with?("/sys/fs/cgroup/") &&
|
|
293
|
+
value["filesystem_type"] == "cgroup2" &&
|
|
294
|
+
%w[mount_id device inode].all? { |key| value[key].is_a?(Integer) && value[key] >= 0 }
|
|
295
|
+
unavailable!("scope parent object identity is malformed")
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def process!(value, boot_id)
|
|
300
|
+
exact_fields!(value, PROCESS_FIELDS)
|
|
301
|
+
groups = value["groups"]
|
|
302
|
+
unless %w[pid uid gid parent_pid].all? { |key| positive_integer?(value[key]) } &&
|
|
303
|
+
groups.is_a?(Array) && groups.all? { |id| positive_integer?(id) } && groups == groups.sort.uniq &&
|
|
304
|
+
value["started_at"].is_a?(String) && value["started_at"].match?(/\Alinux:#{Regexp.escape(boot_id)}:[0-9]+\z/) &&
|
|
305
|
+
value["host"].is_a?(String) && !value["host"].empty?
|
|
306
|
+
unavailable!("scope process birth/credentials are malformed")
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def exact_fields!(value, keys)
|
|
311
|
+
unavailable!("scope event schema differs") unless value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def path?(value)
|
|
315
|
+
value.is_a?(String) && value.bytesize.between?(2, 4096) && !value.include?("\0") &&
|
|
316
|
+
value.start_with?("/") && File.expand_path(value) == value
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def positive_integer?(value) = value.is_a?(Integer) && value.positive?
|
|
320
|
+
def digest?(value) = value.is_a?(String) && DIGEST.match?(value)
|
|
321
|
+
def boot?(value) = value.is_a?(String) && BOOT.match?(value)
|
|
322
|
+
def invocation?(value) = value.is_a?(String) && INVOCATION.match?(value)
|
|
323
|
+
def unavailable!(message) = raise(AttemptErrors::EvidenceUnavailable, message)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Molecules
|
|
9
|
+
# Protected authority mutations share the execution ref's lock and CAS.
|
|
10
|
+
# Replies live in chained attempt events, rather than an RPC ledger.
|
|
11
|
+
module JournalMutation
|
|
12
|
+
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
13
|
+
|
|
14
|
+
# Shared canonical projection used by admission and protected status.
|
|
15
|
+
# Callers hold the same owner exclusion when projecting current events.
|
|
16
|
+
def authority_generation(events)
|
|
17
|
+
unless Models::EvidenceEvent.chain_valid?(events)
|
|
18
|
+
raise AttemptErrors::EvidenceUnavailable, "Attempt event chain is corrupt"
|
|
19
|
+
end
|
|
20
|
+
events.count { |event| event["type"] == "authority_mutation" }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def canonical_attempt_state(events)
|
|
24
|
+
authority_generation(events)
|
|
25
|
+
derive_state(events)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The block runs against the current ref on every CAS attempt. It
|
|
29
|
+
# returns events [{type:, payload:}], immutable blobs, and public data.
|
|
30
|
+
# Only the authority calls this internal journal API; wire requests
|
|
31
|
+
# cannot select blob paths, event types, or accepted response data.
|
|
32
|
+
def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected)
|
|
33
|
+
unless (generation_mode == :expected && expected_generation.is_a?(Integer) && expected_generation >= 0) ||
|
|
34
|
+
(generation_mode == :recorded_completion && operation == "complete_service" && expected_generation.nil?)
|
|
35
|
+
raise ArgumentError, "invalid fixed mutation generation mode"
|
|
36
|
+
end
|
|
37
|
+
[assignment_id, attempt_id, mutation_id].each { |id| validate_mutation_id!(id) }
|
|
38
|
+
unless parameters_digest.is_a?(String) && parameters_digest.match?(/\A[0-9a-f]{64}\z/)
|
|
39
|
+
raise ArgumentError, "invalid mutation parameter digest"
|
|
40
|
+
end
|
|
41
|
+
with_lock do
|
|
42
|
+
self.class::CAS_ATTEMPTS.times do
|
|
43
|
+
old = ref_value
|
|
44
|
+
ensure_checkout!
|
|
45
|
+
old = ref_value if old.nil?
|
|
46
|
+
sync_checkout(old)
|
|
47
|
+
replay = mutation_result(mutation_id)
|
|
48
|
+
if replay
|
|
49
|
+
unless replay["operation"] == operation && replay["parameters_digest"] == parameters_digest &&
|
|
50
|
+
replay["assignment_id"] == assignment_id && replay["attempt_id"] == attempt_id
|
|
51
|
+
raise AttemptErrors::Conflict, "Mutation ID is already bound to different input"
|
|
52
|
+
end
|
|
53
|
+
result = replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit"))
|
|
54
|
+
return with_replay ? {data: result, replayed: true} : result
|
|
55
|
+
end
|
|
56
|
+
current = read_events(assignment_id).select { |event| event["attempt_id"] == attempt_id }
|
|
57
|
+
generation = authority_generation(current)
|
|
58
|
+
unless generation_mode == :recorded_completion || expected_generation == generation
|
|
59
|
+
raise AttemptErrors::Conflict, "Authority registration generation changed"
|
|
60
|
+
end
|
|
61
|
+
plan = yield(current, old, generation)
|
|
62
|
+
blobs = plan.fetch(:blobs, {})
|
|
63
|
+
events = chain_mutation_events(attempt_id, current.last&.fetch("digest"), plan.fetch(:events, []))
|
|
64
|
+
updates = prepare_service_updates(plan.fetch(:service_updates, []), assignment_id: assignment_id,
|
|
65
|
+
attempt_id: attempt_id, pending: {current_events: current, pending_events: events, blobs: blobs, commit: old,
|
|
66
|
+
service_inputs: service_input_context(plan)})
|
|
67
|
+
events.concat(chain_mutation_events(attempt_id, events.last&.fetch("digest") || current.last&.fetch("digest"),
|
|
68
|
+
updates.map { |prepared| prepared.fetch(:event) }))
|
|
69
|
+
data = plan.fetch(:data).merge("generation" => generation + 1)
|
|
70
|
+
receipt = Models::EvidenceEvent.build(type: "authority_mutation", attempt_id: attempt_id,
|
|
71
|
+
previous_digest: events.last&.fetch("digest") || current.last&.fetch("digest"),
|
|
72
|
+
payload: {"mutation_id" => mutation_id, "operation" => operation,
|
|
73
|
+
"parameters_digest" => parameters_digest, "assignment_id" => assignment_id,
|
|
74
|
+
"attempt_id" => attempt_id, "data" => data})
|
|
75
|
+
events << receipt
|
|
76
|
+
write_mutation_blobs(blobs, commit: old)
|
|
77
|
+
write_service_records(updates)
|
|
78
|
+
write_event_files(assignment_id, events)
|
|
79
|
+
git!("-C", checkout_dir, "add", "--", "execution/#{assignment_id}")
|
|
80
|
+
stage_mutation_blobs(blobs)
|
|
81
|
+
stage_service_records(updates)
|
|
82
|
+
git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
83
|
+
"-c", "core.hooksPath=/dev/null", "commit", "-m", "evidence: authority #{operation} #{mutation_id}")
|
|
84
|
+
commit = git!("-C", checkout_dir, "rev-parse", "HEAD").first
|
|
85
|
+
if update_ref_cas(commit, old)
|
|
86
|
+
result = data.merge("journal_commit" => commit)
|
|
87
|
+
return with_replay ? {data: result, replayed: false} : result
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
raise AttemptErrors::EvidenceUnavailable, "Authority mutation ref stayed conflicting"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mutation_result(mutation_id)
|
|
95
|
+
validate_mutation_id!(mutation_id)
|
|
96
|
+
assignment_ids.each do |assignment_id|
|
|
97
|
+
events = read_events(assignment_id)
|
|
98
|
+
events.group_by { |event| event["attempt_id"] }.each_value do |chain|
|
|
99
|
+
unless Models::EvidenceEvent.chain_valid?(chain)
|
|
100
|
+
raise AttemptErrors::EvidenceUnavailable, "Authority mutation event chain is corrupt"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
event = events.find do |entry|
|
|
104
|
+
entry["type"] == "authority_mutation" && entry.dig("payload", "mutation_id") == mutation_id
|
|
105
|
+
end
|
|
106
|
+
next unless event
|
|
107
|
+
|
|
108
|
+
path = "execution/#{assignment_id}/events/#{event_filename(event)}"
|
|
109
|
+
commit = git!("log", "--diff-filter=A", "-1", "--format=%H", ref_value, "--", path).first
|
|
110
|
+
raise AttemptErrors::EvidenceUnavailable, "Mutation acceptance commit is missing" if commit.empty?
|
|
111
|
+
return event.fetch("payload").merge("journal_commit" => commit)
|
|
112
|
+
end
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Source composition supplies original body bytes only for the current
|
|
117
|
+
# service CAS callback. They are never part of persisted mutation data.
|
|
118
|
+
def service_input_context(plan)
|
|
119
|
+
inputs = plan.fetch(:service_inputs, {})
|
|
120
|
+
raise ArgumentError, "invalid ephemeral service input context" unless inputs.is_a?(Hash)
|
|
121
|
+
return {}.freeze if inputs.empty?
|
|
122
|
+
updates = plan.fetch(:service_updates, [])
|
|
123
|
+
unless inputs.size == 1 && updates.size == 1 && inputs.keys == [updates.first.fetch(:request_id)] &&
|
|
124
|
+
inputs.values.first.is_a?(String) && inputs.values.first.bytesize.between?(1, 64 * 1024)
|
|
125
|
+
raise ArgumentError, "invalid ephemeral service input context"
|
|
126
|
+
end
|
|
127
|
+
inputs.to_h { |key, bytes| [key.dup.freeze, bytes.dup.freeze] }.freeze
|
|
128
|
+
end
|
|
129
|
+
private :service_input_context
|
|
130
|
+
|
|
131
|
+
# Read immutable bytes directly from a selected canonical ref commit.
|
|
132
|
+
# Worktree projections are never consulted for protected evidence.
|
|
133
|
+
def blob(path, commit: ref_value)
|
|
134
|
+
validate_blob_path!(path)
|
|
135
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical evidence ref is missing" unless commit
|
|
136
|
+
out, _error, status = Open3.capture3("git", "show", "#{commit}:#{path}",
|
|
137
|
+
chdir: repo_root, stdin_data: "")
|
|
138
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical evidence blob is missing" unless status.success?
|
|
139
|
+
out.b
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def validate_mutation_id!(id)
|
|
145
|
+
raise ArgumentError, "invalid authority ID" unless id.is_a?(String) && id.match?(ID)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_blob_path!(path)
|
|
149
|
+
unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
|
|
150
|
+
!%w[. ..].include?(File.basename(path))
|
|
151
|
+
raise ArgumentError, "invalid canonical blob reference"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def chain_mutation_events(attempt_id, previous, entries)
|
|
156
|
+
entries.map do |entry|
|
|
157
|
+
recorded_at = entry.fetch(:recorded_at) { Time.now.utc }
|
|
158
|
+
raise ArgumentError, "internal event timestamp must be a UTC Time" unless recorded_at.is_a?(Time) && recorded_at.utc?
|
|
159
|
+
event = Models::EvidenceEvent.build(type: entry.fetch(:type), attempt_id: attempt_id,
|
|
160
|
+
payload: entry.fetch(:payload), previous_digest: previous, recorded_at: recorded_at)
|
|
161
|
+
previous = event.fetch("digest")
|
|
162
|
+
event
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Git clean/smudge filters and CRLF conversion cannot redefine accepted
|
|
167
|
+
# evidence bytes. Insert unfiltered objects after staging the events.
|
|
168
|
+
def stage_mutation_blobs(blobs)
|
|
169
|
+
blobs.each do |path, bytes|
|
|
170
|
+
oid, error, status = Open3.capture3("git", "hash-object", "-w", "--stdin", "--no-filters",
|
|
171
|
+
chdir: repo_root, stdin_data: bytes.b)
|
|
172
|
+
unless status.success?
|
|
173
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot write canonical evidence object: #{error.strip}"
|
|
174
|
+
end
|
|
175
|
+
git!("-C", checkout_dir, "update-index", "--add", "--cacheinfo", "100644,#{oid.strip},#{path}")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def write_mutation_blobs(blobs, commit:)
|
|
180
|
+
blobs.map do |path, bytes|
|
|
181
|
+
validate_blob_path!(path)
|
|
182
|
+
raise ArgumentError, "canonical blob must contain bytes" unless bytes.is_a?(String)
|
|
183
|
+
target = File.join(checkout_dir, path)
|
|
184
|
+
if File.exist?(target)
|
|
185
|
+
# A checkout may have smudged the file; immutability is checked
|
|
186
|
+
# against accepted bytes at the canonical commit, not projection.
|
|
187
|
+
unless blob(path, commit: commit) == bytes.b
|
|
188
|
+
raise AttemptErrors::Conflict, "Canonical evidence blob is immutable"
|
|
189
|
+
end
|
|
190
|
+
else
|
|
191
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
192
|
+
File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
|
|
193
|
+
file.write(bytes.b)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
path
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -54,6 +54,10 @@ module Ace
|
|
|
54
54
|
"assignment:#{assignment_id}"
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
def slot_key(slot_id)
|
|
58
|
+
"execution-slot:#{slot_id}"
|
|
59
|
+
end
|
|
60
|
+
|
|
57
61
|
# Task identity is the canonical worktree identity: work-on derives
|
|
58
62
|
# the worktree path from the task ref, and prune candidates carry
|
|
59
63
|
# the same task id.
|