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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
  3. data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
  4. data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
  5. data/CHANGELOG.md +7 -0
  6. data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
  7. data/lib/ace/assign/authority/assignment_definition.rb +35 -0
  8. data/lib/ace/assign/authority/candidate_transfer.rb +2 -2
  9. data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
  10. data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
  11. data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
  12. data/lib/ace/assign/authority/ordinary_client.rb +70 -0
  13. data/lib/ace/assign/authority/ordinary_completion.rb +94 -0
  14. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
  15. data/lib/ace/assign/authority/ordinary_lifecycle.rb +191 -0
  16. data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
  17. data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
  18. data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
  19. data/lib/ace/assign/authority/ordinary_results.rb +196 -0
  20. data/lib/ace/assign/authority/ordinary_server.rb +75 -0
  21. data/lib/ace/assign/authority/prepared_work.rb +2 -2
  22. data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
  23. data/lib/ace/assign/authority/router.rb +11 -6
  24. data/lib/ace/assign/authority/server.rb +11 -180
  25. data/lib/ace/assign/authority/socket_server.rb +255 -0
  26. data/lib/ace/assign/authority/transfer_codec.rb +2 -2
  27. data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
  28. data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
  29. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
  30. data/lib/ace/assign/molecules/evidence_journal.rb +141 -34
  31. data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
  32. data/lib/ace/assign/molecules/journal_mutation.rb +5 -3
  33. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
  34. data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
  35. data/lib/ace/assign/organisms/attempt_coordinator.rb +51 -36
  36. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
  37. data/lib/ace/assign/version.rb +1 -1
  38. 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:, handlers: [])
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
- handler.authorize_transfer!(request: request, peer: peer, role: role)
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
- @launch.close
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
- require "socket"
3
- require "fileutils"
2
+
4
3
  require_relative "deployment"
5
- require_relative "transfer_codec"
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
- # Existing assignment owner transport; kernel peers select roles, never argv.
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", hitl_service: nil)
16
- @authority_id, @lifecycle, @deployment, @kernel = authority_id, lifecycle, deployment, kernel
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
- @service = deployment.authority(authority_id)
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
- def serve
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
- Molecules::EvidenceJournal.with_event_read_operation do
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