ace-lab 0.9.0 → 0.13.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/CHANGELOG.md +363 -104
- data/README.md +66 -100
- data/docs/usage.md +529 -592
- data/lib/ace/lab/atoms/install_schema.rb +373 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +118 -0
- data/lib/ace/lab/cli/commands/project_add.rb +101 -0
- data/lib/ace/lab/cli/commands/status.rb +109 -0
- data/lib/ace/lab/cli/commands/verify.rb +115 -0
- data/lib/ace/lab/cli.rb +21 -23
- data/lib/ace/lab/install_manifest_path.rb +23 -0
- data/lib/ace/lab/models/status_report.rb +38 -0
- data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
- data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
- data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
- data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
- data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
- data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
- data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
- data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
- data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
- data/lib/ace/lab/molecules/project_registry.rb +251 -0
- data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
- data/lib/ace/lab/molecules/role_verifier.rb +223 -0
- data/lib/ace/lab/organisms/install.rb +213 -0
- data/lib/ace/lab/organisms/prepare.rb +156 -0
- data/lib/ace/lab/organisms/project_add.rb +170 -0
- data/lib/ace/lab/organisms/status_service.rb +70 -0
- data/lib/ace/lab/organisms/verify.rb +768 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +34 -108
- data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
- data/lib/ace/lab/atoms/service_input.rb +0 -92
- data/lib/ace/lab/cli/commands/assignment.rb +0 -92
- data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
- data/lib/ace/lab/cli/commands/run.rb +0 -65
- data/lib/ace/lab/cli/commands/service.rb +0 -240
- data/lib/ace/lab/models/service_receipt.rb +0 -18
- data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
- data/lib/ace/lab/molecules/command_policy.rb +0 -158
- data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
- data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
- data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
- data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
- data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
- data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
- data/lib/ace/lab/molecules/service_executor.rb +0 -99
- data/lib/ace/lab/molecules/service_policy.rb +0 -289
- data/lib/ace/lab/organisms/authority_composition.rb +0 -87
- data/lib/ace/lab/organisms/command_executor.rb +0 -220
- data/lib/ace/lab/organisms/command_run_client.rb +0 -131
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
- data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
- data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
- data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
- data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
- data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
- data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
- data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
- data/lib/ace/lab/organisms/service_request_service.rb +0 -249
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
require "securerandom"
|
|
3
|
-
require "rubygems/package"
|
|
4
|
-
require "ace/hitl/providers/lab"
|
|
5
|
-
require_relative "../molecules/protected_publication_publisher"
|
|
6
|
-
|
|
7
|
-
module Ace
|
|
8
|
-
module Lab
|
|
9
|
-
module Organisms
|
|
10
|
-
class ProtectedServiceReceiver
|
|
11
|
-
# One invocation is retained by the existing receiver worker, not a
|
|
12
|
-
# second effect queue. Only canonical continuation can authorize a push.
|
|
13
|
-
def begin_publication!(admitted:, materialized:, deadline:)
|
|
14
|
-
raise SecurityError, "publication receiver already owns an invocation" if @publication
|
|
15
|
-
@publication_hitl ||= Ace::Hitl::Providers::Lab.boundary_client(
|
|
16
|
-
socket_path: Ace::Hitl::Providers::Lab::DEFAULT_SOCKET_PATH,
|
|
17
|
-
policy: Ace::Hitl::Providers::Lab.grants_policy(grants_path: Ace::Hitl::Providers::Lab::DEFAULT_GRANTS_PATH))
|
|
18
|
-
params = admitted.fetch(:params)
|
|
19
|
-
started = admitted.fetch(:started)
|
|
20
|
-
unless started.fetch("executor_process_binding") == @kernel.capture(Process.pid)
|
|
21
|
-
raise SecurityError, "publication original receiver differs"
|
|
22
|
-
end
|
|
23
|
-
@publication = admitted.merge(materialized: materialized, handles: [], challenge: nil,
|
|
24
|
-
ask_id: nil, disposition: "dispatch_started")
|
|
25
|
-
hold_publication_artifact!(@publication)
|
|
26
|
-
verified = publication_registry!(@publication, deadline)
|
|
27
|
-
return complete_publication!(@publication, verified, deadline) if %w[succeeded failed].include?(verified.fetch("classification"))
|
|
28
|
-
unless verified.fetch("classification") == "absent"
|
|
29
|
-
@publication[:disposition] = "uncertain"
|
|
30
|
-
return publication_status("uncertain", verified.fetch("code"))
|
|
31
|
-
end
|
|
32
|
-
publication_push!(@publication, nil, deadline)
|
|
33
|
-
rescue StandardError, SecurityError
|
|
34
|
-
publication_status("uncertain", "publication_admission_unavailable")
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def tick_publication!(request_id:, deadline:)
|
|
38
|
-
entry = @publication
|
|
39
|
-
return {"request_id" => request_id, "state" => "publication-blocked", "code" => "original-executor-unavailable"} unless entry && entry.dig(:params, "request_id") == request_id
|
|
40
|
-
check_publication!(entry)
|
|
41
|
-
status = publication_call!(entry, "service_status", {}, deadline).data
|
|
42
|
-
if %w[succeeded failed].include?(status["state"])
|
|
43
|
-
close_publication!(entry)
|
|
44
|
-
return projection(status)
|
|
45
|
-
end
|
|
46
|
-
if entry[:disposition] == "uncertain"
|
|
47
|
-
verified = publication_registry!(entry, deadline)
|
|
48
|
-
return complete_publication!(entry, verified, deadline) if verified["classification"] == "succeeded"
|
|
49
|
-
return publication_status("uncertain", "publication_outcome_unconfirmed")
|
|
50
|
-
end
|
|
51
|
-
unless status["dispatch_phase"] == "otp_pending" && status["publication_challenge_digest"] == entry.fetch(:challenge).fetch("publication_challenge_digest")
|
|
52
|
-
return publication_status("uncertain", "publication_challenge_changed")
|
|
53
|
-
end
|
|
54
|
-
publication_ask!(entry, deadline) unless entry[:ask_created]
|
|
55
|
-
ask = @publication_hitl.read(entry.fetch(:ask_id), deadline: deadline)
|
|
56
|
-
check_publication_ask!(entry, ask)
|
|
57
|
-
if %w[cancelled consumed].include?(ask["state"])
|
|
58
|
-
entry[:ask_id] = nil
|
|
59
|
-
entry[:ask_created] = false
|
|
60
|
-
entry[:ask_otp] = nil
|
|
61
|
-
return publication_status("publication-pending", "otp-answer-unavailable")
|
|
62
|
-
end
|
|
63
|
-
if ask.fetch("otp").fetch("expires_at") <= Time.now.to_i
|
|
64
|
-
@publication_hitl.cancel(entry.fetch(:ask_id), reason: "otp-answer-expired", deadline: deadline)
|
|
65
|
-
return publication_status("publication-pending", "otp-answer-expired")
|
|
66
|
-
end
|
|
67
|
-
return publication_status("publication-pending", "otp-answer-pending") unless ask["state"] == "answer-delivered"
|
|
68
|
-
return publication_status("publication-pending", "otp-tick-budget") if publication_remaining!(deadline) < 1.5
|
|
69
|
-
answer = @publication_hitl.consume(entry.fetch(:ask_id), timeout: 1, operation: "publish", deadline: deadline)
|
|
70
|
-
otp = answer["answer"]
|
|
71
|
-
unless otp.is_a?(String) && otp.match?(/\A[0-9]{6}\z/) && ask.fetch("otp").fetch("expires_at") > Time.now.to_i
|
|
72
|
-
return publication_status("publication-pending", "otp-answer-unavailable")
|
|
73
|
-
end
|
|
74
|
-
check_publication!(entry)
|
|
75
|
-
return publication_status("publication-pending", "otp-tick-budget") if publication_remaining!(deadline) < 1
|
|
76
|
-
status = publication_call!(entry, "service_status", {}, deadline).data
|
|
77
|
-
return publication_status("publication-pending", "otp-answer-expired") unless ask.fetch("otp").fetch("expires_at") > Time.now.to_i
|
|
78
|
-
entry[:disposition] = "uncertain" # A lost issuing reply is never retried.
|
|
79
|
-
issued = publication_call!(entry, "publication_continue", {
|
|
80
|
-
"expected_generation" => status.fetch("generation"), "input_digest" => entry.fetch(:params).fetch("input_digest"),
|
|
81
|
-
"challenge_digest" => entry.fetch(:challenge).fetch("publication_challenge_digest")}, deadline,
|
|
82
|
-
mutation_id: Digest::SHA256.hexdigest("publication-continue:#{entry.fetch(:challenge).fetch('publication_challenge_digest')}")).data
|
|
83
|
-
return publication_status("uncertain", "publication_issuing_unconfirmed") unless issued["invocation"] == "permitted" && issued["dispatch_phase"] == "issuing"
|
|
84
|
-
entry[:started] = issued
|
|
85
|
-
publication_push!(entry, otp, deadline)
|
|
86
|
-
rescue StandardError, SecurityError
|
|
87
|
-
publication_status("uncertain", "publication_tick_unavailable")
|
|
88
|
-
ensure
|
|
89
|
-
otp&.clear if otp.is_a?(String) && !otp.frozen?
|
|
90
|
-
answer&.delete("answer")
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def publication_active?
|
|
94
|
-
!@publication.nil?
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Called only after the existing worker has stopped its bounded tick.
|
|
98
|
-
# Durable uncertain state and candidate bytes remain for read-only recovery.
|
|
99
|
-
def release_publication_lifetime!
|
|
100
|
-
@publication&.fetch(:handles)&.each { |_path, handle, _identity| handle.close unless handle.closed? }
|
|
101
|
-
@publication = nil
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
private
|
|
105
|
-
|
|
106
|
-
def hold_publication_artifact!(entry)
|
|
107
|
-
root = entry.fetch(:materialized).fetch("directory")
|
|
108
|
-
publication = entry.fetch(:envelope).fetch("input").fetch("publication")
|
|
109
|
-
components = publication.fetch("artifact_relative_path").split("/")
|
|
110
|
-
paths = [root]
|
|
111
|
-
components.each { |component| paths << File.join(paths.last, component) }
|
|
112
|
-
paths.each_with_index do |path, index|
|
|
113
|
-
handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
114
|
-
stat = handle.stat
|
|
115
|
-
valid = index == paths.size - 1 ? stat.file? && stat.size.between?(1, 64 * 1024 * 1024) : stat.directory?
|
|
116
|
-
unless valid && stat.uid == Process.uid && (stat.mode & 0o022).zero?
|
|
117
|
-
handle.close
|
|
118
|
-
raise SecurityError, "publication staging protection differs"
|
|
119
|
-
end
|
|
120
|
-
entry.fetch(:handles) << [path, handle, publication_identity(stat)]
|
|
121
|
-
end
|
|
122
|
-
entry[:artifact] = paths.last
|
|
123
|
-
spec = Gem::Package.new(paths.last).spec
|
|
124
|
-
unless spec.name == publication.fetch("gem_name") && spec.version.to_s == publication.fetch("version")
|
|
125
|
-
raise SecurityError, "publication artifact metadata differs"
|
|
126
|
-
end
|
|
127
|
-
entry[:platform] = spec.platform.to_s
|
|
128
|
-
check_publication!(entry)
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def check_publication!(entry)
|
|
132
|
-
@kernel.live!(entry.fetch(:started).fetch("executor_process_binding"))
|
|
133
|
-
raise SecurityError, "publication receiver birth differs" unless @kernel.capture(Process.pid) == entry.fetch(:started).fetch("executor_process_binding")
|
|
134
|
-
entry.fetch(:handles).each do |path, handle, expected|
|
|
135
|
-
unless publication_identity(handle.stat) == expected && publication_identity(File.lstat(path)) == expected
|
|
136
|
-
raise SecurityError, "publication held staging changed"
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
artifact = entry.fetch(:handles).last.fetch(1)
|
|
140
|
-
artifact.rewind
|
|
141
|
-
digest = Digest::SHA256.new
|
|
142
|
-
while (chunk = artifact.read(65_536))
|
|
143
|
-
digest.update(chunk)
|
|
144
|
-
end
|
|
145
|
-
unless digest.hexdigest == entry.fetch(:envelope).fetch("input").fetch("target").fetch("artifact_digest")
|
|
146
|
-
raise SecurityError, "publication artifact bytes differ"
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def publication_identity(stat)
|
|
151
|
-
stat.directory? ? [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode] :
|
|
152
|
-
[stat.dev, stat.ino, stat.uid, stat.gid, stat.mode, stat.size, stat.mtime, stat.ctime]
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def publication_registry!(entry, deadline)
|
|
156
|
-
check_publication!(entry)
|
|
157
|
-
@publication_publisher.verify!(publication: entry.fetch(:envelope).fetch("input").fetch("publication"),
|
|
158
|
-
artifact_digest: entry.fetch(:params).fetch("target").fetch("artifact_digest"), platform: entry.fetch(:platform), deadline: deadline)
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def publication_push!(entry, otp, deadline)
|
|
162
|
-
check_publication!(entry)
|
|
163
|
-
entry[:disposition] = "uncertain"
|
|
164
|
-
result = @publication_publisher.push!(operation: entry.fetch(:operation),
|
|
165
|
-
publication: entry.fetch(:envelope).fetch("input").fetch("publication"), artifact: entry.fetch(:artifact),
|
|
166
|
-
artifact_digest: entry.fetch(:params).fetch("target").fetch("artifact_digest"), otp: otp, deadline: deadline)
|
|
167
|
-
check_publication!(entry)
|
|
168
|
-
verified = publication_registry!(entry, deadline)
|
|
169
|
-
return complete_publication!(entry, verified, deadline) if %w[succeeded failed].include?(verified["classification"])
|
|
170
|
-
if %w[otp-required otp-rejected otp-expired].include?(result["classification"]) && verified["classification"] == "absent"
|
|
171
|
-
return retain_publication_challenge!(entry, result, deadline)
|
|
172
|
-
end
|
|
173
|
-
return complete_publication!(entry, result, deadline) if result["classification"] == "failed" && verified["classification"] == "absent"
|
|
174
|
-
publication_status("uncertain", "publication_outcome_unconfirmed")
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def retain_publication_challenge!(entry, result, deadline)
|
|
178
|
-
input = entry.fetch(:envelope).fetch("input")
|
|
179
|
-
params = entry.fetch(:params)
|
|
180
|
-
previous = entry[:challenge]&.fetch("publication_challenge_digest")
|
|
181
|
-
need = params.slice("request_id", "input_digest", "candidate_generation", "head").merge(
|
|
182
|
-
"schema" => "ace.publication-otp-need/v1", "classification" => result.fetch("classification"),
|
|
183
|
-
"claim_binding" => entry.fetch(:claim).fetch("claim_binding"),
|
|
184
|
-
"registry" => input.fetch("publication").fetch("registry"), "gem_name" => input.fetch("publication").fetch("gem_name"),
|
|
185
|
-
"version" => input.fetch("publication").fetch("version"), "artifact_digest" => input.fetch("target").fetch("artifact_digest"),
|
|
186
|
-
"dispatch_event_digest" => entry.fetch(:dispatch_event_digest), "previous_challenge_digest" => previous)
|
|
187
|
-
bytes = JSON.generate(need)
|
|
188
|
-
status = publication_call!(entry, "service_status", {}, deadline).data
|
|
189
|
-
entry[:challenge] = publication_call!(entry, "publication_challenge", {
|
|
190
|
-
"input_digest" => params.fetch("input_digest"), "expected_generation" => status.fetch("generation"),
|
|
191
|
-
"receipt_sha256" => Digest::SHA256.hexdigest(bytes)}, deadline,
|
|
192
|
-
mutation_id: Digest::SHA256.hexdigest("publication-challenge:#{Digest::SHA256.hexdigest(bytes)}"),
|
|
193
|
-
upload_parts: [bytes], purpose: :receipt_artifacts).data
|
|
194
|
-
entry[:disposition] = "otp_pending"
|
|
195
|
-
entry[:ask_id] = nil
|
|
196
|
-
entry[:ask_created] = false
|
|
197
|
-
entry[:ask_otp] = nil
|
|
198
|
-
publication_ask!(entry, deadline)
|
|
199
|
-
publication_status("publication-pending", result.fetch("classification"))
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def publication_ask!(entry, deadline)
|
|
203
|
-
entry[:ask_id] ||= "publication-#{SecureRandom.hex(12)}"
|
|
204
|
-
binding = entry.fetch(:params).slice("assignment_id", "attempt_id", "request_id", "input_digest", "candidate_generation", "head").merge(
|
|
205
|
-
"schema" => "ace.hitl-publication-binding/v1", "mapping_id" => @mapping_id,
|
|
206
|
-
"claim_binding" => entry.fetch(:claim).fetch("claim_binding"),
|
|
207
|
-
"challenge_digest" => entry.fetch(:challenge).fetch("publication_challenge_digest"))
|
|
208
|
-
entry[:ask_binding] = immutable(binding)
|
|
209
|
-
entry[:ask_otp] ||= immutable({"operation" => "publish", "input_digest" => binding.fetch("input_digest"),
|
|
210
|
-
"result_ref" => entry.fetch(:challenge).fetch("publication_challenge_ref").fetch("ref"), "expires_at" => Time.now.to_i + 900})
|
|
211
|
-
@publication_hitl.create(id: entry.fetch(:ask_id), assignment: binding.fetch("assignment_id"), attempt: binding.fetch("attempt_id"),
|
|
212
|
-
project: @map.fetch("project_id"), harness: "lab-admin", plan: "Scoped RubyGems publication", kind: "otp",
|
|
213
|
-
question: "Provide the RubyGems OTP for this authorized artifact publication.", ace_hitl_id: entry.fetch(:ask_id),
|
|
214
|
-
publication_binding: binding, otp: entry.fetch(:ask_otp), deadline: deadline)
|
|
215
|
-
entry[:ask_created] = true
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def check_publication_ask!(entry, ask)
|
|
219
|
-
unless ask["id"] == entry.fetch(:ask_id) && ask["publication_binding"] == entry.fetch(:ask_binding) &&
|
|
220
|
-
ask["publication_requester_peer"] == entry.fetch(:started).fetch("executor_process_binding") &&
|
|
221
|
-
ask.dig("otp", "input_digest") == entry.fetch(:params).fetch("input_digest") &&
|
|
222
|
-
ask.dig("otp", "result_ref") == entry.fetch(:challenge).fetch("publication_challenge_ref").fetch("ref")
|
|
223
|
-
raise SecurityError, "publication HITL retained request differs"
|
|
224
|
-
end
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
def publication_call!(entry, operation, extra, deadline, **options)
|
|
228
|
-
params = entry.fetch(:params).slice("assignment_id", "attempt_id", "candidate_generation", "head", "request_id")
|
|
229
|
-
params["claim_binding"] = entry.fetch(:claim).fetch("claim_binding") unless operation == "service_status"
|
|
230
|
-
@client.call(operation, params.merge(extra), timeout: publication_remaining!(deadline), **options)
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def complete_publication!(entry, result, deadline)
|
|
234
|
-
check_publication!(entry)
|
|
235
|
-
input = entry.fetch(:envelope).fetch("input")
|
|
236
|
-
evidence = JSON.generate(input.merge("schema" => "ace.publication-result/v1", "classification" => result.fetch("classification"),
|
|
237
|
-
"code" => result.fetch("code"), "request_id" => entry.fetch(:params).fetch("request_id"),
|
|
238
|
-
"input_digest" => entry.fetch(:params).fetch("input_digest"), "claim_binding" => entry.fetch(:claim).fetch("claim_binding")))
|
|
239
|
-
receipt = entry.fetch(:envelope).fetch("request").slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS).merge(
|
|
240
|
-
"outcome" => result.fetch("classification") == "succeeded" ? "succeeded" : "failed",
|
|
241
|
-
"evidence" => [{"ref" => "publication-result", "sha256" => Digest::SHA256.hexdigest(evidence)}])
|
|
242
|
-
bytes = JSON.generate(receipt)
|
|
243
|
-
completed = publication_call!(entry, "complete_service", {"receipt_sha256" => Digest::SHA256.hexdigest(bytes)}, deadline,
|
|
244
|
-
mutation_id: Digest::SHA256.hexdigest("publication-complete:#{entry.fetch(:mutation_id)}"), upload_parts: [bytes, evidence], purpose: :receipt_artifacts).data
|
|
245
|
-
return publication_status("uncertain", "publication_completion_unconfirmed") unless %w[succeeded failed].include?(completed["state"])
|
|
246
|
-
close_publication!(entry)
|
|
247
|
-
projection(completed)
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
def close_publication!(entry)
|
|
251
|
-
entry.fetch(:handles).each { |_path, handle, _identity| handle.close }
|
|
252
|
-
cleanup_staging(entry.fetch(:materialized).fetch("directory"), entry.fetch(:staging_identity))
|
|
253
|
-
@publication = nil
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
def publication_remaining!(deadline)
|
|
257
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
258
|
-
raise IOError, "publication tick deadline expired" unless remaining.positive?
|
|
259
|
-
remaining
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def publication_status(state, code)
|
|
263
|
-
{"request_id" => @publication&.dig(:params, "request_id"), "state" => state, "code" => code}.freeze
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
end
|
|
268
|
-
end
|
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "digest"
|
|
5
|
-
require "fileutils"
|
|
6
|
-
require "ace/assign/authority/client"
|
|
7
|
-
require "ace/assign/authority/candidate_transfer"
|
|
8
|
-
require "ace/assign/authority/evidence_transfer"
|
|
9
|
-
require_relative "../molecules/protected_service_handler"
|
|
10
|
-
require_relative "../molecules/protected_service_policy"
|
|
11
|
-
require_relative "../atoms/protected_workspace_prune_preview"
|
|
12
|
-
require_relative "protected_service_publication"
|
|
13
|
-
|
|
14
|
-
module Ace
|
|
15
|
-
module Lab
|
|
16
|
-
module Organisms
|
|
17
|
-
# Fixed executor-side orchestration. All durable permission and outcome
|
|
18
|
-
# state remains in the existing authority journal. Transport supplies
|
|
19
|
-
# the kernel peer; submitted actor strings are never accepted.
|
|
20
|
-
class ProtectedServiceReceiver
|
|
21
|
-
SUBMISSION = %w[assignment_id attempt_id expected_generation candidate_generation head request_id
|
|
22
|
-
operation input_digest target authorization].freeze
|
|
23
|
-
|
|
24
|
-
def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
|
|
25
|
-
kernel: Ace::Runtime::Molecules::ProtectedLinux.new, client: nil,
|
|
26
|
-
handler: Molecules::ProtectedServiceHandler.new, cleanup_owner: nil, publication_publisher: nil, publication_hitl: nil)
|
|
27
|
-
@mapping_id, @service_id, @deployment, @kernel, @handler = mapping_id, service_id, deployment, kernel, handler
|
|
28
|
-
@cleanup_owner = cleanup_owner
|
|
29
|
-
@publication_publisher = publication_publisher || Molecules::ProtectedPublicationPublisher.new
|
|
30
|
-
@publication_hitl = publication_hitl
|
|
31
|
-
@map = deployment.mapping(mapping_id)
|
|
32
|
-
deployment.verify_composition!(@map.fetch("authority_id"), composition: "services")
|
|
33
|
-
@project = deployment.project(@map.fetch("project_id"))
|
|
34
|
-
@receiver = @project.fetch("service_receivers").fetch(service_id)
|
|
35
|
-
@client = client || Ace::Assign::Authority::Client.new(mapping_id: mapping_id, deployment: deployment, kernel: kernel)
|
|
36
|
-
@inputs = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(*) { raise SecurityError, "proposal decisions belong to authority" })
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def preview_workspace_prune(intent:, peer:, deadline:)
|
|
40
|
-
raise SecurityError, "fixed cleanup preview composition unavailable" unless @cleanup_owner
|
|
41
|
-
intent = Atoms::ProtectedWorkspacePrunePreview.intent!(JSON.parse(JSON.generate(intent)))
|
|
42
|
-
maintenance = intent.fetch("maintenance")
|
|
43
|
-
unless maintenance.fetch("mapping_id") == @mapping_id && maintenance.fetch("project_id") == @map.fetch("project_id") &&
|
|
44
|
-
peer.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
|
|
45
|
-
raise SecurityError, "cleanup preview worker or installed scope differs"
|
|
46
|
-
end
|
|
47
|
-
@kernel.live!(peer)
|
|
48
|
-
me = @kernel.capture(Process.pid)
|
|
49
|
-
credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
|
|
50
|
-
unless me.values_at("uid", "gid", "groups") == [@receiver.fetch("executor_uid"), credentials.fetch("gid"), credentials.fetch("groups")]
|
|
51
|
-
raise SecurityError, "cleanup preview receiver principal differs"
|
|
52
|
-
end
|
|
53
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
-
raise Ace::Runtime::RuntimeUnavailableError, "cleanup preview deadline expired" unless remaining.positive?
|
|
55
|
-
selected = @client.call("workspace_prune_preview_context", maintenance.except("project_id", "mapping_id").merge(
|
|
56
|
-
"service_id" => @service_id, "worker_process_binding" => peer), timeout: remaining).data
|
|
57
|
-
selected = Atoms::ProtectedWorkspacePrunePreview.context!(selected)
|
|
58
|
-
result = @cleanup_owner.preview!(intent: intent, maintenance_context: selected, deadline: deadline)
|
|
59
|
-
Atoms::ProtectedWorkspacePrunePreview.result!(result, intent: intent, context: selected)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def execute(submission:, peer:, input_bytes:, mutation_id:, on_claim: nil, claim_timeout: nil)
|
|
63
|
-
contacted = false
|
|
64
|
-
request_id = nil
|
|
65
|
-
unless submission.is_a?(Hash) && submission.keys.sort == SUBMISSION.sort &&
|
|
66
|
-
peer.fetch("uid") == @map.fetch("worker_uid") && peer["gid"] == @map.fetch("worker_gid") &&
|
|
67
|
-
peer["groups"] == @map.fetch("worker_groups")
|
|
68
|
-
raise SecurityError, "receiver submission or peer differs"
|
|
69
|
-
end
|
|
70
|
-
params = immutable(submission.merge("service_id" => @service_id, "worker_process_binding" => peer))
|
|
71
|
-
id = params.fetch("request_id")
|
|
72
|
-
unless id.is_a?(String) && id.match?(Ace::Assign::Molecules::JournalMutation::ID)
|
|
73
|
-
raise ArgumentError, "receiver request identity is invalid"
|
|
74
|
-
end
|
|
75
|
-
request_id = id
|
|
76
|
-
peer = params.fetch("worker_process_binding")
|
|
77
|
-
@kernel.live!(peer)
|
|
78
|
-
me = @kernel.capture(Process.pid)
|
|
79
|
-
credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
|
|
80
|
-
unless me["uid"] == @receiver["executor_uid"] && me["gid"] == credentials["gid"] && me["groups"] == credentials["groups"]
|
|
81
|
-
raise SecurityError, "receiver principal differs"
|
|
82
|
-
end
|
|
83
|
-
unless input_bytes.is_a?(String) && mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
|
|
84
|
-
raise ArgumentError, "receiver input or mutation identity is invalid"
|
|
85
|
-
end
|
|
86
|
-
if claim_timeout && (!claim_timeout.is_a?(Numeric) || !claim_timeout.finite? ||
|
|
87
|
-
!claim_timeout.positive? || claim_timeout > 5)
|
|
88
|
-
raise ArgumentError, "receiver claim deadline is invalid"
|
|
89
|
-
end
|
|
90
|
-
bytes = input_bytes.dup.freeze
|
|
91
|
-
mutation_id = mutation_id.dup.freeze
|
|
92
|
-
input = @inputs.input_binding(bytes, expected_digest: params.fetch("input_digest"),
|
|
93
|
-
expected_target: params.fetch("target"), operation: params.fetch("operation"))
|
|
94
|
-
cleanup = params.fetch("operation") == "prune-preserved-workspace"
|
|
95
|
-
raise SecurityError, "fixed cleanup owner composition is unavailable" if cleanup && !@cleanup_owner
|
|
96
|
-
contacted = true
|
|
97
|
-
claim_options = {mutation_id: mutation_id, upload_parts: [bytes], purpose: :service_input}
|
|
98
|
-
claim_options[:timeout] = claim_timeout if claim_timeout
|
|
99
|
-
claim = @client.call("request_service", params, **claim_options)
|
|
100
|
-
notify_claim(on_claim, claim, request_id) if on_claim
|
|
101
|
-
unless !claim.replayed && claim.data["claim"] == "created"
|
|
102
|
-
return projection(claim.data)
|
|
103
|
-
end
|
|
104
|
-
original_cleanup_owner = @cleanup_owner.identity! if cleanup
|
|
105
|
-
binding = params.slice("assignment_id", "attempt_id", "candidate_generation", "head", "request_id")
|
|
106
|
-
exported = @client.call("export_candidate", binding.except("request_id").merge("purpose_id" => params.fetch("request_id")),
|
|
107
|
-
download: true, purpose: :candidate, timeout: 30)
|
|
108
|
-
unless exported.parts.is_a?(Array) && exported.parts.length == 1
|
|
109
|
-
raise SecurityError, "candidate export is incomplete"
|
|
110
|
-
end
|
|
111
|
-
materialized = Ace::Assign::Authority::CandidateTransfer.new(root: @receiver.fetch("staging_root")).materialize(
|
|
112
|
-
bytes: exported.parts.first, sha256: exported.data.fetch("sha256"), size: exported.data.fetch("bytes"),
|
|
113
|
-
head: binding.fetch("head"), tree: exported.data.fetch("tree"), root: @receiver.fetch("staging_root"))
|
|
114
|
-
staging_identity = File.lstat(materialized.fetch("directory"))
|
|
115
|
-
begin_params = binding.merge("claim_binding" => claim.data.fetch("claim_binding"), "expected_generation" => claim.data.fetch("generation"))
|
|
116
|
-
started = @client.call("begin_dispatch", begin_params, mutation_id: Digest::SHA256.hexdigest("begin:#{mutation_id}"), upload_parts: [bytes], purpose: :service_input)
|
|
117
|
-
return projection(started.data) unless !started.replayed && started.data["invocation"] == "permitted"
|
|
118
|
-
if cleanup && Ace::Assign::Atoms::EvidenceDigest.digest(original_cleanup_owner) !=
|
|
119
|
-
Ace::Assign::Atoms::EvidenceDigest.digest(started.data.fetch("operation_owner_binding"))
|
|
120
|
-
raise SecurityError, "original cleanup admission owner differs"
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
document = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
|
|
124
|
-
operation = Molecules::ServicePolicy.new(document).operation!(params.fetch("operation"),
|
|
125
|
-
project: @map.fetch("project_id"), service_id: @service_id)
|
|
126
|
-
authorization = @client.call("service_authorization", binding.merge("claim_binding" => claim.data.fetch("claim_binding"),
|
|
127
|
-
"input_digest" => params.fetch("input_digest")), upload_parts: [bytes], purpose: :service_input)
|
|
128
|
-
expected = binding.slice("head", "candidate_generation", "request_id").merge("claim_binding" => claim.data.fetch("claim_binding"),
|
|
129
|
-
"policy_digest" => claim.data.fetch("policy_digest"), "operation_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(operation))
|
|
130
|
-
unless !authorization.replayed && authorization.data == expected
|
|
131
|
-
raise SecurityError, "current authority authorization differs"
|
|
132
|
-
end
|
|
133
|
-
# This successful fresh authority read is the admission point. A
|
|
134
|
-
# later veto cannot retroactively undo it; no read creates permission.
|
|
135
|
-
reloaded = Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)).operation!(
|
|
136
|
-
params.fetch("operation"), project: @map.fetch("project_id"), service_id: @service_id)
|
|
137
|
-
unless Ace::Assign::Atoms::EvidenceDigest.digest(reloaded) == expected.fetch("operation_digest")
|
|
138
|
-
raise SecurityError, "local operation changed after admission"
|
|
139
|
-
end
|
|
140
|
-
canonical = params.slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS, "authorization", "service_id").merge(
|
|
141
|
-
"project_id" => @map.fetch("project_id"), "candidate_head" => binding.fetch("head"),
|
|
142
|
-
"caller_uid" => peer.fetch("uid"), "executor_uid" => @receiver.fetch("executor_uid"), "transport" => "unix")
|
|
143
|
-
envelope = immutable("version" => 1, "request" => canonical, "input" => input.fetch(:input), "execution" => {
|
|
144
|
-
"executor_uid" => @receiver.fetch("executor_uid"), "authority_id" => @map.fetch("authority_id"),
|
|
145
|
-
"claim_binding" => claim.data.fetch("claim_binding"), "candidate_generation" => binding.fetch("candidate_generation"),
|
|
146
|
-
"head" => binding.fetch("head"), "staging_id" => File.basename(materialized.fetch("directory"))})
|
|
147
|
-
if params.fetch("operation") == "publish"
|
|
148
|
-
return begin_publication!(admitted: {params: params, claim: claim.data, started: started.data,
|
|
149
|
-
dispatch_event_digest: started.data.fetch("dispatch_event_digest"), operation: immutable(operation), envelope: envelope,
|
|
150
|
-
staging_identity: staging_identity, mutation_id: mutation_id}, materialized: materialized,
|
|
151
|
-
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
|
|
152
|
-
end
|
|
153
|
-
if cleanup
|
|
154
|
-
artifacts = cleanup_result_evidence!(params, input.fetch(:input), claim.data, started.data)
|
|
155
|
-
response = {"outcome" => "succeeded", "evidence" => artifacts.each_with_index.map do |artifact, index|
|
|
156
|
-
{"ref" => "cleanup-#{index}", "sha256" => Digest::SHA256.hexdigest(artifact)}
|
|
157
|
-
end}
|
|
158
|
-
else
|
|
159
|
-
response = @handler.execute(operation: immutable(operation), envelope: envelope, candidate_root: materialized.fetch("directory"))
|
|
160
|
-
return uncertain(params.fetch("request_id")) unless response
|
|
161
|
-
artifacts = Ace::Assign::Authority::EvidenceTransfer.read_staged(root: materialized.fetch("directory"), evidence: response.fetch("evidence"))
|
|
162
|
-
end
|
|
163
|
-
receipt = canonical.slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS).merge(
|
|
164
|
-
"outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"))
|
|
165
|
-
receipt_bytes = JSON.generate(receipt)
|
|
166
|
-
completion = @client.call("complete_service", binding.merge("claim_binding" => claim.data.fetch("claim_binding"),
|
|
167
|
-
"receipt_sha256" => Digest::SHA256.hexdigest(receipt_bytes)),
|
|
168
|
-
mutation_id: Digest::SHA256.hexdigest("complete:#{mutation_id}"), upload_parts: [receipt_bytes] + artifacts, purpose: :receipt_artifacts)
|
|
169
|
-
unless completion.data["request_id"] == request_id && %w[succeeded failed].include?(completion.data["state"])
|
|
170
|
-
return uncertain(request_id)
|
|
171
|
-
end
|
|
172
|
-
cleanup_staging(materialized.fetch("directory"), staging_identity)
|
|
173
|
-
projection(completion.data)
|
|
174
|
-
rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Ace::Lab::InvalidConfigurationError,
|
|
175
|
-
SecurityError, ArgumentError, KeyError, SystemCallError, Timeout::Error
|
|
176
|
-
contacted ? uncertain(request_id) : {"request_id" => request_id, "state" => "refused", "code" => "invalid_receiver_admission"}
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
private
|
|
180
|
-
|
|
181
|
-
def cleanup_result_evidence!(params, input, claim, started)
|
|
182
|
-
owner = started.fetch("operation_owner_binding")
|
|
183
|
-
result = @cleanup_owner.execute!(request: {
|
|
184
|
-
"request_id" => params.fetch("request_id"), "input_digest" => params.fetch("input_digest"),
|
|
185
|
-
"claim_binding" => claim.fetch("claim_binding"), "request_event_digest" => claim.fetch("request_event_digest"),
|
|
186
|
-
"dispatch_event_digest" => started.fetch("dispatch_event_digest"), "input" => input
|
|
187
|
-
}, operation_owner_binding: owner)
|
|
188
|
-
selection = {"schema" => "ace.protected-workspace-prune-root-selection/v1",
|
|
189
|
-
"request_id" => params.fetch("request_id"), "input_digest" => params.fetch("input_digest"),
|
|
190
|
-
"operation_owner_binding_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(owner),
|
|
191
|
-
"receipt_ref" => result.fetch(:receipt_ref)}
|
|
192
|
-
[result.fetch(:bytes), JSON.generate(selection)]
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
# A source-owned listener callback, never a wire-selected handler. Its
|
|
196
|
-
# reply exposes accepted canonical identity before long work. Transport
|
|
197
|
-
# loss in the callback is handled by that listener; it cannot resend.
|
|
198
|
-
def notify_claim(callback, claim, request_id)
|
|
199
|
-
data = claim.data
|
|
200
|
-
unless data.is_a?(Hash) && data["request_id"] == request_id &&
|
|
201
|
-
data["generation"].is_a?(Integer) && data["generation"].positive? &&
|
|
202
|
-
data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
203
|
-
[true, false].include?(claim.replayed)
|
|
204
|
-
raise SecurityError, "canonical service acceptance identity is unavailable"
|
|
205
|
-
end
|
|
206
|
-
callback.call(immutable(data.slice("request_id", "generation", "journal_commit", "state", "claim")
|
|
207
|
-
.merge("replayed" => claim.replayed)))
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def cleanup_staging(directory, identity, prefix: "candidate-")
|
|
211
|
-
quarantine = nil
|
|
212
|
-
root = File.expand_path(@receiver.fetch("staging_root"))
|
|
213
|
-
Ace::Assign::Authority::PrivateDirectory.verify!(root)
|
|
214
|
-
current = File.lstat(directory)
|
|
215
|
-
return unless File.dirname(directory) == root && File.basename(directory).start_with?(prefix) &&
|
|
216
|
-
current.directory? && current.uid == Process.uid && current.dev == identity.dev && current.ino == identity.ino
|
|
217
|
-
# Capture the pathname atomically before checking the object we will
|
|
218
|
-
# delete. A concurrent replacement at the original name must survive.
|
|
219
|
-
quarantine = Dir.mktmpdir(".receiver-cleanup-", root)
|
|
220
|
-
File.chmod(0o700, quarantine)
|
|
221
|
-
captured = File.join(quarantine, "candidate")
|
|
222
|
-
File.rename(directory, captured)
|
|
223
|
-
moved = File.lstat(captured)
|
|
224
|
-
return unless moved.directory? && moved.uid == Process.uid &&
|
|
225
|
-
moved.dev == identity.dev && moved.ino == identity.ino
|
|
226
|
-
FileUtils.remove_entry_secure(captured)
|
|
227
|
-
rescue Ace::Assign::Error, SystemCallError
|
|
228
|
-
# A cleanup failure cannot change already-confirmed canonical truth.
|
|
229
|
-
# Never fall back to cleaning the staging root or another directory.
|
|
230
|
-
nil
|
|
231
|
-
ensure
|
|
232
|
-
if quarantine
|
|
233
|
-
begin
|
|
234
|
-
# Never recursively remove a mismatched captured object.
|
|
235
|
-
Dir.rmdir(quarantine)
|
|
236
|
-
rescue SystemCallError
|
|
237
|
-
nil
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def projection(data)
|
|
243
|
-
data.slice("request_id", "state", "dispatch_phase", "claim", "generation", "journal_commit").tap do |result|
|
|
244
|
-
result.merge!(data.slice("receipt", "completion_digest")) if data["operation"] == "publish"
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
def uncertain(id)
|
|
249
|
-
{"request_id" => id, "state" => "uncertain", "required_action" => "inspect_canonical_service_status_and_record_exact_outcome"}
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def immutable(value)
|
|
253
|
-
copy = JSON.parse(JSON.generate(value))
|
|
254
|
-
freeze_value(copy)
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def freeze_value(value)
|
|
258
|
-
value.each_value { |child| freeze_value(child) } if value.is_a?(Hash)
|
|
259
|
-
value.each { |child| freeze_value(child) } if value.is_a?(Array)
|
|
260
|
-
value.freeze
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
require_relative "protected_service_recovery"
|