ace-assign 0.69.0 → 0.70.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 +7 -0
- data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
- data/lib/ace/assign/authority/assignment_definition.rb +35 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +2 -2
- data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
- 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 +94 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +191 -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_results.rb +196 -0
- data/lib/ace/assign/authority/ordinary_server.rb +75 -0
- data/lib/ace/assign/authority/prepared_work.rb +2 -2
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- 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 +2 -2
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- 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 +141 -34
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +5 -3
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +51 -36
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +23 -9
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "receipt_transfer"
|
|
4
|
+
require_relative "../molecules/canonical_evidence"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
# A worker submits an attributable result. This never accepts a terminal
|
|
10
|
+
# outcome, an independent review, or an external delivery effect.
|
|
11
|
+
module OrdinaryResults
|
|
12
|
+
RESULT_OPERATIONS = %w[submit_result result_status result_snapshot].freeze
|
|
13
|
+
RESULT_TRANSFERS = {
|
|
14
|
+
"submit_result" => {direction: :upload, purpose: :receipt_artifacts, roles: %i[worker supervisor]},
|
|
15
|
+
"result_snapshot" => {direction: :download, purpose: :receipt_artifacts, roles: %i[worker supervisor]}
|
|
16
|
+
}.freeze
|
|
17
|
+
RESULT_KEYS = %w[assignment_id attempt_id expected_generation pane head receipt_sha256 transfer].freeze
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def validate_result!(request)
|
|
22
|
+
params = request.fetch("params")
|
|
23
|
+
if request.fetch("operation") != "submit_result"
|
|
24
|
+
snapshot = request.fetch("operation") == "result_snapshot"
|
|
25
|
+
strict!(params, %w[assignment_id attempt_id] + (snapshot ? ["result_id"] : []))
|
|
26
|
+
token!(params.fetch("result_id")) if snapshot
|
|
27
|
+
raise ArgumentError, "ordinary result status mutation must be null" unless request.fetch("mutation_id").nil?
|
|
28
|
+
else
|
|
29
|
+
strict!(params, RESULT_KEYS)
|
|
30
|
+
token!(request.fetch("mutation_id"))
|
|
31
|
+
unless params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation").positive? &&
|
|
32
|
+
params.fetch("head").is_a?(String) && params.fetch("head").match?(/\A[0-9a-f]{40}\z/) &&
|
|
33
|
+
params.fetch("receipt_sha256").is_a?(String) && params.fetch("receipt_sha256").match?(/\A[0-9a-f]{64}\z/)
|
|
34
|
+
raise ArgumentError, "ordinary result selection is invalid"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
%w[assignment_id attempt_id].each { |key| token!(params.fetch(key)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ordinary_result(request, peer, role, deadline, transfer)
|
|
41
|
+
validate_result!(request)
|
|
42
|
+
params = request.fetch("params")
|
|
43
|
+
commit = @journal.ref_value
|
|
44
|
+
registered = registration(params.fetch("assignment_id"), commit: commit)
|
|
45
|
+
raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
|
|
46
|
+
if request.fetch("operation") != "submit_result"
|
|
47
|
+
events = result_events(params, commit)
|
|
48
|
+
result = retained_ordinary_result(params, events, commit)
|
|
49
|
+
if request.fetch("operation") == "result_snapshot"
|
|
50
|
+
unless result && result.fetch("result_id") == params.fetch("result_id")
|
|
51
|
+
raise AttemptErrors::NotFound, "ordinary exact result is unavailable"
|
|
52
|
+
end
|
|
53
|
+
receipt = result.fetch("receipt")
|
|
54
|
+
canonical = Molecules::CanonicalEvidence.new(journal: @journal)
|
|
55
|
+
parts = [JSON.generate(receipt)] + receipt.fetch("artifacts").map { |artifact|
|
|
56
|
+
canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
|
|
57
|
+
**ordinary_result_context(result.fetch("binding")), commit: commit)
|
|
58
|
+
}
|
|
59
|
+
authorize!(request, peer, role, deadline)
|
|
60
|
+
return {data: ordinary_result_projection(result).merge("journal_commit" => commit), transfer_parts: parts, replayed: false}
|
|
61
|
+
end
|
|
62
|
+
authorize!(request, peer, role, deadline)
|
|
63
|
+
return {data: {"assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
|
|
64
|
+
"journal_commit" => commit, "generation" => @journal.authority_generation(events),
|
|
65
|
+
"state" => Molecules::CanonicalAttemptState.derive(events), "submitted_result" => result && ordinary_result_projection(result)}, replayed: false}
|
|
66
|
+
end
|
|
67
|
+
unless %i[worker supervisor].include?(role)
|
|
68
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary result requires its original worker"
|
|
69
|
+
end
|
|
70
|
+
authorize_start_replay!(request, peer, role, deadline)
|
|
71
|
+
admitted = ReceiptTransfer.decode(input: transfer, receipt_sha256: params.fetch("receipt_sha256"),
|
|
72
|
+
artifact_field: "artifacts", reference_key: "path")
|
|
73
|
+
digest = Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project, "peer_uid" => peer.fetch("uid")})
|
|
74
|
+
keys = [@exclusion.task_key(registered.fetch("task_id")), @exclusion.assignment_key(params.fetch("assignment_id"))]
|
|
75
|
+
@exclusion.with_shared_multi(keys, deadline: deadline) do
|
|
76
|
+
@journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
77
|
+
mutation_id: request.fetch("mutation_id"), operation: "submit_result", parameters_digest: digest,
|
|
78
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
|
|
79
|
+
replay_authorizer: -> { authorize_start_replay!(request, peer, role, deadline) }) do |events, prefix, generation|
|
|
80
|
+
authorize_start_replay!(request, peer, role, deadline)
|
|
81
|
+
unless Molecules::CanonicalAttemptState.derive(events) == "running"
|
|
82
|
+
raise AttemptErrors::Conflict, "ordinary result requires the original running attempt"
|
|
83
|
+
end
|
|
84
|
+
raise AttemptErrors::Conflict, "ordinary attempt already submitted its result" if retained_ordinary_result(params, events, prefix)
|
|
85
|
+
observed = @admission.execution!(peer: peer, pane: params.fetch("pane"), head: params.fetch("head"), deadline: deadline)
|
|
86
|
+
start_replay_owner!(params, observed.fetch(:identity))
|
|
87
|
+
ordinary_result_plan(params, peer, admitted, observed, events, prefix, generation + 1)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def result_events(params, commit)
|
|
93
|
+
events = @journal.read_events(params.fetch("assignment_id"), commit: commit)
|
|
94
|
+
.select { |event| event.fetch("attempt_id") == params.fetch("attempt_id") }
|
|
95
|
+
unless Models::EvidenceEvent.chain_valid?(events) && events.one? { |event| event["type"] == "process_start" }
|
|
96
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary result attempt evidence is unavailable"
|
|
97
|
+
end
|
|
98
|
+
events
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ordinary_result_plan(params, peer, admitted, observed, events, commit, generation)
|
|
102
|
+
original = admitted.fetch(:receipt)
|
|
103
|
+
original_receipt = Models::ExecutionReceipt.from_h(original)
|
|
104
|
+
unless original_receipt.digest == Atoms::EvidenceDigest.digest(original_receipt.digest_payload)
|
|
105
|
+
raise AttemptErrors::ReceiptRejected, "ordinary original result digest differs"
|
|
106
|
+
end
|
|
107
|
+
identity = observed.fetch(:identity)
|
|
108
|
+
expected_producer = {"actor" => identity.actor, "role" => identity.role, "runtime" => identity.runtime}
|
|
109
|
+
validate_ordinary_result_receipt!(original, expected_producer)
|
|
110
|
+
id = SecureRandom.hex(16)
|
|
111
|
+
binding = params.slice("assignment_id", "attempt_id", "head").merge("authority" => "ordinary", "project_id" => @project,
|
|
112
|
+
"result_id" => id, "worker_uid" => peer.fetch("uid"), "producer" => expected_producer,
|
|
113
|
+
"runtime_binding" => identity.runtime_binding, "checkout" => observed.fetch(:checkout),
|
|
114
|
+
"generation" => generation, "uploaded_receipt_sha256" => admitted.fetch(:receipt_sha256),
|
|
115
|
+
"original_receipt_digest" => original_receipt.digest)
|
|
116
|
+
canonical = Molecules::CanonicalEvidence.new(journal: @journal)
|
|
117
|
+
plan = admitted.fetch(:artifacts).empty? ? {events: [], blobs: {}, references: []} :
|
|
118
|
+
canonical.import_plan(**ordinary_result_context(binding), artifacts: admitted.fetch(:artifacts),
|
|
119
|
+
admitted_after_event_digest: events.last.fetch("digest"))
|
|
120
|
+
receipt = original.except("digest", "recorded_at").merge("artifacts" => plan.fetch(:references).map { |reference|
|
|
121
|
+
{"path" => reference.fetch("ref"), "sha256" => reference.fetch("sha256")}
|
|
122
|
+
})
|
|
123
|
+
pending = @journal.send(:chain_mutation_events, params.fetch("attempt_id"), events.last.fetch("digest"), plan.fetch(:events))
|
|
124
|
+
reader = ->(_data, artifact) { canonical.read_pending({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
|
|
125
|
+
**ordinary_result_context(binding), current_events: events, pending_events: pending, blobs: plan.fetch(:blobs), commit: commit) }
|
|
126
|
+
verified = verify_ordinary_result_receipt(params, receipt, binding, commit, reader)
|
|
127
|
+
if JSON.generate(verified.to_h).bytesize > 16_384
|
|
128
|
+
raise AttemptErrors::ReceiptRejected, "canonical ordinary receipt exceeds its transfer limit"
|
|
129
|
+
end
|
|
130
|
+
payload = {"authority" => "ordinary", "result_id" => id, "binding" => binding, "receipt" => verified.to_h}
|
|
131
|
+
plan.merge(events: plan.fetch(:events) + [{type: "result_submitted", payload: payload}],
|
|
132
|
+
data: ordinary_result_projection(payload))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def ordinary_result_projection(payload)
|
|
136
|
+
payload.slice("result_id").merge(payload.fetch("binding").slice("head", "generation"),
|
|
137
|
+
"receipt_digest" => payload.fetch("receipt").fetch("digest"), "verdict" => payload.fetch("receipt").fetch("verdict"),
|
|
138
|
+
"state" => "running", "authority" => "ordinary")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def ordinary_result_context(binding)
|
|
142
|
+
{kind: "result", project_id: @project, assignment_id: binding.fetch("assignment_id"), attempt_id: binding.fetch("attempt_id"),
|
|
143
|
+
peer_uid: binding.fetch("worker_uid"), binding: binding, request_id_or_event_id: binding.fetch("result_id"), generation: binding.fetch("generation")}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_ordinary_result_receipt!(receipt, producer)
|
|
147
|
+
unless receipt["producer"] == producer && (receipt.keys - Models::ExecutionReceipt.from_h(receipt).to_h.keys).empty? &&
|
|
148
|
+
!receipt["campaign"] && !receipt["review"] && receipt["operation"] != "review" &&
|
|
149
|
+
!Molecules::ReceiptVerifier::EXTERNAL_EFFECT_OPERATIONS.include?(receipt["operation"])
|
|
150
|
+
raise AttemptErrors::ReceiptRejected, "ordinary result producer or operation requires another evidence owner"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def verify_ordinary_result_receipt(params, receipt, binding, commit, reader)
|
|
155
|
+
validate_ordinary_result_receipt!(receipt, binding.fetch("producer"))
|
|
156
|
+
attempt = @journal.derived_attempts(params.fetch("assignment_id"), commit: commit).find { |item| item.attempt_id == params.fetch("attempt_id") }
|
|
157
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary result attempt is unavailable" unless attempt
|
|
158
|
+
Array(receipt["artifacts"]).each { |artifact| reader.call(receipt, artifact) }
|
|
159
|
+
Molecules::ReceiptVerifier.new(artifact_reader: reader).verify_result!(receipt,
|
|
160
|
+
attempt: attempt, live_head: binding.fetch("head"), repo_root: @journal.repo_root)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def retained_ordinary_result(params, events, commit)
|
|
164
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary result chain is corrupt" unless Models::EvidenceEvent.chain_valid?(events)
|
|
165
|
+
records = events.select { |event| event["type"] == "result_submitted" }
|
|
166
|
+
replies = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "submit_result" }
|
|
167
|
+
return nil if records.empty? && replies.empty?
|
|
168
|
+
unless records.one? && replies.one?
|
|
169
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary result provenance is missing or duplicated"
|
|
170
|
+
end
|
|
171
|
+
payload = records.first.fetch("payload")
|
|
172
|
+
binding = payload.fetch("binding")
|
|
173
|
+
start = events.find { |event| event["type"] == "process_start" }.fetch("payload")
|
|
174
|
+
unless payload.keys.sort == %w[authority binding receipt result_id] && payload["authority"] == "ordinary" &&
|
|
175
|
+
payload["result_id"].is_a?(String) && payload["result_id"].match?(/\A[0-9a-f]{32}\z/) &&
|
|
176
|
+
binding.keys.sort == %w[assignment_id attempt_id authority checkout generation head original_receipt_digest producer project_id result_id runtime_binding uploaded_receipt_sha256 worker_uid] &&
|
|
177
|
+
binding["authority"] == "ordinary" && binding["result_id"] == payload["result_id"] &&
|
|
178
|
+
binding["head"] == binding.dig("checkout", "head") &&
|
|
179
|
+
binding.values_at("project_id", "assignment_id", "attempt_id", "runtime_binding") ==
|
|
180
|
+
[@project, params.fetch("assignment_id"), params.fetch("attempt_id"), start.fetch("runtime_binding")] &&
|
|
181
|
+
binding.fetch("producer") == start.slice("actor", "role", "runtime") &&
|
|
182
|
+
binding.fetch("worker_uid") == start.dig("runtime_binding", "process_identity", "uid") &&
|
|
183
|
+
payload.fetch("receipt").fetch("producer") == binding.fetch("producer") &&
|
|
184
|
+
replies.first.dig("payload", "data").slice(*ordinary_result_projection(payload).keys) == ordinary_result_projection(payload)
|
|
185
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary result owner differs"
|
|
186
|
+
end
|
|
187
|
+
canonical = Molecules::CanonicalEvidence.new(journal: @journal)
|
|
188
|
+
reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
|
|
189
|
+
**ordinary_result_context(binding), commit: commit) }
|
|
190
|
+
verify_ordinary_result_receipt(params, payload.fetch("receipt"), binding, commit, reader)
|
|
191
|
+
payload
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "socket_server"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Authority
|
|
8
|
+
# Explicit ordinary admission over Assign's existing wire/transfer owner.
|
|
9
|
+
# The Lab composition supplies a trusted installed admission object and
|
|
10
|
+
# closed handlers. No protected Deployment or kernel adapter is replaced.
|
|
11
|
+
class OrdinaryServer < SocketServer
|
|
12
|
+
def initialize(admission:, lifecycle:, services: [])
|
|
13
|
+
@admission = admission
|
|
14
|
+
super(service: admission.service, lifecycle: lifecycle, services: services)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def verify_listener!
|
|
20
|
+
@admission.verify_listener!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def authorize_transfer!(request:, peer:, role:, deadline:)
|
|
24
|
+
@lifecycle.authorize_transfer!(request: request, peer: peer, role: role, deadline: deadline)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dispatch_lifecycle(request:, peer:, role:, deadline:, transfer: nil)
|
|
28
|
+
@lifecycle.dispatch(request: request, peer: peer, role: role, deadline: deadline, transfer: transfer)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def receive(socket)
|
|
32
|
+
deadline = wire.deadline(30)
|
|
33
|
+
peer = @admission.peer(socket, deadline: deadline)
|
|
34
|
+
ingress = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
35
|
+
frame = wire.read(ingress, deadline: [deadline, wire.deadline(5)].min, limit: 16_384, with_size: true)
|
|
36
|
+
request = frame.fetch(:data)
|
|
37
|
+
unless request.is_a?(Hash) && request.keys.sort == %w[mutation_id operation params project_id version] &&
|
|
38
|
+
request["version"].is_a?(Integer) && request["version"] == 1 &&
|
|
39
|
+
request["operation"].is_a?(String) && request["params"].is_a?(Hash)
|
|
40
|
+
raise ArgumentError, "invalid ordinary authority envelope"
|
|
41
|
+
end
|
|
42
|
+
role = @admission.authorize!(request: request, peer: peer, deadline: deadline)
|
|
43
|
+
binding = @lifecycle.transfer_binding(request)
|
|
44
|
+
bodyless_read!(ingress, deadline) unless binding && binding.fetch(:direction) == :upload
|
|
45
|
+
# Both binary bodies and EOF use ancillary-rejecting reads. Replies
|
|
46
|
+
# use the original duplex socket through the wrapper below.
|
|
47
|
+
dispatch_request(Duplex.new(socket, ingress), request, peer, role, deadline, frame_bytes: frame.fetch(:bytesize))
|
|
48
|
+
rescue ArgumentError, KeyError, AttemptErrors::MalformedTransfer
|
|
49
|
+
refusal(socket, "invalid_input")
|
|
50
|
+
rescue AttemptErrors::UnauthorizedIdentity, SecurityError
|
|
51
|
+
refusal(socket, "unauthorized")
|
|
52
|
+
rescue AttemptErrors::Conflict
|
|
53
|
+
refusal(socket, "conflict")
|
|
54
|
+
rescue AttemptErrors::NotFound
|
|
55
|
+
refusal(socket, "missing")
|
|
56
|
+
rescue AttemptErrors::BoundedResultUnavailable
|
|
57
|
+
refusal(socket, "bounded_result")
|
|
58
|
+
rescue StandardError
|
|
59
|
+
refusal(socket, "evidence_unavailable")
|
|
60
|
+
ensure
|
|
61
|
+
socket.close unless socket.closed?
|
|
62
|
+
@mutex.synchronize { @connections.delete(socket) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class Duplex
|
|
66
|
+
def initialize(socket, ingress) = (@socket, @ingress = socket, ingress)
|
|
67
|
+
def to_io = @socket
|
|
68
|
+
def read_nonblock(*args, **options) = @ingress.read_nonblock(*args, **options)
|
|
69
|
+
def write_nonblock(*args, **options) = @socket.write_nonblock(*args, **options)
|
|
70
|
+
end
|
|
71
|
+
private_constant :Duplex
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -69,9 +69,9 @@ module Ace
|
|
|
69
69
|
# The existing transport verifies complete Git objects before this
|
|
70
70
|
# shared byte contract parses anything. Preserve the original input for
|
|
71
71
|
# the eventual registration journal; CandidateTransfer may reexport it.
|
|
72
|
-
def self.admit(bytes:, head:, tree:, sha256:, size:, root:)
|
|
72
|
+
def self.admit(bytes:, head:, tree:, sha256:, size:, root:, deadline: nil)
|
|
73
73
|
transfer = CandidateTransfer.new(root: root)
|
|
74
|
-
transfer.admit(bytes: bytes, head: head, sha256: sha256, size: size) do |repository, deadline, result|
|
|
74
|
+
transfer.admit(bytes: bytes, head: head, sha256: sha256, size: size, deadline: deadline) do |repository, deadline, result|
|
|
75
75
|
raise ArgumentError, "prepared_input_invalid: Git tree differs" unless result.fetch("tree") == tree
|
|
76
76
|
new(files: transfer.prepared_files(repository: repository, deadline: deadline, head: head))
|
|
77
77
|
end
|
|
@@ -12,7 +12,7 @@ module Ace
|
|
|
12
12
|
class ProtectedAssignmentContext
|
|
13
13
|
attr_reader :uid
|
|
14
14
|
|
|
15
|
-
def self.load
|
|
15
|
+
def self.load(uid: Process.uid)
|
|
16
16
|
paths = [Deployment::PATH, DeploymentHistory::PATH]
|
|
17
17
|
installed = paths.any? do |path|
|
|
18
18
|
begin
|
|
@@ -22,13 +22,13 @@ module Ace
|
|
|
22
22
|
false
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
|
-
return new(deployment: nil, history: nil) unless installed
|
|
25
|
+
return new(deployment: nil, history: nil, uid: uid) unless installed
|
|
26
26
|
deployment = Deployment.load
|
|
27
27
|
history = DeploymentHistory.load
|
|
28
28
|
unless history.selects?(deployment)
|
|
29
29
|
raise AttemptErrors::EvidenceUnavailable, "installed protected descriptor is not retained"
|
|
30
30
|
end
|
|
31
|
-
new(deployment: deployment, history: history)
|
|
31
|
+
new(deployment: deployment, history: history, uid: uid)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def initialize(deployment:, history:, uid: Process.uid, kernel: nil, env: ENV,
|
|
@@ -4,10 +4,10 @@ module Ace
|
|
|
4
4
|
module Authority
|
|
5
5
|
# Source-owned handlers share one listener. Requests never name a method.
|
|
6
6
|
class Router
|
|
7
|
-
def initialize(launch
|
|
7
|
+
def initialize(launch: nil, handlers: [])
|
|
8
8
|
@launch = launch
|
|
9
9
|
@routes = {}
|
|
10
|
-
([launch] + handlers).each do |handler|
|
|
10
|
+
([launch].compact + handlers).each do |handler|
|
|
11
11
|
handler.class::OPERATIONS.each do |operation|
|
|
12
12
|
raise ArgumentError, "duplicate authority operation" if @routes.key?(operation)
|
|
13
13
|
@routes[operation] = handler
|
|
@@ -16,11 +16,12 @@ module Ace
|
|
|
16
16
|
@routes.freeze
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def dispatch(request:, peer:, role:, transfer: nil)
|
|
19
|
+
def dispatch(request:, peer:, role:, transfer: nil, deadline: nil)
|
|
20
20
|
validate_review_namespace!(request)
|
|
21
21
|
handler = @routes.fetch(request.fetch("operation")) { raise ArgumentError, "unknown authority operation" }
|
|
22
22
|
options = {request: request, peer: peer, role: role}
|
|
23
23
|
options[:transfer] = transfer if transfer
|
|
24
|
+
options[:deadline] = deadline if deadline
|
|
24
25
|
handler.dispatch(**options)
|
|
25
26
|
end
|
|
26
27
|
|
|
@@ -42,10 +43,12 @@ module Ace
|
|
|
42
43
|
binding
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
def authorize_transfer!(request:, peer:, role:)
|
|
46
|
+
def authorize_transfer!(request:, peer:, role:, deadline: nil)
|
|
46
47
|
validate_review_namespace!(request)
|
|
47
48
|
handler = @routes.fetch(request.fetch("operation"))
|
|
48
|
-
|
|
49
|
+
options = {request: request, peer: peer, role: role}
|
|
50
|
+
options[:deadline] = deadline if deadline
|
|
51
|
+
handler.authorize_transfer!(**options)
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def validate_review_namespace!(request)
|
|
@@ -60,14 +63,16 @@ module Ace
|
|
|
60
63
|
private :validate_review_namespace!
|
|
61
64
|
|
|
62
65
|
def close
|
|
63
|
-
@
|
|
66
|
+
@routes.values.uniq.each { |handler| handler.close if handler.respond_to?(:close) }
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
def serve_launch_control!(**options)
|
|
70
|
+
raise AttemptErrors::EvidenceUnavailable, "launch control is unavailable in this composition" unless @launch
|
|
67
71
|
@launch.serve_launch_control!(**options)
|
|
68
72
|
end
|
|
69
73
|
|
|
70
74
|
def gate_ready(**options)
|
|
75
|
+
raise AttemptErrors::EvidenceUnavailable, "launch gate is unavailable in this composition" unless @launch
|
|
71
76
|
@launch.gate_ready(**options)
|
|
72
77
|
end
|
|
73
78
|
end
|
|
@@ -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
|