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,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
# Receipt-first binary framing. Field names come from source operations,
|
|
10
|
+
# never from the wire. Artifact order and declared digests are exact.
|
|
11
|
+
module ReceiptTransfer
|
|
12
|
+
def self.decode(input:, receipt_sha256:, artifact_field:, reference_key:)
|
|
13
|
+
unless input && input.count.between?(1, 17) && %w[artifacts evidence].include?(artifact_field) &&
|
|
14
|
+
%w[path ref].include?(reference_key) && receipt_sha256.is_a?(String) && receipt_sha256.match?(/\A[0-9a-f]{64}\z/)
|
|
15
|
+
raise AttemptErrors::ReceiptRejected, "receipt transfer binding is invalid"
|
|
16
|
+
end
|
|
17
|
+
bytes = input.bytes(index: 0)
|
|
18
|
+
unless bytes.bytesize.between?(1, 16 * 1024) && Digest::SHA256.hexdigest(bytes) == receipt_sha256
|
|
19
|
+
raise AttemptErrors::ReceiptRejected, "first transfer part must be the exact receipt"
|
|
20
|
+
end
|
|
21
|
+
receipt = JSON.parse(bytes)
|
|
22
|
+
unless receipt.is_a?(Hash) && !Models::ExecutionReceipt.forbidden_field?(receipt)
|
|
23
|
+
raise AttemptErrors::ReceiptRejected, "receipt must contain bounded structured evidence"
|
|
24
|
+
end
|
|
25
|
+
references = receipt.fetch(artifact_field)
|
|
26
|
+
unless references.is_a?(Array) && references.length == input.count - 1 && references.length <= 16 &&
|
|
27
|
+
references.all? { |entry| entry.is_a?(Hash) && entry.keys.sort == [reference_key, "sha256"].sort &&
|
|
28
|
+
entry[reference_key].is_a?(String) && entry[reference_key].bytesize.between?(1, 256) &&
|
|
29
|
+
entry["sha256"].is_a?(String) && entry["sha256"].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
30
|
+
references.map { |entry| entry[reference_key] }.uniq.length == references.length
|
|
31
|
+
raise AttemptErrors::ReceiptRejected, "receipt artifact parts are incomplete or duplicated"
|
|
32
|
+
end
|
|
33
|
+
artifacts = references.each_with_index.map do |reference, index|
|
|
34
|
+
artifact = input.bytes(index: index + 1)
|
|
35
|
+
unless artifact.bytesize <= 64 * 1024 && Digest::SHA256.hexdigest(artifact) == reference.fetch("sha256")
|
|
36
|
+
raise AttemptErrors::ReceiptRejected, "receipt artifact part differs from declaration"
|
|
37
|
+
end
|
|
38
|
+
artifact
|
|
39
|
+
end
|
|
40
|
+
raise AttemptErrors::ReceiptRejected, "receipt evidence exceeds its transfer limit" if artifacts.sum(&:bytesize) > 256 * 1024
|
|
41
|
+
{receipt: receipt, artifacts: artifacts, receipt_sha256: receipt_sha256}
|
|
42
|
+
rescue JSON::ParserError, KeyError
|
|
43
|
+
raise AttemptErrors::ReceiptRejected, "receipt transfer schema is invalid"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Ace
|
|
3
|
+
module Assign
|
|
4
|
+
module Authority
|
|
5
|
+
# Source-owned handlers share one listener. Requests never name a method.
|
|
6
|
+
class Router
|
|
7
|
+
def initialize(launch:, handlers: [])
|
|
8
|
+
@launch = launch
|
|
9
|
+
@routes = {}
|
|
10
|
+
([launch] + handlers).each do |handler|
|
|
11
|
+
handler.class::OPERATIONS.each do |operation|
|
|
12
|
+
raise ArgumentError, "duplicate authority operation" if @routes.key?(operation)
|
|
13
|
+
@routes[operation] = handler
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
@routes.freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def dispatch(request:, peer:, role:, transfer: nil)
|
|
20
|
+
handler = @routes.fetch(request.fetch("operation")) { raise ArgumentError, "unknown authority operation" }
|
|
21
|
+
options = {request: request, peer: peer, role: role}
|
|
22
|
+
options[:transfer] = transfer if transfer
|
|
23
|
+
handler.dispatch(**options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def transfer_binding(operation)
|
|
27
|
+
handler = @routes.fetch(operation) { raise ArgumentError, "unknown authority operation" }
|
|
28
|
+
return nil unless handler.class.const_defined?(:TRANSFER_OPERATIONS, false)
|
|
29
|
+
binding = handler.class::TRANSFER_OPERATIONS[operation]
|
|
30
|
+
return nil unless binding
|
|
31
|
+
unless binding.is_a?(Hash) && binding.keys.sort == %i[direction purpose roles] &&
|
|
32
|
+
%i[upload download].include?(binding[:direction]) && TransferCodec::LIMITS.key?(binding[:purpose]) &&
|
|
33
|
+
binding[:roles].is_a?(Array) && binding[:roles].all? { |role| %i[worker launcher reviewer executor supervisor].include?(role) }
|
|
34
|
+
raise ArgumentError, "invalid source transfer binding"
|
|
35
|
+
end
|
|
36
|
+
binding
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def authorize_transfer!(request:, peer:, role:)
|
|
40
|
+
handler = @routes.fetch(request.fetch("operation"))
|
|
41
|
+
handler.authorize_transfer!(request: request, peer: peer, role: role)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def close
|
|
45
|
+
@launch.close
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def gate_ready(**options)
|
|
49
|
+
@launch.gate_ready(**options)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "socket"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "deployment"
|
|
5
|
+
require_relative "transfer_codec"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Authority
|
|
10
|
+
# Existing assignment owner transport; kernel peers select roles, never argv.
|
|
11
|
+
class Server
|
|
12
|
+
CONNECTIONS = 16
|
|
13
|
+
def initialize(authority_id:, lifecycle:, deployment: Deployment.load,
|
|
14
|
+
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, composition: "launch")
|
|
15
|
+
@authority_id, @lifecycle, @deployment, @kernel = authority_id, lifecycle, deployment, kernel
|
|
16
|
+
@composition = composition
|
|
17
|
+
deployment.verify_composition!(authority_id, composition: composition)
|
|
18
|
+
@service = deployment.authority(authority_id)
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@connections = []
|
|
21
|
+
@handlers = []
|
|
22
|
+
@transfers = 0
|
|
23
|
+
@stopped = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def serve
|
|
27
|
+
@kernel.supported!
|
|
28
|
+
me = @kernel.capture(Process.pid)
|
|
29
|
+
unless principal?(me, @service, "uid", "gid", "groups")
|
|
30
|
+
raise AttemptErrors::UnauthorizedIdentity, "listener principal differs from installed authority"
|
|
31
|
+
end
|
|
32
|
+
@deployment.verify_receiver_paths!(@authority_id)
|
|
33
|
+
directory = File.dirname(@service.fetch("socket_path"))
|
|
34
|
+
wire.root_path!(directory, directory: true, owner: @service.fetch("uid"))
|
|
35
|
+
lock = File.open(File.join(directory, "listener.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
36
|
+
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
37
|
+
raise AttemptErrors::Conflict, "assignment authority already owns its listener"
|
|
38
|
+
end
|
|
39
|
+
path = @service.fetch("socket_path")
|
|
40
|
+
if File.exist?(path) || File.symlink?(path)
|
|
41
|
+
raise AttemptErrors::Conflict, "existing authority endpoint requires verified installer recovery"
|
|
42
|
+
end
|
|
43
|
+
@listener = UNIXServer.new(path)
|
|
44
|
+
File.chmod(0o660, path)
|
|
45
|
+
@endpoint = wire.socket_identity(path)
|
|
46
|
+
until @stopped
|
|
47
|
+
ready = IO.select([@listener], nil, nil, 0.25)
|
|
48
|
+
next unless ready
|
|
49
|
+
socket = @listener.accept_nonblock(exception: false)
|
|
50
|
+
next if socket == :wait_readable
|
|
51
|
+
admitted = @mutex.synchronize do
|
|
52
|
+
if @connections.size < CONNECTIONS
|
|
53
|
+
@connections << socket
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
unless admitted
|
|
58
|
+
refusal(socket, "busy")
|
|
59
|
+
socket.close
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@handlers.reject! { |handler| !handler.alive? }
|
|
64
|
+
@handlers << Thread.new(socket) { |connection| receive(connection) }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
rescue IOError, Errno::EBADF
|
|
68
|
+
raise unless @stopped
|
|
69
|
+
ensure
|
|
70
|
+
stop
|
|
71
|
+
# Closing a stream does not cancel a handler already inside dispatch.
|
|
72
|
+
# The owner lock and endpoint remain held until every handler ends.
|
|
73
|
+
@mutex.synchronize { @handlers.dup }.each(&:join)
|
|
74
|
+
@lifecycle.close if @endpoint && @lifecycle.respond_to?(:close)
|
|
75
|
+
if @endpoint && same_endpoint?(path)
|
|
76
|
+
File.unlink(path)
|
|
77
|
+
end
|
|
78
|
+
lock&.close
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def request_stop
|
|
82
|
+
@stopped = true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def stop
|
|
86
|
+
@stopped = true
|
|
87
|
+
@listener&.close unless @listener&.closed?
|
|
88
|
+
@mutex.synchronize { @connections.each { |socket| socket.close unless socket.closed? } }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def same_endpoint?(path)
|
|
94
|
+
wire.socket_identity(path) == @endpoint
|
|
95
|
+
rescue StandardError
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def receive(socket)
|
|
100
|
+
deadline = wire.deadline(30)
|
|
101
|
+
peer = @kernel.peer(socket)
|
|
102
|
+
frame = wire.read(socket, deadline: [deadline, wire.deadline(5)].min, with_size: true)
|
|
103
|
+
request = frame.fetch(:data)
|
|
104
|
+
unless request.is_a?(Hash) && request.keys.sort == %w[mutation_id operation params project_id version] &&
|
|
105
|
+
request["version"] == 1 && request["operation"].is_a?(String) && request["params"].is_a?(Hash)
|
|
106
|
+
raise ArgumentError, "invalid authority envelope"
|
|
107
|
+
end
|
|
108
|
+
deadline = wire.deadline(90) if %w[reserve_attempt close_execution_scope].include?(request.fetch("operation"))
|
|
109
|
+
params = request.fetch("params")
|
|
110
|
+
map = @deployment.verify!(params.fetch("mapping_id"), kernel: @kernel, authority_state: true)
|
|
111
|
+
unless map["authority_id"] == @authority_id && map["project_id"] == request["project_id"]
|
|
112
|
+
raise AttemptErrors::UnauthorizedIdentity, "mapping belongs to another authority or project"
|
|
113
|
+
end
|
|
114
|
+
role = if principal?(peer, map, "launcher_uid", "launcher_gid", "launcher_groups")
|
|
115
|
+
:launcher
|
|
116
|
+
elsif principal?(peer, map, "worker_uid", "worker_gid", "worker_groups")
|
|
117
|
+
:worker
|
|
118
|
+
end
|
|
119
|
+
unless role
|
|
120
|
+
project = @deployment.project(map.fetch("project_id"))
|
|
121
|
+
configured = project.fetch("peer_credentials")[peer.fetch("uid").to_s]
|
|
122
|
+
if configured && peer["gid"] == configured["gid"] && peer["groups"] == configured["groups"]
|
|
123
|
+
{reviewer: "reviewer_uids", executor: "service_executor_uids", supervisor: "supervisor_uids"}.each do |candidate, key|
|
|
124
|
+
role ||= candidate if project.fetch(key).include?(peer.fetch("uid"))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
raise AttemptErrors::UnauthorizedIdentity, "unmapped kernel peer" unless role
|
|
129
|
+
if %w[observe_execution_scope close_execution_scope].include?(request["operation"]) ||
|
|
130
|
+
@composition == "services" && %w[attempt_status evidence_fetch].include?(request["operation"])
|
|
131
|
+
bodyless_read!(socket, deadline)
|
|
132
|
+
end
|
|
133
|
+
if request["operation"] == "gate_ready"
|
|
134
|
+
raise AttemptErrors::UnauthorizedIdentity, "gate readiness requires worker peer" unless role == :worker
|
|
135
|
+
@lifecycle.gate_ready(request: request, peer: peer, socket: socket, deadline: deadline)
|
|
136
|
+
else
|
|
137
|
+
binding = @lifecycle.transfer_binding(request.fetch("operation")) if @lifecycle.respond_to?(:transfer_binding)
|
|
138
|
+
if binding
|
|
139
|
+
raise AttemptErrors::UnauthorizedIdentity, "transfer role differs" unless binding.fetch(:roles).include?(role)
|
|
140
|
+
raise ArgumentError, "transfer control header is oversized" if frame.fetch(:bytesize) > 16_384
|
|
141
|
+
@lifecycle.authorize_transfer!(request: request, peer: peer, role: role)
|
|
142
|
+
admitted_transfer = @mutex.synchronize do
|
|
143
|
+
if @transfers < 2
|
|
144
|
+
@transfers += 1
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
raise AttemptErrors::Conflict, "transfer capacity is busy" unless admitted_transfer
|
|
149
|
+
codec = transfer_codec
|
|
150
|
+
if binding.fetch(:direction) == :upload
|
|
151
|
+
result = codec.receive(socket, descriptor: params.fetch("transfer"), purpose: binding.fetch(:purpose), deadline: deadline) do |input|
|
|
152
|
+
@lifecycle.dispatch(request: request, peer: peer, role: role, transfer: input)
|
|
153
|
+
end
|
|
154
|
+
else
|
|
155
|
+
result = @lifecycle.dispatch(request: request, peer: peer, role: role)
|
|
156
|
+
parts = result.fetch(:transfer_parts)
|
|
157
|
+
descriptor = codec.descriptor(parts, purpose: binding.fetch(:purpose))
|
|
158
|
+
result = result.merge(data: result.fetch(:data).merge("transfer" => descriptor))
|
|
159
|
+
end
|
|
160
|
+
else
|
|
161
|
+
raise ArgumentError, "unexpected transfer descriptor" if params.key?("transfer")
|
|
162
|
+
result = @lifecycle.dispatch(request: request, peer: peer, role: role)
|
|
163
|
+
end
|
|
164
|
+
wire.write(socket, {"status" => "ok", "data" => result.fetch(:data),
|
|
165
|
+
"transport" => {"replayed" => result.fetch(:replayed, false)}}, deadline: deadline, limit: 16_384)
|
|
166
|
+
if binding && binding.fetch(:direction) == :download
|
|
167
|
+
codec.send(socket, parts: parts, descriptor: descriptor, purpose: binding.fetch(:purpose), deadline: deadline)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
rescue ArgumentError, KeyError, AttemptErrors::MalformedTransfer
|
|
171
|
+
refusal(socket, "invalid_input")
|
|
172
|
+
rescue AttemptErrors::UnauthorizedIdentity
|
|
173
|
+
refusal(socket, "unauthorized")
|
|
174
|
+
rescue AttemptErrors::Conflict
|
|
175
|
+
refusal(socket, "conflict")
|
|
176
|
+
rescue AttemptErrors::NotFound
|
|
177
|
+
refusal(socket, "missing")
|
|
178
|
+
rescue StandardError
|
|
179
|
+
refusal(socket, "evidence_unavailable")
|
|
180
|
+
ensure
|
|
181
|
+
socket.close unless socket.closed?
|
|
182
|
+
@mutex.synchronize do
|
|
183
|
+
@connections.delete(socket)
|
|
184
|
+
@transfers -= 1 if admitted_transfer
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# These closed read operations have no request body. Half-close proves
|
|
189
|
+
# framing completion before status or artifact data can be exposed.
|
|
190
|
+
def bodyless_read!(socket, deadline)
|
|
191
|
+
loop do
|
|
192
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
193
|
+
raise ArgumentError, "read request framing is incomplete" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
194
|
+
bytes = socket.read_nonblock(1, exception: false)
|
|
195
|
+
next if bytes == :wait_readable
|
|
196
|
+
raise ArgumentError, "unexpected read request body" unless bytes.nil?
|
|
197
|
+
return
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def transfer_codec
|
|
202
|
+
@mutex.synchronize do
|
|
203
|
+
@transfer_codec ||= begin
|
|
204
|
+
root = @service.fetch("state_root")
|
|
205
|
+
PrivateDirectory.verify!(root)
|
|
206
|
+
spool = File.join(root, "transfers")
|
|
207
|
+
Dir.mkdir(spool, 0o700) unless File.exist?(spool) || File.symlink?(spool)
|
|
208
|
+
TransferCodec.new(root: spool)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def principal?(identity, mapping, uid, gid, groups)
|
|
214
|
+
identity["uid"] == mapping[uid] && identity["gid"] == mapping[gid] && identity["groups"] == mapping[groups]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def refusal(socket, code)
|
|
218
|
+
wire.write(socket, {"status" => "error", "error" => {"code" => code, "message" => "Protected authority refused #{code}"}},
|
|
219
|
+
deadline: wire.deadline(0.1))
|
|
220
|
+
rescue StandardError
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def wire
|
|
225
|
+
Ace::Runtime::Molecules::ProtectedSocket
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../molecules/canonical_evidence"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Authority
|
|
8
|
+
# One source-owned context for initial and retained service evidence.
|
|
9
|
+
# The journal supplies the record and transient pending view; callers
|
|
10
|
+
# never supply an alternate reader, artifact path or trusted flag.
|
|
11
|
+
class ServiceEvidence
|
|
12
|
+
FIELDS = (Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS +
|
|
13
|
+
%w[dispatch_ticket_id claim_binding candidate_generation claim_generation policy_digest]).freeze
|
|
14
|
+
|
|
15
|
+
def initialize(journal:)
|
|
16
|
+
@journal = journal
|
|
17
|
+
@canonical = Molecules::CanonicalEvidence.new(journal: journal)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def context(record, no_effect: false)
|
|
21
|
+
binding = FIELDS.to_h { |key| [key, record.fetch(key)] }
|
|
22
|
+
unless %w[claim_binding policy_digest].all? { |key| binding[key].is_a?(String) && binding[key].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
23
|
+
%w[candidate_generation claim_generation].all? { |key| binding[key].is_a?(Integer) && binding[key].positive? } &&
|
|
24
|
+
binding["dispatch_ticket_id"].is_a?(String) && binding["dispatch_ticket_id"].match?(Molecules::JournalMutation::ID)
|
|
25
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical dispatch binding is invalid"
|
|
26
|
+
end
|
|
27
|
+
if no_effect
|
|
28
|
+
binding.merge!(%w[no_effect_challenge challenge_generation challenge_event_digest].to_h do |key|
|
|
29
|
+
[key, record.fetch(key)]
|
|
30
|
+
end)
|
|
31
|
+
unless binding["no_effect_challenge"].is_a?(String) && !binding["no_effect_challenge"].empty? &&
|
|
32
|
+
binding["challenge_generation"].is_a?(Integer) && binding["challenge_generation"].positive? &&
|
|
33
|
+
binding["challenge_event_digest"].is_a?(String) && binding["challenge_event_digest"].match?(/\A[0-9a-f]{64}\z/)
|
|
34
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical no-effect challenge is invalid"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
{kind: "service", project_id: record.fetch("project_id"), assignment_id: record.fetch("assignment_id"),
|
|
38
|
+
attempt_id: record.fetch("attempt_id"), peer_uid: record.fetch("executor_uid"), binding: binding,
|
|
39
|
+
request_id_or_event_id: record.fetch("request_id"), generation: record.fetch("claim_generation")}
|
|
40
|
+
rescue KeyError
|
|
41
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical service evidence binding is incomplete"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call(reference, record, state, pending)
|
|
45
|
+
expected = context(record, no_effect: state == "failed-settled")
|
|
46
|
+
if pending && pending[:pending_events]
|
|
47
|
+
@canonical.read_pending(reference, **expected, **pending.slice(:current_events, :pending_events, :blobs, :commit))
|
|
48
|
+
else
|
|
49
|
+
@canonical.read(reference, **expected, commit: pending && pending[:commit] || @journal.ref_value)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require_relative "private_directory"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Assign
|
|
10
|
+
module Authority
|
|
11
|
+
# Binary framing after the existing authenticated control header. The
|
|
12
|
+
# Server selects purpose from a source handler's fixed operation table,
|
|
13
|
+
# admits a transfer slot before calling, and holds no journal lock here.
|
|
14
|
+
class TransferCodec
|
|
15
|
+
LIMITS = {candidate: [64 * 1024 * 1024, 1, 64 * 1024 * 1024],
|
|
16
|
+
artifacts: [256 * 1024, 16, 64 * 1024],
|
|
17
|
+
receipt_artifacts: [272 * 1024, 17, 64 * 1024],
|
|
18
|
+
service_input: [64 * 1024, 1, 64 * 1024],
|
|
19
|
+
inbox_proof: [32 * 1024, 2, 16 * 1024]}.freeze
|
|
20
|
+
SHA256 = /\A[0-9a-f]{64}\z/
|
|
21
|
+
|
|
22
|
+
class Input
|
|
23
|
+
def initialize(file, descriptor)
|
|
24
|
+
@file, @descriptor = file, descriptor
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def bytes(index: 0)
|
|
28
|
+
unless index.is_a?(Integer) && index.between?(0, count - 1)
|
|
29
|
+
raise ArgumentError, "Invalid transferred part index"
|
|
30
|
+
end
|
|
31
|
+
part = @descriptor.fetch("parts").fetch(index)
|
|
32
|
+
offset = @descriptor.fetch("parts").take(index).sum { |entry| entry.fetch("bytes") }
|
|
33
|
+
@file.seek(offset)
|
|
34
|
+
bytes = (@file.read(part.fetch("bytes")) || "").b
|
|
35
|
+
unless bytes.bytesize == part["bytes"] && Digest::SHA256.hexdigest(bytes) == part["sha256"]
|
|
36
|
+
raise AttemptErrors::ReceiptRejected, "Transferred bytes no longer match accepted binding"
|
|
37
|
+
end
|
|
38
|
+
bytes
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def count
|
|
42
|
+
@descriptor.fetch("parts").length
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(root:)
|
|
47
|
+
@root = PrivateDirectory.verify!(root)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Upload write-EOF is required before invoking the mutation consumer.
|
|
51
|
+
# Persistent launch gate connections never use this codec.
|
|
52
|
+
def receive(socket, descriptor:, purpose:, deadline:)
|
|
53
|
+
validate!(descriptor, purpose)
|
|
54
|
+
descriptor = descriptor.merge("parts" => descriptor.fetch("parts").map { |part| part.dup.freeze }.freeze).freeze
|
|
55
|
+
deadline = [deadline, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30].min
|
|
56
|
+
PrivateDirectory.verify!(@root)
|
|
57
|
+
Tempfile.create(["transfer-", ".bytes"], @root, binmode: true) do |file|
|
|
58
|
+
digest = Digest::SHA256.new
|
|
59
|
+
descriptor.fetch("parts").each do |part|
|
|
60
|
+
part_digest = Digest::SHA256.new
|
|
61
|
+
remaining = part.fetch("bytes")
|
|
62
|
+
while remaining.positive?
|
|
63
|
+
chunk = read(socket, [remaining, 65_536].min, deadline)
|
|
64
|
+
reject!("Transferred bytes ended early") if chunk.nil?
|
|
65
|
+
file.write(chunk)
|
|
66
|
+
digest.update(chunk)
|
|
67
|
+
part_digest.update(chunk)
|
|
68
|
+
remaining -= chunk.bytesize
|
|
69
|
+
end
|
|
70
|
+
reject!("Transferred part digest differs") unless part_digest.hexdigest == part["sha256"]
|
|
71
|
+
end
|
|
72
|
+
reject!("Transferred aggregate digest differs") unless digest.hexdigest == descriptor["sha256"]
|
|
73
|
+
reject!("Transferred bytes exceed declared size") unless read(socket, 1, deadline).nil?
|
|
74
|
+
file.flush
|
|
75
|
+
file.rewind
|
|
76
|
+
yield Input.new(file, descriptor)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def descriptor(parts, purpose:)
|
|
81
|
+
reject!("Invalid transfer parts") unless parts.is_a?(Array) && parts.all? { |part| part.is_a?(String) }
|
|
82
|
+
digest = Digest::SHA256.new
|
|
83
|
+
parts.each { |part| digest.update(part.b) }
|
|
84
|
+
result = {"version" => 1, "bytes" => parts.sum(&:bytesize), "sha256" => digest.hexdigest,
|
|
85
|
+
"parts" => parts.map { |part| {"bytes" => part.bytesize, "sha256" => Digest::SHA256.hexdigest(part.b)} }}
|
|
86
|
+
validate!(result, purpose)
|
|
87
|
+
result
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The caller writes the bounded JSON descriptor first, then these raw
|
|
91
|
+
# bytes. Both directions share the Server's original monotonic deadline.
|
|
92
|
+
def send(socket, parts:, descriptor:, purpose:, deadline:)
|
|
93
|
+
expected = self.descriptor(parts, purpose: purpose)
|
|
94
|
+
reject!("Export descriptor differs from bytes") unless expected == descriptor
|
|
95
|
+
deadline = [deadline, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30].min
|
|
96
|
+
parts.each do |part|
|
|
97
|
+
offset = 0
|
|
98
|
+
while offset < part.bytesize
|
|
99
|
+
wait(socket, deadline, write: true)
|
|
100
|
+
written = socket.write_nonblock(part.byteslice(offset, [65_536, part.bytesize - offset].min), exception: false)
|
|
101
|
+
offset += written unless written == :wait_writable
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate!(descriptor, purpose)
|
|
107
|
+
total, count, each = LIMITS.fetch(purpose) { reject!("Unknown source transfer purpose") }
|
|
108
|
+
unless descriptor.is_a?(Hash) && descriptor.keys.sort == %w[bytes parts sha256 version] &&
|
|
109
|
+
descriptor["version"] == 1 && descriptor["bytes"].is_a?(Integer) &&
|
|
110
|
+
descriptor["bytes"].between?(0, total) && descriptor["sha256"].is_a?(String) && descriptor["sha256"].match?(SHA256) &&
|
|
111
|
+
descriptor["parts"].is_a?(Array) && descriptor["parts"].length.between?(1, count) &&
|
|
112
|
+
descriptor["parts"].all? { |part| part.is_a?(Hash) && part.keys.sort == %w[bytes sha256] &&
|
|
113
|
+
part["bytes"].is_a?(Integer) && part["bytes"].between?(0, each) &&
|
|
114
|
+
part["sha256"].is_a?(String) && part["sha256"].match?(SHA256) } &&
|
|
115
|
+
descriptor["bytes"] == descriptor["parts"].sum { |part| part["bytes"] } &&
|
|
116
|
+
(!%i[candidate service_input].include?(purpose) || descriptor["bytes"].positive?)
|
|
117
|
+
reject!("Invalid or oversized transfer descriptor")
|
|
118
|
+
end
|
|
119
|
+
if purpose == :inbox_proof &&
|
|
120
|
+
(descriptor.fetch("parts").length != 2 || descriptor.fetch("parts").any? { |part| part.fetch("bytes").zero? })
|
|
121
|
+
reject!("Inbox proof requires exactly two nonempty bounded parts")
|
|
122
|
+
end
|
|
123
|
+
if purpose == :receipt_artifacts
|
|
124
|
+
receipt, *evidence = descriptor.fetch("parts")
|
|
125
|
+
unless receipt.fetch("bytes").between?(1, 16 * 1024) && evidence.sum { |part| part.fetch("bytes") } <= 256 * 1024
|
|
126
|
+
reject!("Receipt or evidence exceeds its fixed transfer limit")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def read(socket, count, deadline)
|
|
135
|
+
loop do
|
|
136
|
+
wait(socket, deadline)
|
|
137
|
+
bytes = socket.read_nonblock(count, exception: false)
|
|
138
|
+
return bytes unless bytes == :wait_readable
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def wait(socket, deadline, write: false)
|
|
143
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
144
|
+
raise Timeout::Error, "Transfer deadline exceeded" unless remaining.positive?
|
|
145
|
+
ready = IO.select(write ? nil : [socket], write ? [socket] : nil, nil, remaining)
|
|
146
|
+
raise Timeout::Error, "Transfer deadline exceeded" unless ready
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def reject!(message)
|
|
150
|
+
raise AttemptErrors::MalformedTransfer, message
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "../../../authority/launch_driver"
|
|
4
|
+
module Ace
|
|
5
|
+
module Assign
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
module Authority
|
|
9
|
+
class Launch < Ace::Support::Cli::Command
|
|
10
|
+
desc "Launch one mapped native worker through the protected gate"
|
|
11
|
+
option :mapping, required: true, desc: "Installed launch mapping ID"
|
|
12
|
+
option :assignment, desc: "Managed assignment ID"
|
|
13
|
+
option :definition, desc: "Current managed assignment definition JSON file"
|
|
14
|
+
option :step, desc: "Assignment subtree scope"
|
|
15
|
+
option :base_head, desc: "Exact base commit SHA"
|
|
16
|
+
option :mutation, desc: "Stable invocation ID for safe replay"
|
|
17
|
+
option :dry_run, type: :boolean, default: false, desc: "Verify installed boundary without mutation or creation"
|
|
18
|
+
def call(**options)
|
|
19
|
+
driver = build_driver(options.fetch(:mapping))
|
|
20
|
+
result = if options[:dry_run]
|
|
21
|
+
driver.preflight
|
|
22
|
+
else
|
|
23
|
+
%i[assignment definition step base_head].each do |key|
|
|
24
|
+
raise ArgumentError, "Missing --#{key.to_s.tr('_', '-')}" unless options[key].is_a?(String) && !options[key].empty?
|
|
25
|
+
end
|
|
26
|
+
raise ArgumentError, "assignment definition is oversized" if File.size(options[:definition]) > 32_768
|
|
27
|
+
args = {assignment_id: options[:assignment], definition_bytes: File.read(options[:definition]),
|
|
28
|
+
scope: options[:step], base_head: options[:base_head]}
|
|
29
|
+
args[:mutation_id] = options[:mutation] if options[:mutation]
|
|
30
|
+
driver.launch(**args)
|
|
31
|
+
end
|
|
32
|
+
puts JSON.generate(result)
|
|
33
|
+
rescue ArgumentError, SystemCallError, Ace::Runtime::Error, Ace::Assign::Error => error
|
|
34
|
+
raise Ace::Support::Cli::Error, error.message
|
|
35
|
+
end
|
|
36
|
+
private
|
|
37
|
+
def build_driver(mapping)
|
|
38
|
+
Ace::Assign::Authority::LaunchDriver.new(mapping_id: mapping)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "../../../authority/server"
|
|
3
|
+
require_relative "../../../authority/router"
|
|
4
|
+
require_relative "../../../authority/launch_lifecycle"
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
module Authority
|
|
10
|
+
class Serve < Ace::Support::Cli::Command
|
|
11
|
+
desc "Serve the installed protected assignment authority"
|
|
12
|
+
option :authority, required: true, desc: "Installed authority ID"
|
|
13
|
+
def call(**options)
|
|
14
|
+
deployment = Ace::Assign::Authority::Deployment.load
|
|
15
|
+
launch = Ace::Assign::Authority::LaunchLifecycle.new(deployment: deployment)
|
|
16
|
+
router = Ace::Assign::Authority::Router.new(launch: launch)
|
|
17
|
+
server = Ace::Assign::Authority::Server.new(authority_id: options.fetch(:authority), deployment: deployment, lifecycle: router)
|
|
18
|
+
signals = %w[INT TERM].to_h { |signal| [signal, Signal.trap(signal) { server.request_stop }] }
|
|
19
|
+
server.serve
|
|
20
|
+
rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
|
|
21
|
+
raise Ace::Support::Cli::Error, error.message
|
|
22
|
+
ensure
|
|
23
|
+
signals&.each { |signal, handler| Signal.trap(signal, handler) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "../../../authority/client"
|
|
4
|
+
module Ace
|
|
5
|
+
module Assign
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
module Authority
|
|
9
|
+
class Status < Ace::Support::Cli::Command
|
|
10
|
+
desc "Inspect canonical protected launch ownership"
|
|
11
|
+
option :mapping, required: true, desc: "Installed launch mapping ID"
|
|
12
|
+
option :assignment, required: true, desc: "Assignment ID"
|
|
13
|
+
option :attempt, required: true, desc: "Attempt ID"
|
|
14
|
+
def call(**options)
|
|
15
|
+
reply = Ace::Assign::Authority::Client.new(mapping_id: options.fetch(:mapping)).call("attempt_status",
|
|
16
|
+
{"assignment_id" => options.fetch(:assignment), "attempt_id" => options.fetch(:attempt)})
|
|
17
|
+
puts JSON.generate(reply.data)
|
|
18
|
+
rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
|
|
19
|
+
raise Ace::Support::Cli::Error, error.message
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|