ace-lab 0.3.1 → 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/CHANGELOG.md +25 -0
- data/docs/usage.md +10 -0
- data/lib/ace/lab/atoms/protected_publication_input.rb +50 -0
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +114 -0
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +86 -0
- data/lib/ace/lab/atoms/service_input.rb +20 -8
- data/lib/ace/lab/authorization_path.rb +20 -0
- data/lib/ace/lab/cli/commands/service.rb +45 -0
- data/lib/ace/lab/errors.rb +11 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +5 -1
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +163 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +165 -0
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +239 -0
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +110 -0
- data/lib/ace/lab/molecules/protected_service_handler.rb +43 -0
- data/lib/ace/lab/molecules/protected_service_policy.rb +156 -0
- data/lib/ace/lab/molecules/service_policy.rb +36 -1
- data/lib/ace/lab/organisms/authority_composition.rb +87 -0
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +361 -0
- data/lib/ace/lab/organisms/protected_service_client.rb +129 -0
- data/lib/ace/lab/organisms/protected_service_listener.rb +181 -0
- data/lib/ace/lab/organisms/protected_service_publication.rb +268 -0
- data/lib/ace/lab/organisms/protected_service_receiver.rb +267 -0
- data/lib/ace/lab/organisms/protected_service_recovery.rb +185 -0
- data/lib/ace/lab/organisms/protected_service_request.rb +128 -0
- data/lib/ace/lab/organisms/protected_service_worker.rb +89 -0
- data/lib/ace/lab/organisms/service_request_service.rb +4 -2
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +2 -22
- metadata +52 -4
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module Organisms
|
|
6
|
+
class ProtectedServiceReceiver
|
|
7
|
+
RECOVERY_BINDING = %w[assignment_id attempt_id candidate_generation head request_id].freeze
|
|
8
|
+
RECOVERY_EXECUTION = %w[mapping_id candidate_generation head claim_binding dispatch_phase].freeze
|
|
9
|
+
CLEANUP_RECOVERY_EXECUTION = (RECOVERY_EXECUTION + %w[operation_owner_binding executor_process_binding
|
|
10
|
+
request_event_digest dispatch_event_digest]).freeze
|
|
11
|
+
RECOVERY_CHALLENGE = %w[request_id input_digest claim_binding no_effect_challenge challenge_generation
|
|
12
|
+
failure_event_digest failure_generation challenge_event_digest].freeze
|
|
13
|
+
|
|
14
|
+
def recover_no_effect(binding:, input_bytes:, mutation_id:, expected_generation:)
|
|
15
|
+
request_id = nil
|
|
16
|
+
recovery_binding!(binding, input_bytes, mutation_id, expected_generation)
|
|
17
|
+
params = immutable(binding)
|
|
18
|
+
input_bytes = input_bytes.dup.freeze
|
|
19
|
+
mutation_id = mutation_id.dup.freeze
|
|
20
|
+
recovery_binding!(params, input_bytes, mutation_id, expected_generation)
|
|
21
|
+
request_id = params.fetch("request_id")
|
|
22
|
+
recovery_executor!
|
|
23
|
+
status, context, input = recovery_status!(params, input_bytes)
|
|
24
|
+
return projection(status) if %w[succeeded failed-settled].include?(status.fetch("state"))
|
|
25
|
+
unless context["challenge"]
|
|
26
|
+
claim_params = params.merge("expected_generation" => expected_generation)
|
|
27
|
+
begin
|
|
28
|
+
@client.call("claim_service_settlement", claim_params, mutation_id: mutation_id)
|
|
29
|
+
rescue Ace::Runtime::RuntimeUnavailableError
|
|
30
|
+
# Recover a possibly accepted challenge through current status,
|
|
31
|
+
# never refreshing the original generation or silently claiming.
|
|
32
|
+
end
|
|
33
|
+
status, context, input = recovery_status!(params, input_bytes)
|
|
34
|
+
return projection(status) if %w[succeeded failed-settled].include?(status.fetch("state"))
|
|
35
|
+
return uncertain(request_id) unless context["challenge"]
|
|
36
|
+
end
|
|
37
|
+
operation = recovery_inspector!(context)
|
|
38
|
+
root = Ace::Assign::Authority::PrivateDirectory.verify!(@receiver.fetch("staging_root"))
|
|
39
|
+
directory = Dir.mktmpdir("recovery-", root)
|
|
40
|
+
File.chmod(0o700, directory)
|
|
41
|
+
identity = File.lstat(directory)
|
|
42
|
+
envelope = immutable("version" => 1, "kind" => "service_no_effect_inspection",
|
|
43
|
+
"request" => context.fetch("request"), "input" => input.fetch(:input),
|
|
44
|
+
"execution" => context.fetch("execution"), "challenge" => context.fetch("challenge"))
|
|
45
|
+
# The selected inspector supplies retained success or positive absence;
|
|
46
|
+
# neither follows from a timeout or process/scope cleanup.
|
|
47
|
+
if context.dig("request", "operation") == "prune-preserved-workspace"
|
|
48
|
+
return uncertain(request_id) unless @cleanup_owner && context.dig("execution", "dispatch_phase") == "dispatch_started"
|
|
49
|
+
inspected_kind, artifacts = cleanup_inspection_evidence!(context, input.fetch(:input))
|
|
50
|
+
if inspected_kind == "succeeded"
|
|
51
|
+
evidence = artifacts.each_with_index.map do |artifact, index|
|
|
52
|
+
{"ref" => "cleanup-#{index}", "sha256" => Digest::SHA256.hexdigest(artifact)}
|
|
53
|
+
end
|
|
54
|
+
receipt = JSON.generate(context.fetch("request").merge("outcome" => "succeeded", "evidence" => evidence))
|
|
55
|
+
completion = params.merge("claim_binding" => context.dig("execution", "claim_binding"),
|
|
56
|
+
"receipt_sha256" => Digest::SHA256.hexdigest(receipt))
|
|
57
|
+
@client.call("complete_service", completion,
|
|
58
|
+
mutation_id: Digest::SHA256.hexdigest("cleanup-completed:#{mutation_id}"),
|
|
59
|
+
upload_parts: [receipt] + artifacts, purpose: :receipt_artifacts)
|
|
60
|
+
completed, = recovery_status!(params, input_bytes)
|
|
61
|
+
return uncertain(request_id) unless completed["state"] == "succeeded"
|
|
62
|
+
cleanup_staging(directory, identity, prefix: "recovery-")
|
|
63
|
+
return projection(completed)
|
|
64
|
+
end
|
|
65
|
+
response = {"outcome" => "failed", "request_id" => request_id,
|
|
66
|
+
"input_digest" => context.dig("request", "input_digest"), "evidence" => artifacts.each_with_index.map do |artifact, index|
|
|
67
|
+
{"ref" => index.zero? ? "cleanup-inspection-no-effect" : "cleanup-inspection-selection", "sha256" => Digest::SHA256.hexdigest(artifact)}
|
|
68
|
+
end}
|
|
69
|
+
else
|
|
70
|
+
response = @handler.execute(operation: immutable(operation), envelope: envelope, candidate_root: directory)
|
|
71
|
+
end
|
|
72
|
+
return uncertain(request_id) unless response && response["outcome"] == "failed" &&
|
|
73
|
+
response["request_id"] == request_id && response["input_digest"] == context.dig("request", "input_digest")
|
|
74
|
+
artifacts ||= Ace::Assign::Authority::EvidenceTransfer.read_staged(root: directory, evidence: response.fetch("evidence"))
|
|
75
|
+
receipt = context.fetch("request").merge("outcome" => "failed", "evidence" => response.fetch("evidence"))
|
|
76
|
+
bytes = JSON.generate(receipt)
|
|
77
|
+
completion_params = params.merge("claim_binding" => context.dig("execution", "claim_binding"),
|
|
78
|
+
"reconciliation_challenge" => context.fetch("challenge").slice("no_effect_challenge", "challenge_generation", "challenge_event_digest"),
|
|
79
|
+
"receipt_sha256" => Digest::SHA256.hexdigest(bytes))
|
|
80
|
+
@client.call("complete_no_effect", completion_params,
|
|
81
|
+
mutation_id: Digest::SHA256.hexdigest("no-effect:#{mutation_id}"),
|
|
82
|
+
upload_parts: [bytes] + artifacts, purpose: :receipt_artifacts)
|
|
83
|
+
# Protected status invokes the shared canonical receipt/import reader.
|
|
84
|
+
# Never accept a terminal state string without that authentication.
|
|
85
|
+
settled, = recovery_status!(params, input_bytes)
|
|
86
|
+
return uncertain(request_id) unless settled["state"] == "failed-settled"
|
|
87
|
+
cleanup_staging(directory, identity, prefix: "recovery-")
|
|
88
|
+
projection(settled)
|
|
89
|
+
rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Ace::Lab::InvalidConfigurationError,
|
|
90
|
+
SecurityError, ArgumentError, KeyError, TypeError, SystemCallError
|
|
91
|
+
uncertain(request_id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def recovery_binding!(binding, bytes, mutation_id, generation)
|
|
97
|
+
unless binding.is_a?(Hash) && binding.keys.sort == RECOVERY_BINDING.sort &&
|
|
98
|
+
%w[assignment_id attempt_id request_id].all? { |key| binding[key].is_a?(String) && binding[key].match?(Ace::Assign::Molecules::JournalMutation::ID) } &&
|
|
99
|
+
binding["head"].is_a?(String) && binding["head"].match?(Ace::Assign::Authority::CandidateTransfer::SHA) &&
|
|
100
|
+
binding["candidate_generation"].is_a?(Integer) && binding["candidate_generation"] >= 0 &&
|
|
101
|
+
generation.is_a?(Integer) && generation.positive? && bytes.is_a?(String) &&
|
|
102
|
+
mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
|
|
103
|
+
raise ArgumentError, "original recovery binding is invalid"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def recovery_executor!
|
|
108
|
+
me = @kernel.capture(Process.pid)
|
|
109
|
+
credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
|
|
110
|
+
unless me["uid"] == @receiver.fetch("executor_uid") && me["gid"] == credentials.fetch("gid") &&
|
|
111
|
+
me["groups"] == credentials.fetch("groups")
|
|
112
|
+
raise SecurityError, "recovery executor differs"
|
|
113
|
+
end
|
|
114
|
+
@kernel.live!(me)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def recovery_status!(params, bytes)
|
|
118
|
+
reply = @client.call("service_status", params)
|
|
119
|
+
data = reply.data
|
|
120
|
+
context = data.fetch("settlement_context")
|
|
121
|
+
unless context.is_a?(Hash) && context.keys.sort == %w[version request execution challenge].sort &&
|
|
122
|
+
context["version"].is_a?(Integer) && context["version"] == 1
|
|
123
|
+
raise SecurityError, "canonical recovery context is malformed"
|
|
124
|
+
end
|
|
125
|
+
request, execution, challenge = context.values_at("request", "execution", "challenge")
|
|
126
|
+
execution_fields = request.is_a?(Hash) && request["operation"] == "prune-preserved-workspace" &&
|
|
127
|
+
execution.is_a?(Hash) && execution["dispatch_phase"] == "dispatch_started" ? CLEANUP_RECOVERY_EXECUTION : RECOVERY_EXECUTION
|
|
128
|
+
unless request.is_a?(Hash) && request.keys.sort == Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS.sort &&
|
|
129
|
+
execution.is_a?(Hash) && execution.keys.sort == execution_fields.sort &&
|
|
130
|
+
%w[assignment_id attempt_id request_id].all? { |key| request[key] == params.fetch(key) } &&
|
|
131
|
+
request["project_id"] == @map.fetch("project_id") && request["executor_uid"] == @receiver.fetch("executor_uid") &&
|
|
132
|
+
request["transport"] == "unix" && request["candidate_head"] == params.fetch("head") &&
|
|
133
|
+
execution["mapping_id"] == @mapping_id && execution["candidate_generation"] == params.fetch("candidate_generation") &&
|
|
134
|
+
execution["head"] == params.fetch("head") && %w[issued dispatch_started].include?(execution["dispatch_phase"]) &&
|
|
135
|
+
%w[uncertain failed succeeded failed-settled].include?(data["state"]) &&
|
|
136
|
+
data["request_id"] == params.fetch("request_id") && data["service_id"] == @service_id
|
|
137
|
+
raise SecurityError, "canonical recovery original binding differs"
|
|
138
|
+
end
|
|
139
|
+
if !challenge.nil? && (!challenge.is_a?(Hash) || challenge.keys.sort != RECOVERY_CHALLENGE.sort ||
|
|
140
|
+
!%w[challenge_generation failure_generation].all? { |key| challenge[key].is_a?(Integer) && challenge[key].positive? } ||
|
|
141
|
+
challenge["request_id"] != request["request_id"] || challenge["input_digest"] != request["input_digest"] ||
|
|
142
|
+
challenge["claim_binding"] != execution["claim_binding"])
|
|
143
|
+
raise SecurityError, "canonical recovery challenge differs"
|
|
144
|
+
end
|
|
145
|
+
input = @inputs.input_binding(bytes, expected_digest: request.fetch("input_digest"),
|
|
146
|
+
expected_target: request.fetch("target"), operation: request.fetch("operation"))
|
|
147
|
+
[data, immutable(context), input]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def cleanup_inspection_evidence!(context, input)
|
|
151
|
+
request, execution = context.values_at("request", "execution")
|
|
152
|
+
result = @cleanup_owner.inspect!(request: request.slice("request_id", "input_digest").merge(
|
|
153
|
+
"claim_binding" => execution.fetch("claim_binding"),
|
|
154
|
+
"request_event_digest" => execution.fetch("request_event_digest"),
|
|
155
|
+
"dispatch_event_digest" => execution.fetch("dispatch_event_digest"), "input" => input,
|
|
156
|
+
"challenge_ref" => context.fetch("challenge").slice("challenge_event_digest")),
|
|
157
|
+
operation_owner_binding: execution.fetch("operation_owner_binding"))
|
|
158
|
+
unless result.is_a?(Hash) && result.keys.sort == %i[bytes kind receipt_ref] &&
|
|
159
|
+
%w[succeeded failed-no-effect].include?(result[:kind])
|
|
160
|
+
raise SecurityError, "original cleanup inspection kind differs"
|
|
161
|
+
end
|
|
162
|
+
success = result.fetch(:kind) == "succeeded"
|
|
163
|
+
ref, bytes = result.values_at(:receipt_ref, :bytes)
|
|
164
|
+
unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && bytes.is_a?(String) &&
|
|
165
|
+
bytes.bytesize.between?(1, 65_536) && ref["bytes"].is_a?(Integer) && ref["bytes"] == bytes.bytesize &&
|
|
166
|
+
ref["sha256"] == Digest::SHA256.hexdigest(bytes)
|
|
167
|
+
raise SecurityError, "original cleanup inspection result differs"
|
|
168
|
+
end
|
|
169
|
+
selection = {"schema" => success ? Ace::Assign::Authority::ServiceCleanupEvidence::ROOT_SELECTION_SCHEMA : Ace::Assign::Authority::ServiceCleanupEvidence::ROOT_INSPECTION_SELECTION_SCHEMA,
|
|
170
|
+
"request_id" => request.fetch("request_id"), "input_digest" => request.fetch("input_digest"),
|
|
171
|
+
"operation_owner_binding_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(execution.fetch("operation_owner_binding")),
|
|
172
|
+
(success ? "receipt_ref" : "inspection_ref") => ref}
|
|
173
|
+
[result.fetch(:kind), [bytes, JSON.generate(selection)].freeze].freeze
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def recovery_inspector!(context)
|
|
177
|
+
request = context.fetch("request")
|
|
178
|
+
policy = Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path))
|
|
179
|
+
policy.inspection!(request.fetch("operation"), project: @map.fetch("project_id"),
|
|
180
|
+
service_id: @service_id, executor_uid: @receiver.fetch("executor_uid"))
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "ace/assign/authority/protected_assignment_context"
|
|
3
|
+
require "ace/assign/organisms/protected_delivery_coordinator"
|
|
4
|
+
require_relative "protected_service_client"
|
|
5
|
+
require "ace/git/atoms/service_pr_input"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Lab
|
|
9
|
+
module Organisms
|
|
10
|
+
# Public selection adapter. The original authority and fixed receiver
|
|
11
|
+
# own every grant/effect; this adapter has no local journal or executor.
|
|
12
|
+
class ProtectedServiceRequest
|
|
13
|
+
FLAGS = %i[mapping scope service candidate_head candidate_generation expected_generation input_digest target artifact_digest].freeze
|
|
14
|
+
LIMIT = 16_384
|
|
15
|
+
|
|
16
|
+
def initialize(context: Ace::Assign::Authority::ProtectedAssignmentContext.load, ingress_factory: nil)
|
|
17
|
+
@context = context
|
|
18
|
+
@ingress = ingress_factory || ->(**selection) { ProtectedServiceClient.new(**selection) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def selected?(options)
|
|
22
|
+
@context.protected_participant? || @context.mapping_hint? || FLAGS.any? { |key| !options[key].nil? }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def request(project:, assignment:, attempt:, operation:, input_path:, authorization:, request_id:, **options)
|
|
26
|
+
refuse_nonworker!
|
|
27
|
+
unless (options.keys - %i[mapping scope service candidate_head candidate_generation expected_generation dry_run]).empty?
|
|
28
|
+
raise ArgumentError, "protected request rejects incompatible options"
|
|
29
|
+
end
|
|
30
|
+
raise ArgumentError, "protected service request requires a fixed PR operation and no dry-run" unless Ace::Git::Atoms::ServicePrInput::OPERATIONS.include?(operation) && !options[:dry_run]
|
|
31
|
+
validate!(project, assignment, attempt, request_id, options)
|
|
32
|
+
unless authorization.is_a?(String) && authorization.match?(Molecules::ServicePolicy::ID) &&
|
|
33
|
+
options[:service].is_a?(String) && options[:service].match?(Molecules::ServicePolicy::ID) &&
|
|
34
|
+
options[:expected_generation].is_a?(Integer) && options[:expected_generation].positive?
|
|
35
|
+
raise ArgumentError, "protected service request requires receiver, authorization and original generation"
|
|
36
|
+
end
|
|
37
|
+
input = Atoms::ServiceInput.load(input_path)
|
|
38
|
+
Ace::Git::Atoms::ServicePrInput.validate(input, operation: operation)
|
|
39
|
+
target = Atoms::ServiceInput.target(input)
|
|
40
|
+
digest = Atoms::ServiceInput.digest(input)
|
|
41
|
+
prepared = resolve!(project, assignment, attempt, options)
|
|
42
|
+
selection = {"project_id" => project, "mapping_id" => options.fetch(:mapping), "assignment_id" => assignment,
|
|
43
|
+
"attempt_id" => attempt, "scope" => options.fetch(:scope), "service_id" => options.fetch(:service),
|
|
44
|
+
"candidate_head" => options.fetch(:candidate_head), "candidate_generation" => options.fetch(:candidate_generation),
|
|
45
|
+
"expected_generation" => options.fetch(:expected_generation), "request_id" => request_id,
|
|
46
|
+
"input_digest" => digest, "target" => target}
|
|
47
|
+
submission = {"assignment_id" => assignment, "attempt_id" => attempt, "expected_generation" => options.fetch(:expected_generation),
|
|
48
|
+
"candidate_generation" => options.fetch(:candidate_generation), "head" => options.fetch(:candidate_head),
|
|
49
|
+
"request_id" => request_id, "operation" => operation, "input_digest" => digest, "target" => target,
|
|
50
|
+
"authorization" => authorization}
|
|
51
|
+
mutation = Digest::SHA256.hexdigest("qkb.#{operation}.claim:v1:#{assignment}:#{attempt}:#{request_id}")
|
|
52
|
+
claim = @context.with_installed_selection(options: options, input: prepared) do |deployment, kernel, mapping|
|
|
53
|
+
@ingress.call(mapping_id: mapping, service_id: options.fetch(:service), deployment: deployment, kernel: kernel)
|
|
54
|
+
.submit(submission: submission, input_bytes: JSON.generate(input), mutation_id: mutation)
|
|
55
|
+
end
|
|
56
|
+
envelope = if claim.fetch("type") == "service_claim_accepted"
|
|
57
|
+
{"status" => "ok", "data" => {"selection" => selection, "claim" => claim}}
|
|
58
|
+
else
|
|
59
|
+
code = claim.fetch("type")
|
|
60
|
+
{"status" => "error", "error" => {"code" => code,
|
|
61
|
+
"message" => "inspect original canonical service status; no automatic resubmission",
|
|
62
|
+
"selection" => selection, "claim" => claim}}
|
|
63
|
+
end
|
|
64
|
+
bounded!(envelope, selection: selection)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def status(request_id:, **options)
|
|
68
|
+
refuse_nonworker!
|
|
69
|
+
unless (options.keys - %i[project assignment attempt mapping scope candidate_head candidate_generation input_digest target artifact_digest format]).empty?
|
|
70
|
+
raise ArgumentError, "protected status rejects incompatible options"
|
|
71
|
+
end
|
|
72
|
+
project, assignment, attempt = options.values_at(:project, :assignment, :attempt)
|
|
73
|
+
validate!(project, assignment, attempt, request_id, options)
|
|
74
|
+
unless options[:input_digest].is_a?(String) && options[:input_digest].match?(Atoms::ServiceInput::SHA256)
|
|
75
|
+
raise ArgumentError, "protected status requires original input digest"
|
|
76
|
+
end
|
|
77
|
+
target = Atoms::ServiceInput.target({"target" => {"resource" => options[:target], "artifact_digest" => options[:artifact_digest]}})
|
|
78
|
+
resolve!(project, assignment, attempt, options)
|
|
79
|
+
data = Ace::Assign::Organisms::ProtectedDeliveryCoordinator.new(client: @context.client(options: options), project_id: project)
|
|
80
|
+
.perform(assignment_id: assignment, attempt_id: attempt, operation: "status", service_request_id: request_id,
|
|
81
|
+
candidate_head: options.fetch(:candidate_head), candidate_generation: options.fetch(:candidate_generation),
|
|
82
|
+
input_digest: options.fetch(:input_digest), target: target)
|
|
83
|
+
bounded!({"status" => "ok", "data" => data})
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def refuse_nonworker!
|
|
89
|
+
if @context.protected_participant? && !@context.protected_worker?
|
|
90
|
+
raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "protected service request/status is worker-only"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate!(project, assignment, attempt, request_id, options)
|
|
95
|
+
unless [project, assignment, attempt, request_id, options[:mapping]].all? { |value| value.is_a?(String) && value.match?(Molecules::ServicePolicy::ID) } &&
|
|
96
|
+
options[:scope].is_a?(String) && options[:scope].match?(Ace::Assign::Authority::PreparedWork::SCOPE) &&
|
|
97
|
+
options[:candidate_head].is_a?(String) && options[:candidate_head].match?(/\A[0-9a-f]{40}\z/) &&
|
|
98
|
+
options[:candidate_generation].is_a?(Integer) && options[:candidate_generation].positive?
|
|
99
|
+
raise ArgumentError, "protected service requires exact original scoped selectors"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resolve!(project, assignment, attempt, options)
|
|
104
|
+
input = @context.resolve(options: options.merge(attempt: attempt), assignment_id: assignment, scope: options.fetch(:scope))
|
|
105
|
+
unless input && input.descriptor.values_at("project_id", "assignment_id", "attempt_id", "scope", "mapping_id") ==
|
|
106
|
+
[project, assignment, attempt, options.fetch(:scope), options.fetch(:mapping)]
|
|
107
|
+
raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "original prepared service selection differs"
|
|
108
|
+
end
|
|
109
|
+
input
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def bounded!(envelope, selection: nil)
|
|
113
|
+
return envelope if JSON.generate(envelope).bytesize <= LIMIT
|
|
114
|
+
if selection
|
|
115
|
+
# Submission may already be durable. Never classify lost output as
|
|
116
|
+
# a pre-effect refusal or retry; retain the original lookup tuple.
|
|
117
|
+
envelope = {"status" => "error", "error" => {"code" => "service_claim_unconfirmed",
|
|
118
|
+
"message" => "claim output unavailable; inspect original canonical service status without resubmission",
|
|
119
|
+
"selection" => selection, "claim" => {"version" => 1, "type" => "service_claim_unconfirmed",
|
|
120
|
+
"required_action" => "inspect_canonical_service_status"}}}
|
|
121
|
+
return envelope if JSON.generate(envelope).bytesize <= LIMIT
|
|
122
|
+
end
|
|
123
|
+
raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "protected status output exceeds bound"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_service_receiver"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Organisms
|
|
8
|
+
# One admitted worker for one installed receiver. Capacity is acquired
|
|
9
|
+
# before the canonical claim, not after an accepted request. This owns
|
|
10
|
+
# execution lifetime only; it never substitutes for canonical status.
|
|
11
|
+
class ProtectedServiceWorker
|
|
12
|
+
def initialize(receiver:)
|
|
13
|
+
@receiver = receiver
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@worker = nil
|
|
16
|
+
@closing = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start(submission:, peer:, input_bytes:, mutation_id:, on_claim:, deadline: nil)
|
|
20
|
+
raise ArgumentError, "receiver acceptance callback unavailable" unless on_claim.respond_to?(:call)
|
|
21
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
|
+
deadline ||= now + 5
|
|
23
|
+
unless deadline.is_a?(Numeric) && deadline.finite? && deadline > now && deadline <= now + 5
|
|
24
|
+
raise ArgumentError, "receiver acceptance deadline is invalid"
|
|
25
|
+
end
|
|
26
|
+
# Hold independent copies before returning to the socket handler.
|
|
27
|
+
submission = copy(submission)
|
|
28
|
+
peer = copy(peer)
|
|
29
|
+
input_bytes = input_bytes.dup.freeze
|
|
30
|
+
mutation_id = mutation_id.dup.freeze
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
raise Ace::Assign::AttemptErrors::Conflict, "receiver capacity unavailable" if @closing || @worker&.alive?
|
|
33
|
+
|
|
34
|
+
@worker = Thread.new do
|
|
35
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
36
|
+
next({"state" => "uncertain"}) unless remaining.positive?
|
|
37
|
+
result = @receiver.execute(submission: submission, peer: peer, input_bytes: input_bytes,
|
|
38
|
+
mutation_id: mutation_id, claim_timeout: [remaining, 5].min,
|
|
39
|
+
on_claim: lambda do |identity|
|
|
40
|
+
begin
|
|
41
|
+
on_claim.call(identity)
|
|
42
|
+
rescue IOError, SystemCallError
|
|
43
|
+
# A lost acceptance reply cannot cancel already admitted
|
|
44
|
+
# work or cause a second execution. Status owns recovery.
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end)
|
|
48
|
+
if submission.fetch("operation") == "publish"
|
|
49
|
+
while @receiver.publication_active? && !@mutex.synchronize { @closing }
|
|
50
|
+
result = @receiver.tick_publication!(request_id: submission.fetch("request_id"),
|
|
51
|
+
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
|
|
52
|
+
sleep 0.25 if @receiver.publication_active?
|
|
53
|
+
end
|
|
54
|
+
@receiver.release_publication_lifetime!
|
|
55
|
+
end
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Stop admission before joining. A timed-out join preserves both the
|
|
62
|
+
# worker and closing state; the listener must retain its lifetime lock.
|
|
63
|
+
def close(timeout:)
|
|
64
|
+
unless timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0
|
|
65
|
+
raise ArgumentError, "receiver close deadline is invalid"
|
|
66
|
+
end
|
|
67
|
+
worker = @mutex.synchronize do
|
|
68
|
+
@closing = true
|
|
69
|
+
@worker
|
|
70
|
+
end
|
|
71
|
+
!worker || !worker.join(timeout).nil?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def copy(value)
|
|
77
|
+
duplicate = JSON.parse(JSON.generate(value))
|
|
78
|
+
freeze_value(duplicate)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def freeze_value(value)
|
|
82
|
+
value.each_value { |child| freeze_value(child) } if value.is_a?(Hash)
|
|
83
|
+
value.each { |child| freeze_value(child) } if value.is_a?(Array)
|
|
84
|
+
value.freeze
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -69,7 +69,8 @@ module Ace
|
|
|
69
69
|
return route.envelope unless route.ok?
|
|
70
70
|
service_id = route.data.fetch("entry").fetch("id")
|
|
71
71
|
trusted = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path) unless @policy
|
|
72
|
-
policy = @policy || Molecules::ServicePolicy.new(trusted
|
|
72
|
+
policy = @policy || Molecules::ServicePolicy.new(trusted,
|
|
73
|
+
->(reference, exact) { @coordinator.proposal_authorization(reference, exact) })
|
|
73
74
|
head = current_head
|
|
74
75
|
binding = {"request_id" => request_id, "assignment_id" => assignment, "attempt_id" => attempt,
|
|
75
76
|
"project_id" => project, "operation" => operation,
|
|
@@ -212,7 +213,8 @@ module Ace
|
|
|
212
213
|
if @policy
|
|
213
214
|
-> { @policy }
|
|
214
215
|
else
|
|
215
|
-
-> { Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
|
|
216
|
+
-> { Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path),
|
|
217
|
+
->(reference, exact) { @coordinator.proposal_authorization(reference, exact) }) }
|
|
216
218
|
end
|
|
217
219
|
end
|
|
218
220
|
|
data/lib/ace/lab/version.rb
CHANGED
data/lib/ace/lab.rb
CHANGED
|
@@ -3,14 +3,8 @@
|
|
|
3
3
|
require_relative "lab/version"
|
|
4
4
|
require "ace/support/config"
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class Error < StandardError; end
|
|
9
|
-
|
|
10
|
-
# Raised when topology configuration violates the schema contract
|
|
11
|
-
class InvalidConfigurationError < Error; end
|
|
12
|
-
end
|
|
13
|
-
end
|
|
6
|
+
require_relative "lab/errors"
|
|
7
|
+
require_relative "lab/authorization_path"
|
|
14
8
|
|
|
15
9
|
# Load all ace-lab components
|
|
16
10
|
require_relative "lab/atoms/topology_schema"
|
|
@@ -63,20 +57,6 @@ module Ace
|
|
|
63
57
|
# Same discovery the cascade resolver performs for this namespace
|
|
64
58
|
LAB_FILE_PATTERNS = ["lab/config.yml", "lab/config.yaml"].freeze
|
|
65
59
|
|
|
66
|
-
# Authorization grants live in a single deployment-controlled file at a
|
|
67
|
-
# FIXED path — never in the caller-writable configuration cascade and
|
|
68
|
-
# never at a caller-selected location (review rounds 4-5, F3/F1). The
|
|
69
|
-
# file must be root-owned and not group/world-writable, including every
|
|
70
|
-
# directory on its real path; GrantResolver verifies and fails closed.
|
|
71
|
-
AUTHORIZATION_PATH = "/etc/lab/ace-lab/authorization.yml"
|
|
72
|
-
|
|
73
|
-
# Path of the trusted authorization grants document. A method (not a
|
|
74
|
-
# bare constant reference) so tests can stub the seam without any
|
|
75
|
-
# caller-controllable production override.
|
|
76
|
-
# @return [String]
|
|
77
|
-
def self.authorization_path
|
|
78
|
-
AUTHORIZATION_PATH
|
|
79
|
-
end
|
|
80
60
|
|
|
81
61
|
# Load configuration using Ace::Support::Config cascade.
|
|
82
62
|
# Load failures raise InvalidConfigurationError (classified by the query
|
metadata
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-lab
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-08 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ace-runtime
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.2'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: ace-support-cli
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,14 +71,28 @@ dependencies:
|
|
|
57
71
|
requirements:
|
|
58
72
|
- - "~>"
|
|
59
73
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '0.
|
|
74
|
+
version: '0.66'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.66'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: ace-hitl
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.13'
|
|
61
89
|
type: :runtime
|
|
62
90
|
prerelease: false
|
|
63
91
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
92
|
requirements:
|
|
65
93
|
- - "~>"
|
|
66
94
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '0.
|
|
95
|
+
version: '0.13'
|
|
68
96
|
- !ruby/object:Gem::Dependency
|
|
69
97
|
name: ace-support-test-helpers
|
|
70
98
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -168,9 +196,13 @@ files:
|
|
|
168
196
|
- exe/ace-lab
|
|
169
197
|
- lib/ace/lab.rb
|
|
170
198
|
- lib/ace/lab/atoms/binding_freshness.rb
|
|
199
|
+
- lib/ace/lab/atoms/protected_publication_input.rb
|
|
200
|
+
- lib/ace/lab/atoms/protected_workspace_prune_input.rb
|
|
201
|
+
- lib/ace/lab/atoms/protected_workspace_prune_preview.rb
|
|
171
202
|
- lib/ace/lab/atoms/public_projection.rb
|
|
172
203
|
- lib/ace/lab/atoms/service_input.rb
|
|
173
204
|
- lib/ace/lab/atoms/topology_schema.rb
|
|
205
|
+
- lib/ace/lab/authorization_path.rb
|
|
174
206
|
- lib/ace/lab/cli.rb
|
|
175
207
|
- lib/ace/lab/cli/commands/agents.rb
|
|
176
208
|
- lib/ace/lab/cli/commands/projects.rb
|
|
@@ -179,6 +211,7 @@ files:
|
|
|
179
211
|
- lib/ace/lab/cli/commands/service.rb
|
|
180
212
|
- lib/ace/lab/cli/commands/services.rb
|
|
181
213
|
- lib/ace/lab/cli/commands/support.rb
|
|
214
|
+
- lib/ace/lab/errors.rb
|
|
182
215
|
- lib/ace/lab/models/query_result.rb
|
|
183
216
|
- lib/ace/lab/models/runtime_binding.rb
|
|
184
217
|
- lib/ace/lab/models/service_receipt.rb
|
|
@@ -189,9 +222,24 @@ files:
|
|
|
189
222
|
- lib/ace/lab/molecules/grant_resolver.rb
|
|
190
223
|
- lib/ace/lab/molecules/hitl_authorizer.rb
|
|
191
224
|
- lib/ace/lab/molecules/inventory_query.rb
|
|
225
|
+
- lib/ace/lab/molecules/protected_cleanup_owner_admission.rb
|
|
226
|
+
- lib/ace/lab/molecules/protected_cleanup_owner_client.rb
|
|
227
|
+
- lib/ace/lab/molecules/protected_cleanup_owner_identity.rb
|
|
228
|
+
- lib/ace/lab/molecules/protected_publication_publisher.rb
|
|
229
|
+
- lib/ace/lab/molecules/protected_service_handler.rb
|
|
230
|
+
- lib/ace/lab/molecules/protected_service_policy.rb
|
|
192
231
|
- lib/ace/lab/molecules/service_executor.rb
|
|
193
232
|
- lib/ace/lab/molecules/service_policy.rb
|
|
194
233
|
- lib/ace/lab/molecules/topology_loader.rb
|
|
234
|
+
- lib/ace/lab/organisms/authority_composition.rb
|
|
235
|
+
- lib/ace/lab/organisms/protected_cleanup_owner.rb
|
|
236
|
+
- lib/ace/lab/organisms/protected_service_client.rb
|
|
237
|
+
- lib/ace/lab/organisms/protected_service_listener.rb
|
|
238
|
+
- lib/ace/lab/organisms/protected_service_publication.rb
|
|
239
|
+
- lib/ace/lab/organisms/protected_service_receiver.rb
|
|
240
|
+
- lib/ace/lab/organisms/protected_service_recovery.rb
|
|
241
|
+
- lib/ace/lab/organisms/protected_service_request.rb
|
|
242
|
+
- lib/ace/lab/organisms/protected_service_worker.rb
|
|
195
243
|
- lib/ace/lab/organisms/service_request_service.rb
|
|
196
244
|
- lib/ace/lab/organisms/topology_service.rb
|
|
197
245
|
- lib/ace/lab/version.rb
|