ace-herdr 0.3.2 → 0.5.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/herdr/config.yml +0 -4
- data/CHANGELOG.md +30 -0
- data/docs/usage.md +65 -32
- data/lib/ace/herdr/cli/commands/inbox.rb +81 -31
- data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
- data/lib/ace/herdr/cli/commands/support.rb +2 -6
- data/lib/ace/herdr/cli.rb +3 -0
- data/lib/ace/herdr/errors.rb +23 -0
- data/lib/ace/herdr/molecules/bounded_process.rb +266 -51
- data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -5
- data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
- data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
- data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
- data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
- data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
- data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
- data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
- data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
- data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
- data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
- data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
- data/lib/ace/herdr/molecules/native_source.rb +189 -0
- data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
- data/lib/ace/herdr/molecules/protected_native_control.rb +259 -0
- data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
- data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
- data/lib/ace/herdr/native_source/selection.json +24 -0
- data/lib/ace/herdr/organisms/deliverer.rb +2 -3
- data/lib/ace/herdr/organisms/inbox.rb +384 -137
- data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
- data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
- data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
- data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
- data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
- data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
- data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
- data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
- data/lib/ace/herdr/organisms/runtime_adapter.rb +30 -1
- data/lib/ace/herdr/version.rb +1 -1
- data/lib/ace/herdr.rb +5 -28
- metadata +69 -8
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "../molecules/inbox_direct_effect_binding"
|
|
3
|
+
require_relative "../molecules/inbox_direct_result"
|
|
4
|
+
|
|
5
|
+
require_relative "../molecules/protected_native_control"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Herdr
|
|
9
|
+
module Organisms
|
|
10
|
+
# Fixed methods on the same context admission and DeliveryRecord owner.
|
|
11
|
+
module InboxContextDirectEffects
|
|
12
|
+
def enqueue_context(operation_id:, key_generation:, event_id:, attempt_id:, reverse:, payload_bytes:, payload_sha256:, payload:, original:, peer:)
|
|
13
|
+
authorize!(peer, purpose: "enqueue")
|
|
14
|
+
binding = Molecules::InboxDirectEffectBinding.build(purpose: "enqueue", event_id: event_id,
|
|
15
|
+
attempt_id: attempt_id, key_generation: key_generation, original: original,
|
|
16
|
+
selection: {"reverse" => reverse, "payload_bytes" => payload_bytes, "payload_sha256" => payload_sha256})
|
|
17
|
+
unless payload.is_a?(String) && payload.bytesize == payload_bytes &&
|
|
18
|
+
payload.encoding == Encoding::UTF_8 && payload.valid_encoding? && !payload.include?("\0") &&
|
|
19
|
+
Digest::SHA256.hexdigest(payload) == payload_sha256
|
|
20
|
+
raise ValidationError, "direct enqueue payload differs"
|
|
21
|
+
end
|
|
22
|
+
selected = @keys.selected
|
|
23
|
+
original_query = direct_original_query!(operation_id, binding, selected, peer, payload_sha256: payload_sha256)
|
|
24
|
+
unless original_query.fetch("process_binding").values_at("session", "pane") == reverse.values_at("session", "pane")
|
|
25
|
+
raise ValidationError, "direct reverse address differs from original launch"
|
|
26
|
+
end
|
|
27
|
+
dispatch = false
|
|
28
|
+
transaction do |state|
|
|
29
|
+
operation = operation!(state, operation_id, peer)
|
|
30
|
+
unless operation.values_at("purpose", "event_id", "key_generation") == ["enqueue", event_id, key_generation] && operation.fetch("original") == original.merge("attempt_id" => attempt_id) &&
|
|
31
|
+
selected.fetch(:snapshot) == state.fetch("key")
|
|
32
|
+
raise ValidationError, "direct enqueue admission differs"
|
|
33
|
+
end
|
|
34
|
+
if operation["original_binding_digest"] && operation.fetch("original_binding_digest") != original_query.fetch("original_binding_digest")
|
|
35
|
+
raise ValidationError, "direct original launch record changed"
|
|
36
|
+
end
|
|
37
|
+
if operation["effect_binding"]
|
|
38
|
+
raise ValidationError, "direct enqueue input changed" unless operation.fetch("effect_binding") == binding
|
|
39
|
+
raise ValidationError, "direct enqueue issuer remains unresolved" if @effect_issuers.key?(operation_id) || operation.fetch("in_flight") == 1
|
|
40
|
+
direct_idle_readback!(state, operation)
|
|
41
|
+
else
|
|
42
|
+
raise ValidationError, "direct enqueue admission is uncertain" unless operation.fetch("in_flight").zero?
|
|
43
|
+
operation["original_binding_digest"] = original_query.fetch("original_binding_digest")
|
|
44
|
+
operation["effect_binding"] = binding
|
|
45
|
+
operation["in_flight"] = 1
|
|
46
|
+
operation["issuer_state"] = "running"
|
|
47
|
+
operation["issuer_epoch"] = @epoch
|
|
48
|
+
@effect_issuers[operation_id] = Thread.current
|
|
49
|
+
dispatch = true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
box = source_inbox!(selected)
|
|
53
|
+
if dispatch
|
|
54
|
+
box.enqueue(event: event_id, attempt: attempt_id, ref: reverse, payload: payload, original_context: original, original_binding_digest: original_query.fetch("original_binding_digest"))
|
|
55
|
+
record_direct_return!(operation_id, peer, binding)
|
|
56
|
+
end
|
|
57
|
+
result = box.verify_direct_enqueue(binding)
|
|
58
|
+
if dispatch
|
|
59
|
+
transaction do |state|
|
|
60
|
+
operation = operation!(state, operation_id, peer)
|
|
61
|
+
unless operation["effect_binding"] == binding && @effect_issuers[operation_id].equal?(Thread.current) &&
|
|
62
|
+
selected.fetch(:snapshot) == state.fetch("key")
|
|
63
|
+
raise ValidationError, "direct enqueue completion changed"
|
|
64
|
+
end
|
|
65
|
+
direct_idle_readback!(state, operation)
|
|
66
|
+
operation["in_flight"] = 0
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
immutable_effect(Molecules::InboxDirectResult.build(operation: "enqueue_context", record: result, admission_state: "idle"))
|
|
70
|
+
ensure
|
|
71
|
+
if dispatch
|
|
72
|
+
transaction { |_state| @effect_issuers.delete(operation_id) if @effect_issuers[operation_id].equal?(Thread.current) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def deliver_context(operation_id:, key_generation:, event_id:, attempt_id:, expected_claim_generation:, original:, peer:)
|
|
77
|
+
authorize!(peer, purpose: "deliver")
|
|
78
|
+
binding = Molecules::InboxDirectEffectBinding.build(purpose: "deliver", event_id: event_id,
|
|
79
|
+
attempt_id: attempt_id, key_generation: key_generation, original: original,
|
|
80
|
+
selection: {"expected_claim_generation" => expected_claim_generation})
|
|
81
|
+
selected = @keys.selected
|
|
82
|
+
record = source_inbox!(selected).retained_status(event: event_id)
|
|
83
|
+
original_query = direct_original_query!(operation_id, binding, selected, peer, payload_sha256: record.fetch("payload_sha256"))
|
|
84
|
+
unless record.fetch("original_binding_digest") == original_query.fetch("original_binding_digest")
|
|
85
|
+
raise ValidationError, "direct retained original record differs"
|
|
86
|
+
end
|
|
87
|
+
dispatch = false
|
|
88
|
+
transaction do |state|
|
|
89
|
+
operation = operation!(state, operation_id, peer)
|
|
90
|
+
unless operation.values_at("purpose", "event_id", "key_generation") == ["deliver", event_id, key_generation] && operation.fetch("original") == original.merge("attempt_id" => attempt_id) &&
|
|
91
|
+
selected.fetch(:snapshot) == state.fetch("key")
|
|
92
|
+
raise ValidationError, "direct delivery admission differs"
|
|
93
|
+
end
|
|
94
|
+
direct_delivery_predecessors!(state, operation_id, event_id, key_generation)
|
|
95
|
+
if operation["original_binding_digest"] && operation.fetch("original_binding_digest") != original_query.fetch("original_binding_digest")
|
|
96
|
+
raise ValidationError, "direct original launch record changed"
|
|
97
|
+
end
|
|
98
|
+
if operation["effect_binding"]
|
|
99
|
+
raise ValidationError, "direct delivery input changed" unless operation.fetch("effect_binding") == binding
|
|
100
|
+
raise ValidationError, "direct delivery issuer remains live" if @effect_issuers.key?(operation_id)
|
|
101
|
+
# Unknown replay is observation only; it cannot become known idle.
|
|
102
|
+
direct_idle_readback!(state, operation) if operation.fetch("in_flight").zero?
|
|
103
|
+
else
|
|
104
|
+
raise ValidationError, "direct delivery admission is uncertain" unless operation.fetch("in_flight").zero?
|
|
105
|
+
source_inbox!(selected).verify_direct_delivery(binding)
|
|
106
|
+
operation["original_binding_digest"] = original_query.fetch("original_binding_digest")
|
|
107
|
+
operation["effect_binding"] = binding
|
|
108
|
+
operation["in_flight"] = 1
|
|
109
|
+
operation["issuer_state"] = "running"
|
|
110
|
+
operation["issuer_epoch"] = @epoch
|
|
111
|
+
@effect_issuers[operation_id] = Thread.current
|
|
112
|
+
dispatch = true
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
box = source_inbox!(selected)
|
|
116
|
+
if dispatch
|
|
117
|
+
transaction do |state|
|
|
118
|
+
operation = operation!(state, operation_id, peer)
|
|
119
|
+
unless operation["effect_binding"] == binding && operation["issuer_state"] == "running" &&
|
|
120
|
+
@effect_issuers[operation_id].equal?(Thread.current)
|
|
121
|
+
raise ValidationError, "direct claim preparation ownership differs"
|
|
122
|
+
end
|
|
123
|
+
direct_delivery_predecessors!(state, operation_id, event_id, key_generation)
|
|
124
|
+
operation["admitted_claim"] = box.prepare_direct_delivery(event: event_id,
|
|
125
|
+
expected_claim_generation: expected_claim_generation, expected_attempt: attempt_id,
|
|
126
|
+
claim_owner: Digest::SHA256.hexdigest(JSON.generate([@context_id, operation_id])),
|
|
127
|
+
operation_id: operation_id, key_generation: key_generation, effect_binding: binding)
|
|
128
|
+
end
|
|
129
|
+
claim = transaction { |state| copy(operation!(state, operation_id, peer)["admitted_claim"]) }
|
|
130
|
+
unless claim && claim.fetch("kind") == "wake"
|
|
131
|
+
box.deliver(event: event_id, expected_claim_generation: expected_claim_generation,
|
|
132
|
+
expected_attempt: attempt_id, prepared_claim: claim && claim.slice("claim_generation", "claim_owner")) do |text|
|
|
133
|
+
native_binding = original_query.fetch("process_binding").merge("guarded_origin" => original_query.fetch("guarded_origin"))
|
|
134
|
+
direct_queue_control!(original_query).inbox_prompt(binding: native_binding, text: text)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
direct_guarded_wake!(operation_id, peer, binding, claim, original_query, box) if claim
|
|
138
|
+
record_direct_return!(operation_id, peer, binding)
|
|
139
|
+
end
|
|
140
|
+
verified = box.verify_direct_delivery(binding)
|
|
141
|
+
transaction do |state|
|
|
142
|
+
operation = operation!(state, operation_id, peer)
|
|
143
|
+
unless operation["effect_binding"] == binding && selected.fetch(:snapshot) == state.fetch("key")
|
|
144
|
+
raise ValidationError, "direct delivery completion changed"
|
|
145
|
+
end
|
|
146
|
+
if dispatch && verified.fetch("idle") && @effect_issuers[operation_id].equal?(Thread.current)
|
|
147
|
+
direct_idle_readback!(state, operation)
|
|
148
|
+
operation["in_flight"] = 0
|
|
149
|
+
end
|
|
150
|
+
Molecules::InboxDirectResult.build(operation: "deliver_context", record: verified.fetch("record"),
|
|
151
|
+
admission_state: operation.fetch("in_flight").zero? ? "idle" : "unknown")
|
|
152
|
+
end
|
|
153
|
+
ensure
|
|
154
|
+
if dispatch
|
|
155
|
+
transaction { |_state| @effect_issuers.delete(operation_id) if @effect_issuers[operation_id].equal?(Thread.current) }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def status_context(event_id:, attempt_id:, original:, peer:)
|
|
160
|
+
grant = authorize!(peer)
|
|
161
|
+
unless (grant.fetch("purposes") & %w[enqueue deliver]).any?
|
|
162
|
+
raise ValidationError, "context event observation is unauthorized"
|
|
163
|
+
end
|
|
164
|
+
token!(event_id); token!(attempt_id)
|
|
165
|
+
Molecules::InboxDirectEffectBinding.original!(original)
|
|
166
|
+
context!(original.fetch("inbox_context_id"))
|
|
167
|
+
record = source_inbox!(@keys.selected).retained_status(event: event_id)
|
|
168
|
+
raise ValidationError, "context event attempt differs" unless record.fetch("attempt_id") == attempt_id && record.fetch("original_context") == original
|
|
169
|
+
immutable_effect(Molecules::InboxDirectResult.build(operation: "status_context", record: record))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
# Remote canonical identity read occurs outside the store transaction.
|
|
175
|
+
# Both sides recheck the original durable admission/key before effects.
|
|
176
|
+
def direct_original_query!(operation_id, binding, selected, peer, payload_sha256:)
|
|
177
|
+
transaction do |state|
|
|
178
|
+
operation = operation!(state, operation_id, peer)
|
|
179
|
+
unless selected.fetch(:snapshot) == state.fetch("key") &&
|
|
180
|
+
operation.fetch("original") == binding.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS, "attempt_id") &&
|
|
181
|
+
operation.values_at("purpose", "event_id") == binding.values_at("purpose", "event_id")
|
|
182
|
+
raise ValidationError, "original query admission differs"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
result = @completion.original!(assignment_id: binding.fetch("assignment_id"), attempt_id: binding.fetch("attempt_id"),
|
|
186
|
+
event_id: binding.fetch("event_id"), inbox_context_id: binding.fetch("inbox_context_id"), purpose: binding.fetch("purpose"),
|
|
187
|
+
payload_sha256: payload_sha256)
|
|
188
|
+
unless result.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS, "attempt_id", "event_id", "purpose") ==
|
|
189
|
+
binding.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS, "attempt_id", "event_id", "purpose")
|
|
190
|
+
raise ValidationError, "original authority association differs"
|
|
191
|
+
end
|
|
192
|
+
result
|
|
193
|
+
rescue NoMethodError, KeyError, TypeError
|
|
194
|
+
raise ValidationError, "original authority identity is unavailable"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def direct_queue_control!(original)
|
|
198
|
+
native = original.fetch("native_binding")
|
|
199
|
+
server = native.fetch("server_identity")
|
|
200
|
+
mapping = {"worker_uid" => server.fetch("uid"), "worker_gid" => server.fetch("gid"), "worker_groups" => server.fetch("groups"),
|
|
201
|
+
"native" => original.fetch("native_channel").merge("server_identity" => server, "socket_identity" => native.fetch("socket_identity"))}
|
|
202
|
+
Molecules::ProtectedNativeControl.new(mapping: mapping, kernel: @kernel)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def direct_guarded_wake!(operation_id, peer, binding, claim, original, box)
|
|
206
|
+
return unless box.guarded_wake_pending?(event: binding.fetch("event_id"), binding: binding, claim: claim)
|
|
207
|
+
control = direct_queue_control!(original)
|
|
208
|
+
native_binding = original.fetch("process_binding").merge("guarded_origin" => original.fetch("guarded_origin"))
|
|
209
|
+
begin
|
|
210
|
+
control.prompt_preflight!(native_binding)
|
|
211
|
+
rescue Ace::Runtime::RuntimeUnavailableError
|
|
212
|
+
# This readonly preflight never entered notification IO. Pending
|
|
213
|
+
# stays positively unissued; a fresh explicit retry owns recovery.
|
|
214
|
+
return
|
|
215
|
+
end
|
|
216
|
+
issuing = transaction do |state|
|
|
217
|
+
operation = operation!(state, operation_id, peer)
|
|
218
|
+
unless operation["admitted_claim"] == claim && operation["effect_binding"] == binding &&
|
|
219
|
+
operation["issuer_state"] == "running" && @effect_issuers[operation_id].equal?(Thread.current) &&
|
|
220
|
+
operation.fetch("original_binding_digest") == original.fetch("original_binding_digest")
|
|
221
|
+
raise ValidationError, "guarded wake issuer changed"
|
|
222
|
+
end
|
|
223
|
+
direct_delivery_predecessors!(state, operation_id, binding.fetch("event_id"), operation.fetch("key_generation"))
|
|
224
|
+
box.prepare_guarded_wake(event: binding.fetch("event_id"), binding: binding, claim: claim, operation_id: operation_id)
|
|
225
|
+
end
|
|
226
|
+
return unless issuing
|
|
227
|
+
result = control.queue_wake(binding: native_binding)
|
|
228
|
+
transaction do |state|
|
|
229
|
+
operation = operation!(state, operation_id, peer)
|
|
230
|
+
unless operation["admitted_claim"] == claim && operation["effect_binding"] == binding &&
|
|
231
|
+
operation["issuer_state"] == "running" && @effect_issuers[operation_id].equal?(Thread.current)
|
|
232
|
+
raise ValidationError, "guarded wake finish admission changed"
|
|
233
|
+
end
|
|
234
|
+
box.finish_guarded_wake(event: binding.fetch("event_id"), binding: binding, claim: claim, issuing: issuing, result: result)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def record_direct_return!(operation_id, peer, binding)
|
|
239
|
+
transaction do |state|
|
|
240
|
+
operation = operation!(state, operation_id, peer)
|
|
241
|
+
unless operation["effect_binding"] == binding && operation["issuer_state"] == "running" &&
|
|
242
|
+
@effect_issuers[operation_id].equal?(Thread.current)
|
|
243
|
+
raise ValidationError, "direct issuer return ownership differs"
|
|
244
|
+
end
|
|
245
|
+
operation["issuer_state"] = "returned"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def direct_binding?(operation)
|
|
250
|
+
operation.dig("effect_binding", "schema") == Molecules::InboxDirectEffectBinding::SCHEMA
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def direct_delivery_predecessors!(state, operation_id, event_id, key_generation)
|
|
254
|
+
state.fetch("operations").each do |id, other|
|
|
255
|
+
next if id == operation_id || other.fetch("event_id") != event_id || other.fetch("key_generation") != key_generation
|
|
256
|
+
if other.fetch("in_flight") == 1 || @effect_issuers.key?(id)
|
|
257
|
+
raise ValidationError, "direct delivery preceding issuer remains unresolved"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def direct_idle_readback!(state, operation)
|
|
263
|
+
selected = @keys.selected
|
|
264
|
+
raise ValidationError, "direct readback key changed" unless selected.fetch(:snapshot) == state.fetch("key")
|
|
265
|
+
binding = Molecules::InboxDirectEffectBinding.verify!(operation.fetch("effect_binding"), key_generation: operation.fetch("key_generation"))
|
|
266
|
+
box = source_inbox!(selected)
|
|
267
|
+
record = if binding.fetch("purpose") == "enqueue"
|
|
268
|
+
box.verify_direct_enqueue(binding)
|
|
269
|
+
else
|
|
270
|
+
claim = operation["admitted_claim"]
|
|
271
|
+
if claim && claim["kind"] == "wake"
|
|
272
|
+
operation_id = state.fetch("operations").find { |_id, retained| retained.equal?(operation) }&.first
|
|
273
|
+
box.verify_guarded_wake_admission!(binding: binding, claim: claim, operation_id: operation_id)
|
|
274
|
+
end
|
|
275
|
+
box.verify_direct_delivery(binding, require_idle: true).fetch("record")
|
|
276
|
+
end
|
|
277
|
+
unless record.fetch("original_binding_digest") == operation.fetch("original_binding_digest")
|
|
278
|
+
raise ValidationError, "direct readback original record changed"
|
|
279
|
+
end
|
|
280
|
+
record
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../molecules/inbox_context_completion_client"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Herdr
|
|
7
|
+
module Organisms
|
|
8
|
+
# Source effect methods on the same context owner and admission metadata.
|
|
9
|
+
module InboxContextEffects
|
|
10
|
+
SNAPSHOT_FIELDS = %w[event_id attempt_id payload_sha256 claim_generation state origin_target target binding].freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def source_inbox!(selected)
|
|
15
|
+
raise ValidationError, "fixed context Inbox owner is unavailable" unless @inbox.is_a?(Inbox)
|
|
16
|
+
@inbox
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def immutable_effect(value)
|
|
20
|
+
case value
|
|
21
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable_effect(item)] }.freeze
|
|
22
|
+
when Array then value.map { |item| immutable_effect(item) }.freeze
|
|
23
|
+
when String then value.dup.freeze
|
|
24
|
+
else value.freeze
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require_relative "inbox_context_server"
|
|
5
|
+
require_relative "../molecules/inbox_context_service_configuration"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Herdr
|
|
9
|
+
module Organisms
|
|
10
|
+
# Connection lifetimes never retire admission metadata. Stop prevents
|
|
11
|
+
# new ingress, then waits within one deadline for existing handlers.
|
|
12
|
+
class InboxContextListener
|
|
13
|
+
MAX_HANDLERS = 8
|
|
14
|
+
STOP_SECONDS = 35
|
|
15
|
+
|
|
16
|
+
def initialize(configuration:, server:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new,
|
|
17
|
+
protection: Ace::Runtime::Molecules::ProtectedSocket, handler_dispatcher: nil)
|
|
18
|
+
unless configuration.is_a?(Molecules::InboxContextServiceConfiguration)
|
|
19
|
+
raise ValidationError, "context listener configuration is not held"
|
|
20
|
+
end
|
|
21
|
+
@configuration, @server, @kernel, @protection = configuration, server, kernel, protection
|
|
22
|
+
@handler_dispatcher = handler_dispatcher
|
|
23
|
+
@path = configuration.data.fetch("control_socket_path")
|
|
24
|
+
@credentials = configuration.data.fetch("owner_credentials")
|
|
25
|
+
@mutex, @handlers, @stopping = Mutex.new, {}, false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def serve
|
|
29
|
+
me = @kernel.capture(Process.pid)
|
|
30
|
+
unless me.values_at("uid", "gid", "groups") == @credentials.values_at("uid", "gid", "groups")
|
|
31
|
+
raise ValidationError, "context listener principal differs"
|
|
32
|
+
end
|
|
33
|
+
@kernel.live!(me)
|
|
34
|
+
@protection.root_path!(File.dirname(@path), directory: true, owner: @credentials.fetch("uid"))
|
|
35
|
+
verify_socket_traversal!
|
|
36
|
+
@lock = File.open("#{@path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600)
|
|
37
|
+
stat = @lock.stat
|
|
38
|
+
unless stat.file? && stat.uid == @credentials.fetch("uid") && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600 &&
|
|
39
|
+
[stat.dev, stat.ino] == File.lstat("#{@path}.lock").then { |current| [current.dev, current.ino] } &&
|
|
40
|
+
@lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
41
|
+
raise ValidationError, "context listener lease differs or is held"
|
|
42
|
+
end
|
|
43
|
+
@lock.close_on_exec = true
|
|
44
|
+
remove_stale_endpoint!
|
|
45
|
+
@listener = UNIXServer.new(@path)
|
|
46
|
+
File.chown(nil, @configuration.data.fetch("socket_gid"), @path)
|
|
47
|
+
File.chmod(0o660, @path)
|
|
48
|
+
@identity = @protection.socket_identity(@path)
|
|
49
|
+
until stopping?
|
|
50
|
+
socket = @listener.accept
|
|
51
|
+
accepted_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 60
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
if @stopping || @handlers.size >= MAX_HANDLERS
|
|
54
|
+
socket.close
|
|
55
|
+
else
|
|
56
|
+
gate = Queue.new
|
|
57
|
+
thread = Thread.new(socket, accepted_deadline) do |connection, operation_deadline|
|
|
58
|
+
gate.pop
|
|
59
|
+
begin
|
|
60
|
+
if @handler_dispatcher
|
|
61
|
+
@handler_dispatcher.call(deadline: operation_deadline) { @server.handle(connection) }
|
|
62
|
+
else
|
|
63
|
+
@server.handle(connection)
|
|
64
|
+
end
|
|
65
|
+
rescue ValidationError, Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError, Timeout::Error
|
|
66
|
+
# Wire loss preserves the owner's retained operation state.
|
|
67
|
+
nil
|
|
68
|
+
ensure
|
|
69
|
+
connection.close
|
|
70
|
+
@mutex.synchronize { @handlers.delete(Thread.current) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
@handlers[thread] = socket
|
|
74
|
+
gate << true
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
rescue IOError, Errno::EBADF
|
|
79
|
+
raise unless stopping?
|
|
80
|
+
ensure
|
|
81
|
+
stop if @listener
|
|
82
|
+
@listener&.close
|
|
83
|
+
remove_owned_endpoint!
|
|
84
|
+
@lock&.close
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def stop(deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + STOP_SECONDS)
|
|
88
|
+
unless deadline.is_a?(Numeric) && deadline.finite?
|
|
89
|
+
raise ValidationError, "context stop deadline differs"
|
|
90
|
+
end
|
|
91
|
+
threads = @mutex.synchronize do
|
|
92
|
+
@stop_deadline = @stop_deadline ? [@stop_deadline, deadline].min : deadline
|
|
93
|
+
@stopping = true
|
|
94
|
+
@listener&.close
|
|
95
|
+
@handlers.keys
|
|
96
|
+
end
|
|
97
|
+
threads.each do |thread|
|
|
98
|
+
remaining = @stop_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
99
|
+
break unless remaining.positive?
|
|
100
|
+
thread.join(remaining)
|
|
101
|
+
end
|
|
102
|
+
if @mutex.synchronize { !@handlers.empty? }
|
|
103
|
+
raise ValidationError, "context handlers remain unresolved at stop deadline"
|
|
104
|
+
end
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def quiescent?
|
|
109
|
+
@mutex.synchronize { @handlers.empty? && (!@listener || @listener.closed?) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def stopping? = @mutex.synchronize { @stopping }
|
|
115
|
+
|
|
116
|
+
def verify_socket_traversal!
|
|
117
|
+
parent = File.dirname(@path)
|
|
118
|
+
stat = File.lstat(parent)
|
|
119
|
+
unless stat.gid == @configuration.data.fetch("socket_gid") && (stat.mode & 0o7777) == 0o750
|
|
120
|
+
raise ValidationError, "context socket parent is not the exact shared traversal directory"
|
|
121
|
+
end
|
|
122
|
+
loop do
|
|
123
|
+
stat = File.lstat(parent)
|
|
124
|
+
unless stat.directory? && !stat.symlink? && @configuration.data.fetch("grants").all? { |grant|
|
|
125
|
+
bit = stat.uid == grant.fetch("uid") ? 0o100 : (grant.fetch("groups") + [grant.fetch("gid")]).include?(stat.gid) ? 0o010 : 0o001
|
|
126
|
+
grant.fetch("uid").zero? || (stat.mode & bit).positive?
|
|
127
|
+
}
|
|
128
|
+
raise ValidationError, "context caller cannot traverse its fixed socket ancestry"
|
|
129
|
+
end
|
|
130
|
+
break if parent == "/"
|
|
131
|
+
parent = File.dirname(parent)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def remove_stale_endpoint!
|
|
136
|
+
stat = File.lstat(@path)
|
|
137
|
+
raise ValidationError, "context endpoint differs" unless stat.socket? && stat.uid == @credentials.fetch("uid")
|
|
138
|
+
File.unlink(@path)
|
|
139
|
+
rescue Errno::ENOENT
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def remove_owned_endpoint!
|
|
144
|
+
File.unlink(@path) if @identity && @protection.socket_identity(@path) == @identity
|
|
145
|
+
rescue Errno::ENOENT
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|