ace-assign 0.69.0 → 0.71.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/presets/deliver-release.yml +11 -8
- data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
- data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
- data/CHANGELOG.md +27 -0
- data/handbook/skills/as-assign-drive/SKILL.md +5 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
- data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
- data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
- data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
- data/lib/ace/assign/authority/assignment_definition.rb +35 -0
- data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
- data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +57 -27
- data/lib/ace/assign/authority/command_execution.rb +180 -0
- data/lib/ace/assign/authority/endcap.rb +4 -2
- data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
- data/lib/ace/assign/authority/endcap_publication.rb +0 -166
- data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
- data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
- data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
- data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
- data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
- data/lib/ace/assign/authority/ordinary_client.rb +70 -0
- data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
- data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
- data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
- data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
- data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
- data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
- data/lib/ace/assign/authority/ordinary_results.rb +206 -0
- data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
- data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
- data/lib/ace/assign/authority/ordinary_server.rb +75 -0
- data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
- data/lib/ace/assign/authority/prepared_work.rb +9 -4
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
- data/lib/ace/assign/authority/retained_input.rb +53 -0
- data/lib/ace/assign/authority/router.rb +11 -6
- data/lib/ace/assign/authority/server.rb +11 -180
- data/lib/ace/assign/authority/socket_server.rb +255 -0
- data/lib/ace/assign/authority/transfer_codec.rb +5 -3
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
- data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
- data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
- data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
- data/lib/ace/assign/organisms/attempt_coordinator.rb +88 -36
- data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +39 -10
|
@@ -1,129 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
2
|
+
|
|
4
3
|
require_relative "deployment"
|
|
5
|
-
require_relative "
|
|
6
|
-
require_relative "../molecules/evidence_journal"
|
|
4
|
+
require_relative "socket_server"
|
|
7
5
|
|
|
8
6
|
module Ace
|
|
9
7
|
module Assign
|
|
10
8
|
module Authority
|
|
11
|
-
#
|
|
12
|
-
class Server
|
|
13
|
-
CONNECTIONS = 16
|
|
9
|
+
# Protected admission is mandatory for this source-selected composition.
|
|
10
|
+
class Server < SocketServer
|
|
14
11
|
def initialize(authority_id:, lifecycle:, deployment: Deployment.load,
|
|
15
|
-
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, composition: "launch",
|
|
16
|
-
@authority_id, @
|
|
17
|
-
@composition = composition
|
|
18
|
-
@hitl_service = hitl_service
|
|
12
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, composition: "launch", services: [])
|
|
13
|
+
@authority_id, @deployment, @kernel, @composition = authority_id, deployment, kernel, composition
|
|
19
14
|
deployment.verify_composition!(authority_id, composition: composition)
|
|
20
|
-
|
|
21
|
-
@mutex = Mutex.new
|
|
22
|
-
@connections = []
|
|
23
|
-
@handlers = []
|
|
24
|
-
@transfers = 0
|
|
25
|
-
@stopped = false
|
|
15
|
+
super(service: deployment.authority(authority_id), lifecycle: lifecycle, services: services)
|
|
26
16
|
end
|
|
27
17
|
|
|
28
|
-
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def verify_listener!
|
|
29
21
|
@kernel.supported!
|
|
30
22
|
me = @kernel.capture(Process.pid)
|
|
31
23
|
unless principal?(me, @service, "uid", "gid", "groups")
|
|
32
24
|
raise AttemptErrors::UnauthorizedIdentity, "listener principal differs from installed authority"
|
|
33
25
|
end
|
|
34
26
|
@deployment.verify_receiver_paths!(@authority_id)
|
|
35
|
-
directory = File.dirname(@service.fetch("socket_path"))
|
|
36
|
-
wire.root_path!(directory, directory: true, owner: @service.fetch("uid"))
|
|
37
|
-
lock = File.open(File.join(directory, "listener.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
38
|
-
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
39
|
-
raise AttemptErrors::Conflict, "assignment authority already owns its listener"
|
|
40
|
-
end
|
|
41
|
-
start_hitl_service!
|
|
42
|
-
path = @service.fetch("socket_path")
|
|
43
|
-
if File.exist?(path) || File.symlink?(path)
|
|
44
|
-
raise AttemptErrors::Conflict, "existing authority endpoint requires verified installer recovery"
|
|
45
|
-
end
|
|
46
|
-
@listener = UNIXServer.new(path)
|
|
47
|
-
File.chmod(0o660, path)
|
|
48
|
-
@endpoint = wire.socket_identity(path)
|
|
49
|
-
until @stopped
|
|
50
|
-
if @hitl_thread && !@hitl_thread.alive?
|
|
51
|
-
raise Ace::Runtime::RuntimeUnavailableError, "protected HITL boundary stopped before its authority"
|
|
52
|
-
end
|
|
53
|
-
ready = IO.select([@listener], nil, nil, 0.25)
|
|
54
|
-
next unless ready
|
|
55
|
-
socket = @listener.accept_nonblock(exception: false)
|
|
56
|
-
next if socket == :wait_readable
|
|
57
|
-
admitted = @mutex.synchronize do
|
|
58
|
-
if @connections.size < CONNECTIONS
|
|
59
|
-
@connections << socket
|
|
60
|
-
true
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
unless admitted
|
|
64
|
-
refusal(socket, "busy")
|
|
65
|
-
socket.close
|
|
66
|
-
next
|
|
67
|
-
end
|
|
68
|
-
@mutex.synchronize do
|
|
69
|
-
@handlers.reject! { |handler| !handler.alive? }
|
|
70
|
-
@handlers << Thread.new(socket) { |connection| receive(connection) }
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
rescue IOError, Errno::EBADF
|
|
74
|
-
raise unless @stopped
|
|
75
|
-
ensure
|
|
76
|
-
stop
|
|
77
|
-
stop_hitl_service!
|
|
78
|
-
# Closing a stream does not cancel a handler already inside dispatch.
|
|
79
|
-
# The owner lock and endpoint remain held until every handler ends.
|
|
80
|
-
@mutex.synchronize { @handlers.dup }.each(&:join)
|
|
81
|
-
@lifecycle.close if @endpoint && @lifecycle.respond_to?(:close)
|
|
82
|
-
if @endpoint && same_endpoint?(path)
|
|
83
|
-
File.unlink(path)
|
|
84
|
-
end
|
|
85
|
-
lock&.close
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def request_stop
|
|
89
|
-
@stopped = true
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def stop
|
|
93
|
-
@stopped = true
|
|
94
|
-
@listener&.close unless @listener&.closed?
|
|
95
|
-
@mutex.synchronize { @connections.each { |socket| socket.close unless socket.closed? } }
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
private
|
|
99
|
-
|
|
100
|
-
# The existing HITL boundary is a sibling owned by this listener's
|
|
101
|
-
# lifetime. It must be ready before assignment ingress is opened.
|
|
102
|
-
def start_hitl_service!
|
|
103
|
-
return unless @hitl_service
|
|
104
|
-
ready = Queue.new
|
|
105
|
-
@hitl_thread = Thread.new do
|
|
106
|
-
@hitl_service.run(on_ready: -> { ready << :ready })
|
|
107
|
-
rescue Exception => error
|
|
108
|
-
@hitl_failure = error
|
|
109
|
-
ready << error
|
|
110
|
-
end
|
|
111
|
-
@hitl_thread.report_on_exception = false
|
|
112
|
-
result = ready.pop(timeout: 5)
|
|
113
|
-
raise Ace::Runtime::RuntimeUnavailableError, "protected HITL boundary startup timed out" if result.nil?
|
|
114
|
-
raise result if result.is_a?(Exception)
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def stop_hitl_service!
|
|
118
|
-
return unless @hitl_thread
|
|
119
|
-
@hitl_service.stop
|
|
120
|
-
@hitl_thread.join
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def same_endpoint?(path)
|
|
124
|
-
wire.socket_identity(path) == @endpoint
|
|
125
|
-
rescue StandardError
|
|
126
|
-
false
|
|
127
27
|
end
|
|
128
28
|
|
|
129
29
|
def receive(socket)
|
|
@@ -200,40 +100,7 @@ module Ace
|
|
|
200
100
|
raise AttemptErrors::UnauthorizedIdentity, "gate readiness requires worker peer" unless role == :worker
|
|
201
101
|
@lifecycle.gate_ready(request: request, peer: peer, socket: socket, deadline: deadline)
|
|
202
102
|
else
|
|
203
|
-
|
|
204
|
-
binding = @lifecycle.transfer_binding(request) if @lifecycle.respond_to?(:transfer_binding)
|
|
205
|
-
if binding
|
|
206
|
-
raise AttemptErrors::UnauthorizedIdentity, "transfer role differs" unless binding.fetch(:roles).include?(role)
|
|
207
|
-
raise ArgumentError, "transfer control header is oversized" if frame.fetch(:bytesize) > 16_384
|
|
208
|
-
@lifecycle.authorize_transfer!(request: request, peer: peer, role: role)
|
|
209
|
-
admitted_transfer = @mutex.synchronize do
|
|
210
|
-
if @transfers < 2
|
|
211
|
-
@transfers += 1
|
|
212
|
-
true
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
raise AttemptErrors::Conflict, "transfer capacity is busy" unless admitted_transfer
|
|
216
|
-
codec = transfer_codec
|
|
217
|
-
if binding.fetch(:direction) == :upload
|
|
218
|
-
result = codec.receive(socket, descriptor: params.fetch("transfer"), purpose: binding.fetch(:purpose), deadline: deadline) do |input|
|
|
219
|
-
@lifecycle.dispatch(request: request, peer: peer, role: role, transfer: input)
|
|
220
|
-
end
|
|
221
|
-
else
|
|
222
|
-
result = @lifecycle.dispatch(request: request, peer: peer, role: role)
|
|
223
|
-
parts = result.fetch(:transfer_parts)
|
|
224
|
-
descriptor = codec.descriptor(parts, purpose: binding.fetch(:purpose))
|
|
225
|
-
result = result.merge(data: result.fetch(:data).merge("transfer" => descriptor))
|
|
226
|
-
end
|
|
227
|
-
else
|
|
228
|
-
raise ArgumentError, "unexpected transfer descriptor" if params.key?("transfer")
|
|
229
|
-
result = @lifecycle.dispatch(request: request, peer: peer, role: role)
|
|
230
|
-
end
|
|
231
|
-
wire.write(socket, {"status" => "ok", "data" => result.fetch(:data),
|
|
232
|
-
"transport" => {"replayed" => result.fetch(:replayed, false)}}, deadline: deadline, limit: 16_384)
|
|
233
|
-
if binding && binding.fetch(:direction) == :download
|
|
234
|
-
codec.send(socket, parts: parts, descriptor: descriptor, purpose: binding.fetch(:purpose), deadline: deadline)
|
|
235
|
-
end
|
|
236
|
-
end
|
|
103
|
+
dispatch_request(socket, request, peer, role, deadline, frame_bytes: frame.fetch(:bytesize))
|
|
237
104
|
end
|
|
238
105
|
rescue ArgumentError, KeyError, AttemptErrors::MalformedTransfer
|
|
239
106
|
refusal(socket, "invalid_input")
|
|
@@ -255,45 +122,9 @@ module Ace
|
|
|
255
122
|
end
|
|
256
123
|
end
|
|
257
124
|
|
|
258
|
-
# These closed read operations have no request body. Half-close proves
|
|
259
|
-
# framing completion before status or artifact data can be exposed.
|
|
260
|
-
def bodyless_read!(socket, deadline)
|
|
261
|
-
loop do
|
|
262
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
263
|
-
raise ArgumentError, "read request framing is incomplete" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
264
|
-
bytes = socket.read_nonblock(1, exception: false)
|
|
265
|
-
next if bytes == :wait_readable
|
|
266
|
-
raise ArgumentError, "unexpected read request body" unless bytes.nil?
|
|
267
|
-
return
|
|
268
|
-
end
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
def transfer_codec
|
|
272
|
-
@mutex.synchronize do
|
|
273
|
-
@transfer_codec ||= begin
|
|
274
|
-
root = @service.fetch("state_root")
|
|
275
|
-
PrivateDirectory.verify!(root)
|
|
276
|
-
spool = File.join(root, "transfers")
|
|
277
|
-
Dir.mkdir(spool, 0o700) unless File.exist?(spool) || File.symlink?(spool)
|
|
278
|
-
TransferCodec.new(root: spool)
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
end
|
|
282
|
-
|
|
283
125
|
def principal?(identity, mapping, uid, gid, groups)
|
|
284
126
|
identity["uid"] == mapping[uid] && identity["gid"] == mapping[gid] && identity["groups"] == mapping[groups]
|
|
285
127
|
end
|
|
286
|
-
|
|
287
|
-
def refusal(socket, code)
|
|
288
|
-
wire.write(socket, {"status" => "error", "error" => {"code" => code, "message" => "Protected authority refused #{code}"}},
|
|
289
|
-
deadline: wire.deadline(0.1))
|
|
290
|
-
rescue StandardError
|
|
291
|
-
nil
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
def wire
|
|
295
|
-
Ace::Runtime::Molecules::ProtectedSocket
|
|
296
|
-
end
|
|
297
128
|
end
|
|
298
129
|
end
|
|
299
130
|
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "transfer_codec"
|
|
6
|
+
require_relative "../molecules/evidence_journal"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Assign
|
|
10
|
+
module Authority
|
|
11
|
+
# Shared endpoint lifetime and bounded wire protocol. Concrete source
|
|
12
|
+
# compositions authenticate their own installed principals before dispatch.
|
|
13
|
+
# Protected deployment/kernel requirements remain in Server.
|
|
14
|
+
class SocketServer
|
|
15
|
+
CONNECTIONS = 16
|
|
16
|
+
|
|
17
|
+
def initialize(service:, lifecycle:, services: [])
|
|
18
|
+
unless services.is_a?(Array) && services.uniq.size == services.size &&
|
|
19
|
+
services.all? { |owned| owned.respond_to?(:run) && owned.respond_to?(:stop) }
|
|
20
|
+
raise ArgumentError, "authority services require distinct source-owned run/stop lifetimes"
|
|
21
|
+
end
|
|
22
|
+
@service, @lifecycle, @services = service, lifecycle, services.dup.freeze
|
|
23
|
+
@service_threads = []
|
|
24
|
+
@activation_gates = []
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@connections = []
|
|
27
|
+
@handlers = []
|
|
28
|
+
@transfers = 0
|
|
29
|
+
@stopped = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def serve
|
|
33
|
+
verify_listener!
|
|
34
|
+
directory = File.dirname(@service.fetch("socket_path"))
|
|
35
|
+
wire.root_path!(directory, directory: true, owner: @service.fetch("uid"))
|
|
36
|
+
lock = File.open(File.join(directory, "listener.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
37
|
+
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
38
|
+
raise AttemptErrors::Conflict, "assignment authority already owns its listener"
|
|
39
|
+
end
|
|
40
|
+
path = @service.fetch("socket_path")
|
|
41
|
+
if File.exist?(path) || File.symlink?(path)
|
|
42
|
+
raise AttemptErrors::Conflict, "existing authority endpoint requires verified installer recovery"
|
|
43
|
+
end
|
|
44
|
+
@listener = UNIXServer.new(path)
|
|
45
|
+
@endpoint = wire.socket_identity(path)
|
|
46
|
+
File.chmod(0o660, path)
|
|
47
|
+
start_services!
|
|
48
|
+
verify_services!
|
|
49
|
+
@activation_gates.each { |gate| gate << :activate }
|
|
50
|
+
until @stopped
|
|
51
|
+
verify_services!
|
|
52
|
+
ready = IO.select([@listener], nil, nil, 0.25)
|
|
53
|
+
next unless ready
|
|
54
|
+
socket = @listener.accept_nonblock(exception: false)
|
|
55
|
+
next if socket == :wait_readable
|
|
56
|
+
admitted = @mutex.synchronize do
|
|
57
|
+
if @connections.size < CONNECTIONS
|
|
58
|
+
@connections << socket
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
unless admitted
|
|
63
|
+
refusal(socket, "busy")
|
|
64
|
+
socket.close
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
@handlers.reject! { |handler| !handler.alive? }
|
|
69
|
+
@handlers << Thread.new(socket) { |connection| receive(connection) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
rescue IOError, Errno::EBADF
|
|
73
|
+
raise unless @stopped
|
|
74
|
+
ensure
|
|
75
|
+
begin
|
|
76
|
+
stop
|
|
77
|
+
stop_services!
|
|
78
|
+
ensure
|
|
79
|
+
# Closing a stream does not cancel an admitted transaction. Hold
|
|
80
|
+
# the owner lock and endpoint until every handler has ended.
|
|
81
|
+
begin
|
|
82
|
+
@mutex.synchronize { @handlers.dup }.each(&:join)
|
|
83
|
+
@lifecycle.close if @endpoint && @lifecycle.respond_to?(:close)
|
|
84
|
+
ensure
|
|
85
|
+
begin
|
|
86
|
+
File.unlink(path) if @endpoint && same_endpoint?(path)
|
|
87
|
+
ensure
|
|
88
|
+
lock&.close
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def request_stop
|
|
95
|
+
@stopped = true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def stop
|
|
99
|
+
@stopped = true
|
|
100
|
+
@listener&.close unless @listener&.closed?
|
|
101
|
+
@mutex.synchronize { @connections.each { |socket| socket.close unless socket.closed? } }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Installed HITL and merge listeners share the assignment owner lock.
|
|
107
|
+
# Every selected boundary must be ready before assignment ingress opens;
|
|
108
|
+
# a stopped boundary closes admission until the complete owner restarts.
|
|
109
|
+
def start_services!
|
|
110
|
+
@services.each do |owned|
|
|
111
|
+
ready = Queue.new
|
|
112
|
+
activation = Queue.new
|
|
113
|
+
@activation_gates << activation
|
|
114
|
+
thread = Thread.new do
|
|
115
|
+
owned.run(on_ready: lambda do
|
|
116
|
+
ready << :ready
|
|
117
|
+
unless activation.pop == :activate
|
|
118
|
+
raise Ace::Runtime::RuntimeUnavailableError, "authority startup was cancelled"
|
|
119
|
+
end
|
|
120
|
+
end)
|
|
121
|
+
rescue Exception => error
|
|
122
|
+
ready << error
|
|
123
|
+
ensure
|
|
124
|
+
ready << :stopped
|
|
125
|
+
end
|
|
126
|
+
thread.report_on_exception = false
|
|
127
|
+
@service_threads << [owned, thread]
|
|
128
|
+
result = ready.pop(timeout: 5)
|
|
129
|
+
raise Ace::Runtime::RuntimeUnavailableError, "authority service startup did not become ready" unless result == :ready || result.is_a?(Exception)
|
|
130
|
+
raise result if result.is_a?(Exception)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def verify_services!
|
|
135
|
+
unless @service_threads.all? { |_, thread| thread.alive? }
|
|
136
|
+
raise Ace::Runtime::RuntimeUnavailableError, "an owned service stopped before its authority"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def stop_services!
|
|
141
|
+
errors = []
|
|
142
|
+
@activation_gates.each { |gate| gate << :cancel }
|
|
143
|
+
@service_threads.reverse_each do |owned, _|
|
|
144
|
+
owned.stop
|
|
145
|
+
rescue Exception => error
|
|
146
|
+
errors << error
|
|
147
|
+
end
|
|
148
|
+
@service_threads.each do |_, thread|
|
|
149
|
+
thread.join
|
|
150
|
+
rescue Exception => error
|
|
151
|
+
errors << error
|
|
152
|
+
end
|
|
153
|
+
raise errors.first unless errors.empty?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def same_endpoint?(path)
|
|
157
|
+
wire.socket_identity(path) == @endpoint
|
|
158
|
+
rescue StandardError
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def verify_listener!
|
|
163
|
+
raise NotImplementedError, "source composition must verify its installed listener"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def dispatch_request(socket, request, peer, role, deadline, frame_bytes:)
|
|
167
|
+
params = request.fetch("params")
|
|
168
|
+
admitted_transfer = false
|
|
169
|
+
Molecules::EvidenceJournal.with_event_read_operation do
|
|
170
|
+
binding = @lifecycle.transfer_binding(request) if @lifecycle.respond_to?(:transfer_binding)
|
|
171
|
+
if binding
|
|
172
|
+
raise AttemptErrors::UnauthorizedIdentity, "transfer role differs" unless binding.fetch(:roles).include?(role)
|
|
173
|
+
raise ArgumentError, "transfer control header is oversized" if frame_bytes > 16_384
|
|
174
|
+
authorize_transfer!(request: request, peer: peer, role: role, deadline: deadline)
|
|
175
|
+
admitted_transfer = @mutex.synchronize do
|
|
176
|
+
if @transfers < 2
|
|
177
|
+
@transfers += 1
|
|
178
|
+
true
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
raise AttemptErrors::Conflict, "transfer capacity is busy" unless admitted_transfer
|
|
182
|
+
codec = transfer_codec
|
|
183
|
+
if binding.fetch(:direction) == :upload
|
|
184
|
+
result = codec.receive(socket, descriptor: params.fetch("transfer"), purpose: binding.fetch(:purpose), deadline: deadline) do |input|
|
|
185
|
+
dispatch_lifecycle(request: request, peer: peer, role: role, transfer: input, deadline: deadline)
|
|
186
|
+
end
|
|
187
|
+
else
|
|
188
|
+
result = dispatch_lifecycle(request: request, peer: peer, role: role, deadline: deadline)
|
|
189
|
+
parts = result.fetch(:transfer_parts)
|
|
190
|
+
descriptor = codec.descriptor(parts, purpose: binding.fetch(:purpose))
|
|
191
|
+
result = result.merge(data: result.fetch(:data).merge("transfer" => descriptor))
|
|
192
|
+
end
|
|
193
|
+
else
|
|
194
|
+
raise ArgumentError, "unexpected transfer descriptor" if params.key?("transfer")
|
|
195
|
+
result = dispatch_lifecycle(request: request, peer: peer, role: role, deadline: deadline)
|
|
196
|
+
end
|
|
197
|
+
wire.write(socket, {"status" => "ok", "data" => result.fetch(:data),
|
|
198
|
+
"transport" => {"replayed" => result.fetch(:replayed, false)}}, deadline: deadline, limit: 16_384)
|
|
199
|
+
if binding && binding.fetch(:direction) == :download
|
|
200
|
+
codec.send(socket, parts: parts, descriptor: descriptor, purpose: binding.fetch(:purpose), deadline: deadline)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
ensure
|
|
204
|
+
@mutex.synchronize { @transfers -= 1 } if admitted_transfer
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def authorize_transfer!(request:, peer:, role:, deadline:)
|
|
208
|
+
@lifecycle.authorize_transfer!(request: request, peer: peer, role: role)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def dispatch_lifecycle(request:, peer:, role:, deadline:, transfer: nil)
|
|
212
|
+
options = {request: request, peer: peer, role: role}
|
|
213
|
+
options[:transfer] = transfer if transfer
|
|
214
|
+
@lifecycle.dispatch(**options)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# These closed read operations have no request body. Half-close proves
|
|
218
|
+
# framing completion before status or artifact data can be exposed.
|
|
219
|
+
def bodyless_read!(socket, deadline)
|
|
220
|
+
loop do
|
|
221
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
222
|
+
raise ArgumentError, "read request framing is incomplete" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
223
|
+
bytes = socket.read_nonblock(1, exception: false)
|
|
224
|
+
next if bytes == :wait_readable
|
|
225
|
+
raise ArgumentError, "unexpected read request body" unless bytes.nil?
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def transfer_codec
|
|
231
|
+
@mutex.synchronize do
|
|
232
|
+
@transfer_codec ||= begin
|
|
233
|
+
root = @service.fetch("state_root")
|
|
234
|
+
PrivateDirectory.verify!(root)
|
|
235
|
+
spool = File.join(root, "transfers")
|
|
236
|
+
Dir.mkdir(spool, 0o700) unless File.exist?(spool) || File.symlink?(spool)
|
|
237
|
+
TransferCodec.new(root: spool)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def refusal(socket, code)
|
|
243
|
+
wire.write(socket, {"status" => "error", "error" => {"code" => code, "message" => "Protected authority refused #{code}"}},
|
|
244
|
+
deadline: wire.deadline(0.1))
|
|
245
|
+
rescue StandardError
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def wire
|
|
250
|
+
Ace::Runtime::Molecules::ProtectedSocket
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -5,6 +5,7 @@ require "tempfile"
|
|
|
5
5
|
require "timeout"
|
|
6
6
|
require "ace/runtime/molecules/protected_socket"
|
|
7
7
|
require_relative "private_directory"
|
|
8
|
+
require_relative "../atoms/candidate_limits"
|
|
8
9
|
|
|
9
10
|
module Ace
|
|
10
11
|
module Assign
|
|
@@ -13,10 +14,11 @@ module Ace
|
|
|
13
14
|
# Server selects purpose from a source handler's fixed operation table,
|
|
14
15
|
# admits a transfer slot before calling, and holds no journal lock here.
|
|
15
16
|
class TransferCodec
|
|
16
|
-
LIMITS = {prompt_text: [16_384, 1, 16_384], scope_boundary_observation: [65_536, 1, 65_536], candidate: [
|
|
17
|
+
LIMITS = {prompt_text: [16_384, 1, 16_384], scope_boundary_observation: [65_536, 1, 65_536], candidate: [Atoms::CandidateLimits::BYTES, 1, Atoms::CandidateLimits::BYTES],
|
|
18
|
+
release_artifact: [64 * 1024 * 1024, 1, 64 * 1024 * 1024],
|
|
17
19
|
artifacts: [256 * 1024, 16, 64 * 1024],
|
|
18
20
|
receipt_artifacts: [272 * 1024, 17, 64 * 1024],
|
|
19
|
-
service_input: [64 * 1024, 1, 64 * 1024]}.freeze
|
|
21
|
+
service_input: [64 * 1024, 1, 64 * 1024], assignment_snapshot: [Atoms::CandidateLimits::BYTES, 1, Atoms::CandidateLimits::BYTES]}.freeze
|
|
20
22
|
SHA256 = /\A[0-9a-f]{64}\z/
|
|
21
23
|
|
|
22
24
|
class Input
|
|
@@ -149,7 +151,7 @@ module Ace
|
|
|
149
151
|
part["bytes"].is_a?(Integer) && part["bytes"].between?(0, each) &&
|
|
150
152
|
part["sha256"].is_a?(String) && part["sha256"].match?(SHA256) } &&
|
|
151
153
|
descriptor["bytes"] == descriptor["parts"].sum { |part| part["bytes"] } &&
|
|
152
|
-
(!%i[candidate service_input prompt_text].include?(purpose) || descriptor["bytes"].positive?)
|
|
154
|
+
(!%i[candidate service_input prompt_text assignment_snapshot release_artifact].include?(purpose) || descriptor["bytes"].positive?)
|
|
153
155
|
reject!("Invalid or oversized transfer descriptor")
|
|
154
156
|
end
|
|
155
157
|
if purpose == :receipt_artifacts
|
|
@@ -7,12 +7,12 @@ module Ace
|
|
|
7
7
|
module Attempt
|
|
8
8
|
class Evidence < Ace::Support::Cli::Command
|
|
9
9
|
include Attempt::Base
|
|
10
|
-
desc "Read coordinator-accepted live execution or historical
|
|
10
|
+
desc "Read coordinator-accepted live execution or historical execution authority"
|
|
11
11
|
option :attempt, required: true, desc: "Accepted execution attempt ID"
|
|
12
12
|
option :receipt_digest, required: true, desc: "Accepted receipt digest"
|
|
13
13
|
option :kind, default: "check", desc: "Evidence purpose: check, review-collection or review-approval"
|
|
14
14
|
option :check_name, default: "tests", desc: "Required check name (tests binds operation test)"
|
|
15
|
-
option :historical_head, desc: "
|
|
15
|
+
option :historical_head, desc: "Exact recorded head for historical authority only (never current-head evidence)"
|
|
16
16
|
option :format, default: "json", desc: "JSON evidence projection"
|
|
17
17
|
|
|
18
18
|
def call(**options)
|
|
@@ -23,7 +23,7 @@ module Ace
|
|
|
23
23
|
head = require_option(options, :head, usage)
|
|
24
24
|
raise Ace::Support::Cli::Error, "Head must be an exact SHA-1" unless head.match?(/\A[0-9a-f]{40}\z/)
|
|
25
25
|
params.merge!("head" => head, "candidate_generation" => integer_option(options, :candidate_generation, usage))
|
|
26
|
-
bytes = input_bytes(require_option(options, :bundle, usage), limit:
|
|
26
|
+
bytes = input_bytes(require_option(options, :bundle, usage), limit: Ace::Assign::Authority::CandidateTransfer::MAX_BYTES)
|
|
27
27
|
parts = [bytes]
|
|
28
28
|
emit_json(upload_call(client, "submit_candidate", params, mutation, parts, :candidate))
|
|
29
29
|
end
|
|
@@ -14,8 +14,12 @@ module Ace
|
|
|
14
14
|
MAX_TOTAL_BYTES = 256 * 1024
|
|
15
15
|
MAX_ARTIFACTS = 16
|
|
16
16
|
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
17
|
+
MAX_RELEASE_ARTIFACT_BYTES = 64 * 1024 * 1024
|
|
17
18
|
ROLES = {"service" => "executor", "review" => "reviewer",
|
|
18
|
-
"result" => "worker"}.freeze
|
|
19
|
+
"result" => "worker", "release-artifact" => "worker"}.freeze
|
|
20
|
+
REPORT_LIMITS = [MAX_ARTIFACT_BYTES, MAX_TOTAL_BYTES, MAX_ARTIFACTS].freeze
|
|
21
|
+
LIMITS = {"service" => REPORT_LIMITS, "review" => REPORT_LIMITS, "result" => REPORT_LIMITS,
|
|
22
|
+
"release-artifact" => [MAX_RELEASE_ARTIFACT_BYTES, MAX_RELEASE_ARTIFACT_BYTES, 1].freeze}.freeze
|
|
19
23
|
DESCRIPTOR_FIELDS = %w[version artifact_id kind project_id assignment_id attempt_id peer_uid role
|
|
20
24
|
binding_digest sha256 bytes admitted_at admitted_after_event_digest request_id_or_event_id
|
|
21
25
|
candidate_generation_or_claim_generation].freeze
|
|
@@ -27,7 +31,8 @@ module Ace
|
|
|
27
31
|
%w[artifact_id project_id assignment_id attempt_id request_id_or_event_id].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(ID) } &&
|
|
28
32
|
%w[binding_digest sha256 admitted_after_event_digest].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
29
33
|
descriptor["peer_uid"].is_a?(Integer) && descriptor["peer_uid"] >= 0 &&
|
|
30
|
-
descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(0,
|
|
34
|
+
descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(descriptor["kind"] == "release-artifact" ? 1 : 0,
|
|
35
|
+
LIMITS.fetch(descriptor["kind"]).first) &&
|
|
31
36
|
descriptor["candidate_generation_or_claim_generation"].is_a?(Integer) && descriptor["candidate_generation_or_claim_generation"] >= 0 &&
|
|
32
37
|
descriptor["admitted_at"].is_a?(String)
|
|
33
38
|
Time.iso8601(descriptor.fetch("admitted_at"))
|
|
@@ -46,13 +51,15 @@ module Ace
|
|
|
46
51
|
def import_plan(kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
|
|
47
52
|
request_id_or_event_id:, generation:, admitted_after_event_digest:, artifacts:)
|
|
48
53
|
role = ROLES.fetch(kind) { raise ArgumentError, "invalid evidence kind" }
|
|
54
|
+
max_each, max_total, max_count = LIMITS.fetch(kind)
|
|
55
|
+
minimum = kind == "release-artifact" ? 1 : 0
|
|
49
56
|
ids = [project_id, assignment_id, attempt_id, request_id_or_event_id]
|
|
50
57
|
unless ids.all? { |id| id.is_a?(String) && id.match?(ID) } && binding.is_a?(Hash) &&
|
|
51
58
|
admitted_after_event_digest.is_a?(String) && admitted_after_event_digest.match?(/\A[0-9a-f]{64}\z/) &&
|
|
52
59
|
peer_uid.is_a?(Integer) && peer_uid >= 0 && generation.is_a?(Integer) && generation >= 0 &&
|
|
53
|
-
artifacts.is_a?(Array) && !artifacts.empty? && artifacts.length <=
|
|
54
|
-
artifacts.all? { |bytes| bytes.is_a?(String) && bytes.bytesize
|
|
55
|
-
artifacts.sum(&:bytesize) <=
|
|
60
|
+
artifacts.is_a?(Array) && !artifacts.empty? && artifacts.length <= max_count &&
|
|
61
|
+
artifacts.all? { |bytes| bytes.is_a?(String) && bytes.bytesize.between?(minimum, max_each) } &&
|
|
62
|
+
artifacts.sum(&:bytesize) <= max_total
|
|
56
63
|
raise ArgumentError, "invalid or oversized canonical evidence import"
|
|
57
64
|
end
|
|
58
65
|
events = []
|
|
@@ -85,7 +92,11 @@ module Ace
|
|
|
85
92
|
verified_bytes(reference, events: events, kind: kind, project_id: project_id,
|
|
86
93
|
assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
|
|
87
94
|
request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
|
|
88
|
-
|
|
95
|
+
if kind == "release-artifact"
|
|
96
|
+
@journal.bounded_blob(path, commit: commit, max_bytes: MAX_RELEASE_ARTIFACT_BYTES)
|
|
97
|
+
else
|
|
98
|
+
@journal.blob(path, commit: commit)
|
|
99
|
+
end
|
|
89
100
|
end
|
|
90
101
|
end
|
|
91
102
|
|
|
@@ -102,7 +113,13 @@ module Ace
|
|
|
102
113
|
verified_bytes(reference, events: events, kind: kind, project_id: project_id,
|
|
103
114
|
assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
|
|
104
115
|
request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
|
|
105
|
-
blobs.key?(path)
|
|
116
|
+
if blobs.key?(path)
|
|
117
|
+
blobs.fetch(path)
|
|
118
|
+
elsif kind == "release-artifact"
|
|
119
|
+
@journal.bounded_blob(path, commit: commit, max_bytes: MAX_RELEASE_ARTIFACT_BYTES)
|
|
120
|
+
else
|
|
121
|
+
@journal.blob(path, commit: commit)
|
|
122
|
+
end
|
|
106
123
|
end
|
|
107
124
|
end
|
|
108
125
|
|
|
@@ -137,7 +154,7 @@ module Ace
|
|
|
137
154
|
end
|
|
138
155
|
Time.iso8601(descriptor.fetch("admitted_at"))
|
|
139
156
|
bytes = yield(reference.fetch("ref"))
|
|
140
|
-
unavailable! unless bytes.is_a?(String) && bytes.bytesize <=
|
|
157
|
+
unavailable! unless bytes.is_a?(String) && bytes.bytesize <= LIMITS.fetch(kind).first && bytes.bytesize == descriptor["bytes"] &&
|
|
141
158
|
Digest::SHA256.hexdigest(bytes) == descriptor["sha256"]
|
|
142
159
|
bytes
|
|
143
160
|
rescue ArgumentError, KeyError, TypeError
|