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,185 +0,0 @@
|
|
|
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
|
|
@@ -1,128 +0,0 @@
|
|
|
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
|
|
@@ -1,89 +0,0 @@
|
|
|
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
|
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "digest"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
require "open3"
|
|
6
|
-
require "ace/assign"
|
|
7
|
-
|
|
8
|
-
module Ace
|
|
9
|
-
module Lab
|
|
10
|
-
module Organisms
|
|
11
|
-
# Coordinates the generic request contract. ace-assign owns the durable
|
|
12
|
-
# claim and receipt; this class never writes evidence files directly.
|
|
13
|
-
class ServiceRequestService
|
|
14
|
-
# Immutable request identity: a replay must match every field; the
|
|
15
|
-
# candidate head is deliberately absent because replays return the
|
|
16
|
-
# head recorded at claim time.
|
|
17
|
-
SERVICE_REPLAY_FIELDS = %w[project_id operation input_digest assignment_id attempt_id
|
|
18
|
-
target authorization caller_uid request_id].freeze
|
|
19
|
-
|
|
20
|
-
def initialize(topology: nil, policy: nil, coordinator: nil, executor: nil, repo_root: nil)
|
|
21
|
-
@topology = topology || TopologyService.from_config
|
|
22
|
-
@policy = policy
|
|
23
|
-
@repo_root = repo_root || git_toplevel
|
|
24
|
-
@coordinator = coordinator ||
|
|
25
|
-
Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: @repo_root)
|
|
26
|
-
@executor = executor || Molecules::ServiceExecutor.new
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def request(project:, assignment:, attempt:, operation:, input_path:, authorization:, request_id:, dry_run: false)
|
|
30
|
-
{project: project, assignment: assignment, attempt: attempt, request: request_id}.each do |name, value|
|
|
31
|
-
raise ArgumentError, "invalid #{name} ID" unless value.is_a?(String) &&
|
|
32
|
-
value.match?(Molecules::ServicePolicy::ID)
|
|
33
|
-
end
|
|
34
|
-
input = Atoms::ServiceInput.load(input_path)
|
|
35
|
-
target = Atoms::ServiceInput.target(input)
|
|
36
|
-
# An identical retry returns the stored outcome — with the head
|
|
37
|
-
# recorded at claim time — without re-checking attempt, policy, or
|
|
38
|
-
# routing: a completed request must survive attempt terminality,
|
|
39
|
-
# authorization expiry, routing changes, and candidate-head
|
|
40
|
-
# advance. Any changed immutable field under the same request ID is
|
|
41
|
-
# a conflict, never a replay.
|
|
42
|
-
existing = @coordinator.service_request_status(request_id)
|
|
43
|
-
if existing
|
|
44
|
-
# Current project visibility gates replays: a revoked grant must
|
|
45
|
-
# not expose stored receipts, though the old operation route and
|
|
46
|
-
# authorization decision do not need to remain active.
|
|
47
|
-
visibility = @topology.services(project: project)
|
|
48
|
-
return visibility.envelope unless visibility.ok?
|
|
49
|
-
supplied = {"request_id" => request_id, "assignment_id" => assignment, "attempt_id" => attempt,
|
|
50
|
-
"project_id" => project, "operation" => operation,
|
|
51
|
-
"input_digest" => Atoms::ServiceInput.digest(input), "target" => target,
|
|
52
|
-
"caller_uid" => Process.uid, "authorization" => authorization}
|
|
53
|
-
changed = SERVICE_REPLAY_FIELDS.find do |field|
|
|
54
|
-
existing[field] != supplied.fetch(field)
|
|
55
|
-
end
|
|
56
|
-
if changed
|
|
57
|
-
raise Ace::Assign::AttemptErrors::Conflict,
|
|
58
|
-
"Service request #{request_id} has different #{changed}"
|
|
59
|
-
end
|
|
60
|
-
if dry_run
|
|
61
|
-
# A preview of an existing request still validates current
|
|
62
|
-
# eligibility and reports itself as a dry run.
|
|
63
|
-
@coordinator.request_eligible!(existing)
|
|
64
|
-
return {"status" => "ok", "data" => result(existing).fetch("data").merge("dry_run" => true)}
|
|
65
|
-
end
|
|
66
|
-
return result(existing)
|
|
67
|
-
end
|
|
68
|
-
route = @topology.route(project: project, capability: operation)
|
|
69
|
-
return route.envelope unless route.ok?
|
|
70
|
-
service_id = route.data.fetch("entry").fetch("id")
|
|
71
|
-
trusted = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path) unless @policy
|
|
72
|
-
policy = @policy || Molecules::ServicePolicy.new(trusted,
|
|
73
|
-
->(reference, exact) { @coordinator.proposal_authorization(reference, exact) })
|
|
74
|
-
head = current_head
|
|
75
|
-
binding = {"request_id" => request_id, "assignment_id" => assignment, "attempt_id" => attempt,
|
|
76
|
-
"project_id" => project, "operation" => operation,
|
|
77
|
-
"input_digest" => Atoms::ServiceInput.digest(input), "target" => target,
|
|
78
|
-
"candidate_head" => head, "caller_uid" => Process.uid}
|
|
79
|
-
binding["authorization"] = authorization
|
|
80
|
-
binding["service_id"] = service_id
|
|
81
|
-
binding["caller_uid"] = Process.uid
|
|
82
|
-
validate_attempt!(binding)
|
|
83
|
-
begin
|
|
84
|
-
operation_policy = policy.operation!(operation, project: project, service_id: service_id)
|
|
85
|
-
policy.authorize!(authorization, binding)
|
|
86
|
-
rescue SecurityError, Ace::Lab::InvalidConfigurationError
|
|
87
|
-
@coordinator.reject_service_request(binding, reason: "policy_rejected") unless dry_run
|
|
88
|
-
raise
|
|
89
|
-
end
|
|
90
|
-
# The trusted policy owns executor identity and transport; the
|
|
91
|
-
# journaled claim binds them so only that executor identity can
|
|
92
|
-
# later complete the receipt. A local operation whose configured
|
|
93
|
-
# executor is not this process is refused before any claim, so no
|
|
94
|
-
# authorization is consumed by a dispatch that cannot happen.
|
|
95
|
-
binding["executor_uid"] = operation_policy.fetch("executor_uid")
|
|
96
|
-
binding["transport"] = transport = operation_policy.fetch("transport", "local")
|
|
97
|
-
if transport == "local" &&
|
|
98
|
-
(Process.uid != binding["executor_uid"] || Process.euid != binding["executor_uid"])
|
|
99
|
-
# A preview records nothing: the durable rejection is only for
|
|
100
|
-
# real submissions, so the request ID stays reusable.
|
|
101
|
-
@coordinator.reject_service_request(binding, reason: "executor_identity_unavailable") unless dry_run
|
|
102
|
-
raise SecurityError, "current OS identity is not the configured executor"
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
if dry_run
|
|
106
|
-
# The preview applies the same read-only eligibility checks as
|
|
107
|
-
# the claim — review evidence for external effects and the
|
|
108
|
-
# current candidate head — so it cannot promise acceptance for a
|
|
109
|
-
# request that would fail on submission.
|
|
110
|
-
@coordinator.request_eligible!(binding)
|
|
111
|
-
preview = {"outcome" => "accepted", "dry_run" => true, "request_id" => request_id,
|
|
112
|
-
"project" => project, "operation" => operation, "service_id" => service_id,
|
|
113
|
-
"target" => target, "candidate_head" => head}
|
|
114
|
-
return {"status" => "ok", "data" => preview}
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# The claim itself records uncertain (dispatch intent): a crash
|
|
118
|
-
# after the claim leaves uncertainty, never a stranded accepted
|
|
119
|
-
# state that a retry would return without dispatching.
|
|
120
|
-
claimed = @coordinator.claim_service_request(binding)
|
|
121
|
-
return result(claimed) unless claimed["state"] == "uncertain" && claimed["journal_commit"]
|
|
122
|
-
|
|
123
|
-
# The executor reloads the trusted policy from disk at the effect
|
|
124
|
-
# boundary: a revocation after the claim must still stop dispatch.
|
|
125
|
-
begin
|
|
126
|
-
receipt = @executor.execute(operation: operation_policy, request: binding, input: input,
|
|
127
|
-
policy_loader: policy_loader, authorization: authorization,
|
|
128
|
-
head_loader: -> { current_head })
|
|
129
|
-
rescue SecurityError, Ace::Lab::InvalidConfigurationError => e
|
|
130
|
-
# Refusal before invocation: no effect was attempted. When this
|
|
131
|
-
# process is the executor identity (local transport), record a
|
|
132
|
-
# proven no-effect settlement instead of stranding uncertainty.
|
|
133
|
-
settle_pre_invocation_refusal(binding, request_id, e)
|
|
134
|
-
raise
|
|
135
|
-
end
|
|
136
|
-
return result(@coordinator.service_request_status(request_id)) if receipt.nil?
|
|
137
|
-
|
|
138
|
-
state = receipt.fetch("outcome")
|
|
139
|
-
full_receipt = Models::ServiceReceipt.build(binding, receipt)
|
|
140
|
-
result(@coordinator.transition_service_request(request_id, state: state, receipt: full_receipt))
|
|
141
|
-
rescue ArgumentError => e
|
|
142
|
-
failure("invalid_input", e.message)
|
|
143
|
-
rescue SecurityError => e
|
|
144
|
-
failure("unauthorized", e.message)
|
|
145
|
-
rescue Ace::Lab::InvalidConfigurationError => e
|
|
146
|
-
failure("invalid_configuration", e.message)
|
|
147
|
-
rescue Ace::Assign::AttemptErrors::Conflict => e
|
|
148
|
-
failure("conflict", e.message)
|
|
149
|
-
rescue Ace::Assign::AttemptErrors::NotFound, Ace::Assign::AttemptErrors::ReceiptRejected => e
|
|
150
|
-
failure("invalid_attempt", e.message)
|
|
151
|
-
rescue Ace::Assign::AttemptErrors::InvalidState => e
|
|
152
|
-
failure("invalid_attempt", e.message)
|
|
153
|
-
rescue Ace::Assign::AttemptErrors::EvidenceUnavailable => e
|
|
154
|
-
failure("evidence_unavailable", e.message)
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def status(request_id:)
|
|
158
|
-
request = @coordinator.service_request_status(request_id)
|
|
159
|
-
return failure("missing", "service request not found") unless request
|
|
160
|
-
return failure("unauthorized", "caller does not own the request") unless request["caller_uid"] == Process.uid
|
|
161
|
-
visibility = @topology.services(project: request["project_id"])
|
|
162
|
-
return visibility.envelope unless visibility.ok?
|
|
163
|
-
result(request)
|
|
164
|
-
rescue ArgumentError => e
|
|
165
|
-
failure("invalid_input", e.message)
|
|
166
|
-
rescue Ace::Assign::AttemptErrors::EvidenceUnavailable => e
|
|
167
|
-
failure("evidence_unavailable", e.message)
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
private
|
|
171
|
-
|
|
172
|
-
# A refusal raised before the handler was invoked proves no effect
|
|
173
|
-
# was attempted. When this process runs as the executor identity
|
|
174
|
-
# (local transport), record the proven no-effect settlement with an
|
|
175
|
-
# executor-owned attestation; otherwise the claim stays uncertain
|
|
176
|
-
# for evidence-based reconciliation.
|
|
177
|
-
def settle_pre_invocation_refusal(binding, request_id, error)
|
|
178
|
-
return unless binding["transport"] == "local" && Process.uid == binding["executor_uid"]
|
|
179
|
-
evidence_dir = File.join(@repo_root, "evidence", "service")
|
|
180
|
-
FileUtils.mkdir_p(evidence_dir)
|
|
181
|
-
attestation = File.join(evidence_dir, "#{request_id}-no-effect")
|
|
182
|
-
content = "ace-service-attestation request:#{request_id} " \
|
|
183
|
-
"input:#{binding.fetch("input_digest")} outcome:failed no-effect:true\n" \
|
|
184
|
-
"refusal: #{error.message}\n"
|
|
185
|
-
# Exclusive creation without following symlinks: the caller-controlled
|
|
186
|
-
# request ID must never truncate an existing file, and the artifact
|
|
187
|
-
# must stay private to pass the executor-owned verification.
|
|
188
|
-
File.open(attestation, File::WRONLY | File::CREAT | File::EXCL, 0600) do |file|
|
|
189
|
-
file.write(content)
|
|
190
|
-
end
|
|
191
|
-
receipt = Models::ServiceReceipt.build(binding, {
|
|
192
|
-
"outcome" => "failed",
|
|
193
|
-
"evidence" => [{"ref" => "evidence/service/#{request_id}-no-effect",
|
|
194
|
-
"sha256" => Digest::SHA256.hexdigest(content)}],
|
|
195
|
-
"executor_uid" => binding.fetch("executor_uid")
|
|
196
|
-
})
|
|
197
|
-
@coordinator.reconcile_service_no_effect(request_id, receipt: receipt)
|
|
198
|
-
rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::ReceiptRejected,
|
|
199
|
-
Ace::Assign::AttemptErrors::NotFound, Errno::ENOENT, Errno::EACCES, Errno::EEXIST
|
|
200
|
-
nil
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
# The Git toplevel (not a package directory that happens to hold a
|
|
204
|
-
# Rakefile) bounds evidence verification for nested invocations.
|
|
205
|
-
def git_toplevel
|
|
206
|
-
out, status = Open3.capture2("git", "rev-parse", "--show-toplevel")
|
|
207
|
-
(status&.success? ? out.strip : Dir.pwd)
|
|
208
|
-
rescue Errno::ENOENT
|
|
209
|
-
Dir.pwd
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def policy_loader
|
|
213
|
-
if @policy
|
|
214
|
-
-> { @policy }
|
|
215
|
-
else
|
|
216
|
-
-> { Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path),
|
|
217
|
-
->(reference, exact) { @coordinator.proposal_authorization(reference, exact) }) }
|
|
218
|
-
end
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def validate_attempt!(binding)
|
|
222
|
-
attempt = @coordinator.service_attempt(binding)
|
|
223
|
-
unless attempt.candidate_head.nil? || attempt.candidate_head == binding["candidate_head"]
|
|
224
|
-
raise SecurityError, "request is not bound to an active managed attempt"
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def current_head
|
|
229
|
-
out, _stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: @repo_root)
|
|
230
|
-
raise SecurityError, "candidate head is unavailable" unless status.success?
|
|
231
|
-
out.strip
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def result(request)
|
|
235
|
-
public = request.slice("request_id", "assignment_id", "attempt_id", "project_id", "operation",
|
|
236
|
-
"input_digest", "target", "candidate_head", "service_id", "state", "receipt")
|
|
237
|
-
# failed-settled is internal bookkeeping: the documented public
|
|
238
|
-
# outcome stays "failed"; the state field distinguishes settlement.
|
|
239
|
-
outcome = request.fetch("state") == "failed-settled" ? "failed" : request.fetch("state")
|
|
240
|
-
{"status" => "ok", "data" => public.merge("outcome" => outcome)}
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
def failure(code, message)
|
|
244
|
-
{"status" => "error", "error" => {"code" => code, "message" => message}}
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
end
|