ace-assign 0.70.0 → 0.71.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 +20 -0
- data/handbook/skills/as-assign-drive/SKILL.md +5 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
- data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
- data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
- data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
- data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +55 -25
- data/lib/ace/assign/authority/command_execution.rb +180 -0
- data/lib/ace/assign/authority/endcap.rb +4 -2
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
- data/lib/ace/assign/authority/endcap_publication.rb +0 -166
- data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
- data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
- data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
- data/lib/ace/assign/authority/ordinary_completion.rb +13 -3
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +40 -4
- data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
- data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
- data/lib/ace/assign/authority/ordinary_results.rb +11 -1
- data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
- data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
- data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
- data/lib/ace/assign/authority/prepared_work.rb +7 -2
- data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
- data/lib/ace/assign/authority/retained_input.rb +53 -0
- data/lib/ace/assign/authority/transfer_codec.rb +5 -3
- data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
- data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
- data/lib/ace/assign/molecules/evidence_journal.rb +17 -30
- data/lib/ace/assign/molecules/journal_mutation.rb +2 -1
- data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
- data/lib/ace/assign/organisms/attempt_coordinator.rb +40 -3
- data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
- data/lib/ace/assign/version.rb +1 -1
- metadata +19 -4
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "../molecules/evidence_journal"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Authority
|
|
10
|
+
# Project commands share the installed journal, without manufacturing a
|
|
11
|
+
# managed task or projecting command exit as completed product delivery.
|
|
12
|
+
class CommandExecution
|
|
13
|
+
OPERATIONS = %w[command_admit command_status command_claim command_bind command_complete].freeze
|
|
14
|
+
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/
|
|
15
|
+
SHA = /\A[0-9a-f]{64}\z/
|
|
16
|
+
|
|
17
|
+
def initialize(project:, journal:, admission:)
|
|
18
|
+
@project, @journal, @admission = project, journal, admission
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :journal
|
|
22
|
+
|
|
23
|
+
def dispatch(request:, peer:, role:, deadline:)
|
|
24
|
+
operation = request.fetch("operation")
|
|
25
|
+
params = request.fetch("params")
|
|
26
|
+
raise ArgumentError, "invalid command project" unless request.fetch("project_id") == @project
|
|
27
|
+
id = params.fetch("execution_id")
|
|
28
|
+
raise ArgumentError, "invalid command reference" unless id.is_a?(String) && ID.match?(id)
|
|
29
|
+
if operation == "command_status"
|
|
30
|
+
strict!(params, %w[execution_id])
|
|
31
|
+
raise ArgumentError, "status mutation must be null" unless request.fetch("mutation_id").nil?
|
|
32
|
+
commit = @journal.ref_value
|
|
33
|
+
data = projection(id, commit)
|
|
34
|
+
@admission.command_reader!(peer: peer, definition: data.fetch("definition"), deadline: deadline)
|
|
35
|
+
return {data: data.merge("journal_commit" => commit), replayed: false}
|
|
36
|
+
end
|
|
37
|
+
strict!(params, case operation
|
|
38
|
+
when "command_admit" then %w[execution_id selection]
|
|
39
|
+
when "command_claim" then %w[execution_id caller]
|
|
40
|
+
when "command_bind" then %w[execution_id child]
|
|
41
|
+
when "command_complete" then %w[execution_id result]
|
|
42
|
+
else raise ArgumentError, "unknown command operation"
|
|
43
|
+
end)
|
|
44
|
+
authenticated_peer = peer
|
|
45
|
+
launcher = nil
|
|
46
|
+
plan = if operation == "command_admit"
|
|
47
|
+
@admission.command_request!(selection: params.fetch("selection"), peer: peer, deadline: deadline)
|
|
48
|
+
else
|
|
49
|
+
launcher = @admission.command_launcher!(peer: authenticated_peer, deadline: deadline)
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
authenticate = lambda do
|
|
53
|
+
if operation == "command_admit"
|
|
54
|
+
unless @admission.command_request!(selection: params.fetch("selection"), peer: peer, deadline: deadline) == plan
|
|
55
|
+
raise AttemptErrors::UnauthorizedIdentity, "command admission changed"
|
|
56
|
+
end
|
|
57
|
+
else
|
|
58
|
+
unless @admission.command_launcher!(peer: authenticated_peer, deadline: deadline) == launcher
|
|
59
|
+
raise AttemptErrors::UnauthorizedIdentity, "installed command launcher changed"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
peer = launcher if launcher
|
|
64
|
+
expected = {"command_admit" => 0, "command_claim" => 1, "command_bind" => 2, "command_complete" => 3}.fetch(operation)
|
|
65
|
+
digest = Atoms::EvidenceDigest.digest({"project" => @project, "params" => params, "peer" => peer, "plan" => plan})
|
|
66
|
+
@journal.mutate(assignment_id: namespace(id), attempt_id: namespace(id),
|
|
67
|
+
mutation_id: request.fetch("mutation_id"), operation: operation, parameters_digest: digest,
|
|
68
|
+
expected_generation: expected, with_replay: true, deadline: deadline,
|
|
69
|
+
replay_authorizer: authenticate) do |_events, commit, _generation|
|
|
70
|
+
authenticate.call
|
|
71
|
+
prior = projection(id, commit, missing: true)
|
|
72
|
+
data = transition(operation, id, params, plan, prior, peer, deadline)
|
|
73
|
+
{events: [], data: data}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Read only the accepted launcher-owned four-stage execution at the
|
|
78
|
+
# caller's held CAS prefix. Arbitrary allowed commands are not tests.
|
|
79
|
+
def test_evidence_at!(execution_id:, commit:, head:, tree:, deadline:)
|
|
80
|
+
unless execution_id.is_a?(String) && ID.match?(execution_id) && commit && @journal.ref_value == commit
|
|
81
|
+
raise AttemptErrors::EvidenceUnavailable, "test execution prefix or selector differs"
|
|
82
|
+
end
|
|
83
|
+
data = projection(execution_id, commit)
|
|
84
|
+
definition = data.fetch("definition")
|
|
85
|
+
events = @journal.read_events(namespace(execution_id), commit: commit)
|
|
86
|
+
accepted = events.select { |event| event["type"] == "authority_mutation" }
|
|
87
|
+
stages = accepted.map { |event| event.fetch("payload").fetch("data") }
|
|
88
|
+
unless accepted.size == 4 && data["phase"] == "exited" && data["result"] == {"exit_status" => 0, "signal" => nil} &&
|
|
89
|
+
definition["purpose"] == "test" && definition["source"] == {"head" => head, "tree" => tree} &&
|
|
90
|
+
[head, tree].all? { |value| value.is_a?(String) && value.match?(/\A[0-9a-f]{40}\z/) } &&
|
|
91
|
+
stages.all? { |stage| stage.fetch("definition") == definition } &&
|
|
92
|
+
stages.map { |stage| stage.fetch("phase") } == %w[admitted claimed running exited] &&
|
|
93
|
+
stages.drop(1).all? { |stage| stage.fetch("launcher") == data.fetch("launcher") } &&
|
|
94
|
+
stages[2].fetch("child") == data.fetch("child") && data.dig("launcher", "uid") == 0 &&
|
|
95
|
+
data.dig("child", "witnessed_by") == data.fetch("launcher") &&
|
|
96
|
+
data.dig("child", "kernel_identity") == data.dig("child", "root_witness")&.slice("pid", "uid", "started_at", "host") &&
|
|
97
|
+
data.dig("child", "root_witness", "cwd") == definition.dig("executor", "cwd") &&
|
|
98
|
+
data.dig("child", "kernel_identity", "uid") == definition.dig("executor", "uid")
|
|
99
|
+
raise AttemptErrors::ReceiptRejected, "candidate requires an exact successful executed test command"
|
|
100
|
+
end
|
|
101
|
+
@admission.command_test_evidence!(definition: definition, deadline: deadline)
|
|
102
|
+
command = JSON.generate(definition.fetch("argv"))
|
|
103
|
+
raise AttemptErrors::ReceiptRejected, "executed test command reference is oversized" if command.bytesize > 2048
|
|
104
|
+
{"schema" => "ace.test.execution-reference/v1", "reference" => execution_id,
|
|
105
|
+
"candidate_head" => head, "command" => command, "result" => "passed",
|
|
106
|
+
"recorded_at" => accepted.last.fetch("recorded_at"), "receipt_sha256" => Atoms::EvidenceDigest.digest(data)}
|
|
107
|
+
rescue KeyError, TypeError
|
|
108
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical test execution is incomplete"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def transition(operation, id, params, plan, prior, peer, deadline)
|
|
114
|
+
base = {"schema" => "project-command/v1", "execution_id" => id, "project_id" => @project}
|
|
115
|
+
case operation
|
|
116
|
+
when "command_admit"
|
|
117
|
+
raise AttemptErrors::Conflict, "command already admitted" if prior
|
|
118
|
+
# Plan contains references and nonsecret context; its closed schema
|
|
119
|
+
# is owned by the installed Lab admission, never requester input.
|
|
120
|
+
base.merge("phase" => "admitted", "definition" => plan)
|
|
121
|
+
when "command_claim"
|
|
122
|
+
phase!(prior, "admitted")
|
|
123
|
+
@admission.command_claim!(definition: prior.fetch("definition"), caller: params.fetch("caller"),
|
|
124
|
+
launcher: peer, deadline: deadline)
|
|
125
|
+
prior.merge("phase" => "claimed", "launcher" => peer)
|
|
126
|
+
when "command_bind"
|
|
127
|
+
phase!(prior, "claimed")
|
|
128
|
+
launcher!(prior, peer)
|
|
129
|
+
child = @admission.command_child!(definition: prior.fetch("definition"), child: params.fetch("child"),
|
|
130
|
+
launcher: peer, deadline: deadline)
|
|
131
|
+
prior.merge("phase" => "running", "child" => child)
|
|
132
|
+
when "command_complete"
|
|
133
|
+
phase!(prior, "running")
|
|
134
|
+
launcher!(prior, peer)
|
|
135
|
+
result = params.fetch("result")
|
|
136
|
+
strict!(result, %w[exit_status signal])
|
|
137
|
+
exit_status, signal = result.values_at("exit_status", "signal")
|
|
138
|
+
unless (exit_status.is_a?(Integer) && exit_status.between?(0, 255) && signal.nil?) ||
|
|
139
|
+
(exit_status.nil? && signal.is_a?(Integer) && signal.between?(1, 127))
|
|
140
|
+
raise ArgumentError, "invalid command termination"
|
|
141
|
+
end
|
|
142
|
+
prior.merge("phase" => "exited", "result" => result)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def projection(id, commit, missing: false)
|
|
147
|
+
events = @journal.read_events(namespace(id), commit: commit)
|
|
148
|
+
unless Models::EvidenceEvent.chain_valid?(events)
|
|
149
|
+
raise AttemptErrors::EvidenceUnavailable, "command history is corrupt"
|
|
150
|
+
end
|
|
151
|
+
accepted = events.select { |event| event["type"] == "authority_mutation" }
|
|
152
|
+
if accepted.empty?
|
|
153
|
+
return nil if missing
|
|
154
|
+
raise AttemptErrors::NotFound, "command reference is unknown"
|
|
155
|
+
end
|
|
156
|
+
operations = accepted.map { |event| event.dig("payload", "operation") }
|
|
157
|
+
unless operations == OPERATIONS.reject { |name| name == "command_status" }.take(operations.size) && operations.size <= 4
|
|
158
|
+
raise AttemptErrors::EvidenceUnavailable, "command lifecycle is corrupt"
|
|
159
|
+
end
|
|
160
|
+
data = accepted.last.fetch("payload").fetch("data")
|
|
161
|
+
unless data.values_at("schema", "project_id", "execution_id") == ["project-command/v1", @project, id]
|
|
162
|
+
raise AttemptErrors::EvidenceUnavailable, "command association differs"
|
|
163
|
+
end
|
|
164
|
+
data
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def namespace(id) = "command-#{id}"
|
|
168
|
+
def strict!(value, keys)
|
|
169
|
+
raise ArgumentError, "invalid command fields" unless value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
170
|
+
end
|
|
171
|
+
def phase!(prior, phase)
|
|
172
|
+
raise AttemptErrors::Conflict, "command phase differs; inspect original execution" unless prior && prior["phase"] == phase
|
|
173
|
+
end
|
|
174
|
+
def launcher!(prior, peer)
|
|
175
|
+
raise AttemptErrors::UnauthorizedIdentity, "command launcher changed" unless prior.fetch("launcher") == peer
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -294,7 +294,7 @@ module Ace
|
|
|
294
294
|
data.fetch("head") == params.fetch("head")
|
|
295
295
|
raise AttemptErrors::EvidenceUnavailable, "candidate replay provenance differs"
|
|
296
296
|
end
|
|
297
|
-
bytes = journal.bounded_blob(data.fetch("bundle_ref"), commit: replay.fetch("journal_commit"), max_bytes:
|
|
297
|
+
bytes = journal.bounded_blob(data.fetch("bundle_ref"), commit: replay.fetch("journal_commit"), max_bytes: CandidateTransfer::MAX_BYTES)
|
|
298
298
|
unless bytes.bytesize == data.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == data.fetch("sha256")
|
|
299
299
|
raise AttemptErrors::EvidenceUnavailable, "candidate replay bundle differs"
|
|
300
300
|
end
|
|
@@ -434,7 +434,9 @@ module Ace
|
|
|
434
434
|
end
|
|
435
435
|
end
|
|
436
436
|
|
|
437
|
-
require_relative "
|
|
437
|
+
require_relative "fixed_service_lifecycle"
|
|
438
|
+
require_relative "fixed_publication_lifecycle"
|
|
439
|
+
require_relative "endcap_service_context"
|
|
438
440
|
require_relative "endcap_service_settlement"
|
|
439
441
|
|
|
440
442
|
require_relative "endcap_campaign_results"
|
|
@@ -37,7 +37,7 @@ module Ace
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def with_campaign_receipt!(journal:, commit:, events:, params:, map:, receipt:, result:, historical:)
|
|
40
|
-
unless result.is_a?(Hash) && result["schema"] == "ace.review.accepted-result/
|
|
40
|
+
unless result.is_a?(Hash) && result["schema"] == "ace.review.accepted-result/v2" &&
|
|
41
41
|
receipt["campaign"].is_a?(Hash) && receipt["campaign"].keys.sort == %w[id result] &&
|
|
42
42
|
receipt["operation"] == "review" && receipt["verdict"] == "succeeded"
|
|
43
43
|
raise AttemptErrors::ReceiptRejected, "parent campaign receipt differs"
|
|
@@ -35,172 +35,6 @@ module Ace
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def publication_executor_identity?(project:, uid:)
|
|
39
|
-
@deployment.project(project).fetch("service_executor_uids").include?(uid)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def with_publication_hitl!(binding:, project:, peer:)
|
|
43
|
-
params = binding.slice("mapping_id", "assignment_id", "attempt_id", "request_id", "claim_binding", "input_digest", "candidate_generation", "head")
|
|
44
|
-
map = @deployment.mapping(params.fetch("mapping_id"))
|
|
45
|
-
raise AttemptErrors::UnauthorizedIdentity, "publication project differs" unless map.fetch("project_id") == project
|
|
46
|
-
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
47
|
-
protected_journal!(journal)
|
|
48
|
-
record = publication_record!(journal, params, map, peer, :executor)
|
|
49
|
-
unless record["dispatch_phase"] == "otp_pending" && record["publication_challenge_digest"] == binding.fetch("challenge_digest")
|
|
50
|
-
raise AttemptErrors::Conflict, "publication HITL challenge is no longer pending"
|
|
51
|
-
end
|
|
52
|
-
ServiceEvidence.new(journal: journal).publication_challenge!(record)
|
|
53
|
-
selection = settlement_immutable({"executor_process_binding" => record.fetch("executor_process_binding"),
|
|
54
|
-
"challenge_ref" => record.fetch("publication_challenge_ref").fetch("ref")})
|
|
55
|
-
yield selection
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def authorize_publication_update!(journal:, existing:, replacement:, pending:)
|
|
60
|
-
operation = pending.fetch(:operation)
|
|
61
|
-
allowed = ServicePublicationEvidence::PUBLICATION_FIELDS + ["dispatch_phase"]
|
|
62
|
-
unless existing.except(*allowed) == replacement.except(*allowed) && existing["state"] == "uncertain"
|
|
63
|
-
raise AttemptErrors::InvalidState, "publication transition changed original binding"
|
|
64
|
-
end
|
|
65
|
-
owner = ServiceEvidence.new(journal: journal)
|
|
66
|
-
if operation == "publication_challenge"
|
|
67
|
-
unless %w[dispatch_started issuing].include?(existing["dispatch_phase"]) && replacement["dispatch_phase"] == "otp_pending" &&
|
|
68
|
-
replacement["publication_issuing_event_digest"] == existing["publication_issuing_event_digest"]
|
|
69
|
-
raise AttemptErrors::InvalidState, "publication need has no source-owned rejected push"
|
|
70
|
-
end
|
|
71
|
-
need = owner.publication_challenge!(replacement, pending: pending)
|
|
72
|
-
unless need["previous_challenge_digest"] == existing["publication_challenge_digest"]
|
|
73
|
-
raise AttemptErrors::InvalidState, "publication need predecessor differs"
|
|
74
|
-
end
|
|
75
|
-
elsif operation == "publication_continue"
|
|
76
|
-
unless existing["dispatch_phase"] == "otp_pending" && replacement["dispatch_phase"] == "issuing" &&
|
|
77
|
-
(allowed - %w[dispatch_phase publication_issuing_event_digest]).all? { |key| existing[key] == replacement[key] }
|
|
78
|
-
raise AttemptErrors::InvalidState, "publication continuation is not a unique pending challenge"
|
|
79
|
-
end
|
|
80
|
-
owner.publication_challenge!(replacement, pending: pending)
|
|
81
|
-
events = pending.fetch(:current_events) + pending.fetch(:pending_events)
|
|
82
|
-
event = events.find { |row| row["digest"] == replacement["publication_issuing_event_digest"] }
|
|
83
|
-
unless event && event["type"] == "service_publication_issuing" &&
|
|
84
|
-
%w[request_id input_digest claim_binding publication_challenge_digest].all? { |key| event.dig("payload", key) == replacement[key] }
|
|
85
|
-
raise AttemptErrors::EvidenceUnavailable, "publication issuing event is unavailable"
|
|
86
|
-
end
|
|
87
|
-
else
|
|
88
|
-
raise AttemptErrors::InvalidState, "publication transition has no fixed source owner"
|
|
89
|
-
end
|
|
90
|
-
true
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def publication_record!(journal, params, map, peer, role, events: nil)
|
|
94
|
-
record = journal.service_request(params.fetch("request_id"))
|
|
95
|
-
unless record && record["operation"] == "publish" && record["state"] == "uncertain" &&
|
|
96
|
-
%w[dispatch_started otp_pending issuing].include?(record["dispatch_phase"]) &&
|
|
97
|
-
%w[claim_binding input_digest candidate_generation].all? { |key| record[key] == params[key] } &&
|
|
98
|
-
record["candidate_head"] == params["head"] && record["mapping_id"] == params["mapping_id"]
|
|
99
|
-
raise AttemptErrors::Conflict, "publication original tuple or phase differs"
|
|
100
|
-
end
|
|
101
|
-
service_executor!(peer, role, record)
|
|
102
|
-
service_ticket!(record, params, map, peer)
|
|
103
|
-
events ||= attempt_events(journal, params)
|
|
104
|
-
current = exact_candidate!(candidate(events), params)
|
|
105
|
-
origin = active_origin(events, params)
|
|
106
|
-
worker_or_launcher!(record.fetch("worker_process_binding"), :worker, map, origin)
|
|
107
|
-
approved_review!(journal, events, params, map, current)
|
|
108
|
-
@launch.scope_open_for_effect!(events: events, params: params, map: map)
|
|
109
|
-
service_policy!.authorize!(record)
|
|
110
|
-
ServiceEvidence.new(journal: journal).context(record)
|
|
111
|
-
record
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def authorize_publication_transfer!(request, params, map, peer, role)
|
|
115
|
-
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
116
|
-
protected_journal!(journal)
|
|
117
|
-
publication_record!(journal, params, map, peer, role)
|
|
118
|
-
end
|
|
119
|
-
true
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def dispatch_publication(request, params, map, peer, role, transfer)
|
|
123
|
-
authorize_publication_transfer!(request, params, map, peer, role)
|
|
124
|
-
challenge = request.fetch("operation") == "publication_challenge"
|
|
125
|
-
bytes = nil
|
|
126
|
-
if challenge
|
|
127
|
-
unless transfer && transfer.count == 1
|
|
128
|
-
raise ArgumentError, "publication need requires one bounded artifact"
|
|
129
|
-
end
|
|
130
|
-
bytes = transfer.bytes.dup.freeze
|
|
131
|
-
unless Digest::SHA256.hexdigest(bytes) == params.fetch("receipt_sha256")
|
|
132
|
-
raise AttemptErrors::ReceiptRejected, "publication need transfer digest differs"
|
|
133
|
-
end
|
|
134
|
-
elsif transfer
|
|
135
|
-
raise ArgumentError, "publication continuation forbids upload"
|
|
136
|
-
end
|
|
137
|
-
@launch.with_assignment(params: params, map: map) do |journal, _registration|
|
|
138
|
-
publication_record!(journal, params, map, peer, role)
|
|
139
|
-
result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
140
|
-
mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
|
|
141
|
-
parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
|
|
142
|
-
with_replay: true) do |events, _commit, generation|
|
|
143
|
-
record = publication_record!(journal, params, map, peer, role, events: events)
|
|
144
|
-
if challenge
|
|
145
|
-
publication_challenge_plan(journal, events, record, params, bytes, generation)
|
|
146
|
-
else
|
|
147
|
-
publication_continue_plan(journal, events, record, params, generation)
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
if !challenge && result.fetch(:replayed)
|
|
151
|
-
result = result.merge(data: result.fetch(:data).merge("invocation" => "already_issued"))
|
|
152
|
-
end
|
|
153
|
-
result
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def publication_challenge_plan(journal, events, record, params, bytes, generation)
|
|
158
|
-
owner = ServiceEvidence.new(journal: journal)
|
|
159
|
-
digest = Digest::SHA256.hexdigest(bytes)
|
|
160
|
-
if record["dispatch_phase"] == "otp_pending"
|
|
161
|
-
owner.publication_challenge!(record)
|
|
162
|
-
raise AttemptErrors::Conflict, "publication pending challenge differs" unless record["publication_challenge_digest"] == digest
|
|
163
|
-
return {data: service_projection(record).merge("challenge" => "retained")}
|
|
164
|
-
end
|
|
165
|
-
unless %w[dispatch_started issuing].include?(record["dispatch_phase"])
|
|
166
|
-
raise AttemptErrors::InvalidState, "publication has no rejected push"
|
|
167
|
-
end
|
|
168
|
-
previous = record["publication_challenge_digest"]
|
|
169
|
-
owner.publication_challenge!(record) if previous
|
|
170
|
-
owner.publication_need!(bytes, record, previous: previous)
|
|
171
|
-
payload = record.slice("request_id", "input_digest", "claim_binding").merge(
|
|
172
|
-
"challenge_digest" => digest, "previous_challenge_digest" => previous, "generation" => generation + 1)
|
|
173
|
-
time = Time.now.utc
|
|
174
|
-
event = Models::EvidenceEvent.build(type: "service_publication_challenge", attempt_id: record.fetch("attempt_id"),
|
|
175
|
-
payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
|
|
176
|
-
plan = Molecules::CanonicalEvidence.new(journal: journal).import_plan(**owner.context(record),
|
|
177
|
-
artifacts: [bytes], admitted_after_event_digest: event.fetch("digest"))
|
|
178
|
-
replacement = record.merge("dispatch_phase" => "otp_pending", "publication_challenge_digest" => digest,
|
|
179
|
-
"publication_challenge_ref" => plan.fetch(:references).fetch(0),
|
|
180
|
-
"publication_challenge_event_digest" => event.fetch("digest"), "publication_challenge_generation" => generation + 1)
|
|
181
|
-
plan.merge(events: [{type: "service_publication_challenge", payload: payload, recorded_at: time}] + plan.fetch(:events),
|
|
182
|
-
data: service_projection(replacement).merge("challenge" => "created"), service_updates: [{request_id: record.fetch("request_id"),
|
|
183
|
-
expected: record, replacement: replacement, event_type: "service_transition"}])
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def publication_continue_plan(journal, events, record, params, generation)
|
|
187
|
-
ServiceEvidence.new(journal: journal).publication_challenge!(record)
|
|
188
|
-
unless record["publication_challenge_digest"] == params.fetch("challenge_digest")
|
|
189
|
-
raise AttemptErrors::Conflict, "publication continuation challenge differs"
|
|
190
|
-
end
|
|
191
|
-
if record["dispatch_phase"] == "issuing"
|
|
192
|
-
return {data: service_projection(record).merge("invocation" => "already_issued")}
|
|
193
|
-
end
|
|
194
|
-
raise AttemptErrors::InvalidState, "publication is not pending" unless record["dispatch_phase"] == "otp_pending"
|
|
195
|
-
payload = record.slice("request_id", "input_digest", "claim_binding", "publication_challenge_digest").merge("generation" => generation + 1)
|
|
196
|
-
time = Time.now.utc
|
|
197
|
-
event = Models::EvidenceEvent.build(type: "service_publication_issuing", attempt_id: record.fetch("attempt_id"),
|
|
198
|
-
payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
|
|
199
|
-
replacement = record.merge("dispatch_phase" => "issuing", "publication_issuing_event_digest" => event.fetch("digest"))
|
|
200
|
-
{events: [{type: "service_publication_issuing", payload: payload, recorded_at: time}],
|
|
201
|
-
data: service_projection(replacement).merge("invocation" => "permitted"), service_updates: [{request_id: record.fetch("request_id"),
|
|
202
|
-
expected: record, replacement: replacement, event_type: "service_transition"}]}
|
|
203
|
-
end
|
|
204
38
|
end
|
|
205
39
|
end
|
|
206
40
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
# Protected admission remains here. The shared fixed service transitions
|
|
7
|
+
# never manufacture a Deployment mapping or a protected launch origin.
|
|
8
|
+
class Endcap
|
|
9
|
+
include FixedServiceLifecycle
|
|
10
|
+
include FixedPublicationLifecycle
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def with_service_scope(**options, &block) = @launch.with_assignment(**options, &block)
|
|
15
|
+
def service_journal!(journal) = protected_journal!(journal)
|
|
16
|
+
def service_mapping!(id) = @deployment.mapping(id)
|
|
17
|
+
def service_project!(id) = @deployment.project(id)
|
|
18
|
+
def service_scope_open!(**options) = @launch.scope_open_for_effect!(**options)
|
|
19
|
+
def service_preview_events!(**options) = @launch.preview_attempt_events!(**options)
|
|
20
|
+
def service_origin!(events, params) = active_origin(events, params)
|
|
21
|
+
|
|
22
|
+
def service_origin_fields!(origin)
|
|
23
|
+
{"worker_process_binding" => origin.fetch("process_binding").fetch("process_identity"),
|
|
24
|
+
"launch_ticket" => origin.fetch("launch_ticket"),
|
|
25
|
+
"reservation_generation" => origin.fetch("reservation_generation")}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def service_origin_matches!(record, origin)
|
|
29
|
+
expected = service_origin_fields!(origin)
|
|
30
|
+
record.slice(*expected.keys) == expected
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def service_worker!(*args, **options) = worker_or_launcher!(*args, **options)
|
|
34
|
+
def service_review!(*args, **options) = approved_review!(*args, **options)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
module FixedPublicationLifecycle
|
|
7
|
+
def publication_executor_identity?(project:, uid:)
|
|
8
|
+
service_project!(project).fetch("service_executor_uids").include?(uid)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def with_publication_hitl!(binding:, project:, peer:)
|
|
12
|
+
params = binding.slice("mapping_id", "assignment_id", "attempt_id", "request_id", "claim_binding", "input_digest", "candidate_generation", "head")
|
|
13
|
+
map = service_mapping!(params.fetch("mapping_id"))
|
|
14
|
+
raise AttemptErrors::UnauthorizedIdentity, "publication project differs" unless map.fetch("project_id") == project
|
|
15
|
+
with_service_scope(params: params, map: map) do |journal, _registration|
|
|
16
|
+
service_journal!(journal)
|
|
17
|
+
record = publication_record!(journal, params, map, peer, :executor)
|
|
18
|
+
unless record["dispatch_phase"] == "otp_pending" && record["publication_challenge_digest"] == binding.fetch("challenge_digest")
|
|
19
|
+
raise AttemptErrors::Conflict, "publication HITL challenge is no longer pending"
|
|
20
|
+
end
|
|
21
|
+
ServiceEvidence.new(journal: journal).publication_challenge!(record)
|
|
22
|
+
selection = settlement_immutable({"executor_process_binding" => record.fetch("executor_process_binding"),
|
|
23
|
+
"challenge_ref" => record.fetch("publication_challenge_ref").fetch("ref")})
|
|
24
|
+
yield selection
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authorize_publication_update!(journal:, existing:, replacement:, pending:)
|
|
29
|
+
operation = pending.fetch(:operation)
|
|
30
|
+
allowed = ServicePublicationEvidence::PUBLICATION_FIELDS + ["dispatch_phase"]
|
|
31
|
+
unless existing.except(*allowed) == replacement.except(*allowed) && existing["state"] == "uncertain"
|
|
32
|
+
raise AttemptErrors::InvalidState, "publication transition changed original binding"
|
|
33
|
+
end
|
|
34
|
+
owner = ServiceEvidence.new(journal: journal)
|
|
35
|
+
if operation == "publication_challenge"
|
|
36
|
+
unless %w[dispatch_started issuing].include?(existing["dispatch_phase"]) && replacement["dispatch_phase"] == "otp_pending" &&
|
|
37
|
+
replacement["publication_issuing_event_digest"] == existing["publication_issuing_event_digest"]
|
|
38
|
+
raise AttemptErrors::InvalidState, "publication need has no source-owned rejected push"
|
|
39
|
+
end
|
|
40
|
+
need = owner.publication_challenge!(replacement, pending: pending)
|
|
41
|
+
unless need["previous_challenge_digest"] == existing["publication_challenge_digest"]
|
|
42
|
+
raise AttemptErrors::InvalidState, "publication need predecessor differs"
|
|
43
|
+
end
|
|
44
|
+
elsif operation == "publication_continue"
|
|
45
|
+
unless existing["dispatch_phase"] == "otp_pending" && replacement["dispatch_phase"] == "issuing" &&
|
|
46
|
+
(allowed - %w[dispatch_phase publication_issuing_event_digest]).all? { |key| existing[key] == replacement[key] }
|
|
47
|
+
raise AttemptErrors::InvalidState, "publication continuation is not a unique pending challenge"
|
|
48
|
+
end
|
|
49
|
+
owner.publication_challenge!(replacement, pending: pending)
|
|
50
|
+
events = pending.fetch(:current_events) + pending.fetch(:pending_events)
|
|
51
|
+
event = events.find { |row| row["digest"] == replacement["publication_issuing_event_digest"] }
|
|
52
|
+
unless event && event["type"] == "service_publication_issuing" &&
|
|
53
|
+
%w[request_id input_digest claim_binding publication_challenge_digest].all? { |key| event.dig("payload", key) == replacement[key] }
|
|
54
|
+
raise AttemptErrors::EvidenceUnavailable, "publication issuing event is unavailable"
|
|
55
|
+
end
|
|
56
|
+
else
|
|
57
|
+
raise AttemptErrors::InvalidState, "publication transition has no fixed source owner"
|
|
58
|
+
end
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def publication_record!(journal, params, map, peer, role, events: nil)
|
|
63
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
64
|
+
unless record && record["operation"] == "publish" && record["state"] == "uncertain" &&
|
|
65
|
+
%w[dispatch_started otp_pending issuing].include?(record["dispatch_phase"]) &&
|
|
66
|
+
%w[claim_binding input_digest candidate_generation].all? { |key| record[key] == params[key] } &&
|
|
67
|
+
record["candidate_head"] == params["head"] && record["mapping_id"] == params["mapping_id"]
|
|
68
|
+
raise AttemptErrors::Conflict, "publication original tuple or phase differs"
|
|
69
|
+
end
|
|
70
|
+
service_executor!(peer, role, record)
|
|
71
|
+
service_ticket!(record, params, map, peer)
|
|
72
|
+
events ||= attempt_events(journal, params)
|
|
73
|
+
current = exact_candidate!(candidate(events), params)
|
|
74
|
+
origin = service_origin!(events, params)
|
|
75
|
+
service_worker!(record.fetch("worker_process_binding"), :worker, map, origin)
|
|
76
|
+
service_review!(journal, events, params, map, current)
|
|
77
|
+
service_scope_open!(events: events, params: params, map: map)
|
|
78
|
+
service_policy!.authorize!(record)
|
|
79
|
+
ServiceEvidence.new(journal: journal).context(record)
|
|
80
|
+
record
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def authorize_publication_transfer!(request, params, map, peer, role)
|
|
84
|
+
with_service_scope(params: params, map: map) do |journal, _registration|
|
|
85
|
+
service_journal!(journal)
|
|
86
|
+
publication_record!(journal, params, map, peer, role)
|
|
87
|
+
end
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def dispatch_publication(request, params, map, peer, role, transfer)
|
|
92
|
+
authorize_publication_transfer!(request, params, map, peer, role)
|
|
93
|
+
challenge = request.fetch("operation") == "publication_challenge"
|
|
94
|
+
bytes = nil
|
|
95
|
+
if challenge
|
|
96
|
+
unless transfer && transfer.count == 1
|
|
97
|
+
raise ArgumentError, "publication need requires one bounded artifact"
|
|
98
|
+
end
|
|
99
|
+
bytes = transfer.bytes.dup.freeze
|
|
100
|
+
unless Digest::SHA256.hexdigest(bytes) == params.fetch("receipt_sha256")
|
|
101
|
+
raise AttemptErrors::ReceiptRejected, "publication need transfer digest differs"
|
|
102
|
+
end
|
|
103
|
+
elsif transfer
|
|
104
|
+
raise ArgumentError, "publication continuation forbids upload"
|
|
105
|
+
end
|
|
106
|
+
with_service_scope(params: params, map: map) do |journal, _registration|
|
|
107
|
+
publication_record!(journal, params, map, peer, role)
|
|
108
|
+
result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
109
|
+
mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
|
|
110
|
+
parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
|
|
111
|
+
with_replay: true) do |events, _commit, generation|
|
|
112
|
+
record = publication_record!(journal, params, map, peer, role, events: events)
|
|
113
|
+
if challenge
|
|
114
|
+
publication_challenge_plan(journal, events, record, params, bytes, generation)
|
|
115
|
+
else
|
|
116
|
+
publication_continue_plan(journal, events, record, params, generation)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
if !challenge && result.fetch(:replayed)
|
|
120
|
+
result = result.merge(data: result.fetch(:data).merge("invocation" => "already_issued"))
|
|
121
|
+
end
|
|
122
|
+
result
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def publication_challenge_plan(journal, events, record, params, bytes, generation)
|
|
127
|
+
owner = ServiceEvidence.new(journal: journal)
|
|
128
|
+
digest = Digest::SHA256.hexdigest(bytes)
|
|
129
|
+
if record["dispatch_phase"] == "otp_pending"
|
|
130
|
+
owner.publication_challenge!(record)
|
|
131
|
+
raise AttemptErrors::Conflict, "publication pending challenge differs" unless record["publication_challenge_digest"] == digest
|
|
132
|
+
return {data: service_projection(record).merge("challenge" => "retained")}
|
|
133
|
+
end
|
|
134
|
+
unless %w[dispatch_started issuing].include?(record["dispatch_phase"])
|
|
135
|
+
raise AttemptErrors::InvalidState, "publication has no rejected push"
|
|
136
|
+
end
|
|
137
|
+
previous = record["publication_challenge_digest"]
|
|
138
|
+
owner.publication_challenge!(record) if previous
|
|
139
|
+
owner.publication_need!(bytes, record, previous: previous)
|
|
140
|
+
payload = record.slice("request_id", "input_digest", "claim_binding").merge(
|
|
141
|
+
"challenge_digest" => digest, "previous_challenge_digest" => previous, "generation" => generation + 1)
|
|
142
|
+
time = Time.now.utc
|
|
143
|
+
event = Models::EvidenceEvent.build(type: "service_publication_challenge", attempt_id: record.fetch("attempt_id"),
|
|
144
|
+
payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
|
|
145
|
+
plan = Molecules::CanonicalEvidence.new(journal: journal).import_plan(**owner.context(record),
|
|
146
|
+
artifacts: [bytes], admitted_after_event_digest: event.fetch("digest"))
|
|
147
|
+
replacement = record.merge("dispatch_phase" => "otp_pending", "publication_challenge_digest" => digest,
|
|
148
|
+
"publication_challenge_ref" => plan.fetch(:references).fetch(0),
|
|
149
|
+
"publication_challenge_event_digest" => event.fetch("digest"), "publication_challenge_generation" => generation + 1)
|
|
150
|
+
plan.merge(events: [{type: "service_publication_challenge", payload: payload, recorded_at: time}] + plan.fetch(:events),
|
|
151
|
+
data: service_projection(replacement).merge("challenge" => "created"), service_updates: [{request_id: record.fetch("request_id"),
|
|
152
|
+
expected: record, replacement: replacement, event_type: "service_transition"}])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def publication_continue_plan(journal, events, record, params, generation)
|
|
156
|
+
ServiceEvidence.new(journal: journal).publication_challenge!(record)
|
|
157
|
+
unless record["publication_challenge_digest"] == params.fetch("challenge_digest")
|
|
158
|
+
raise AttemptErrors::Conflict, "publication continuation challenge differs"
|
|
159
|
+
end
|
|
160
|
+
if record["dispatch_phase"] == "issuing"
|
|
161
|
+
return {data: service_projection(record).merge("invocation" => "already_issued")}
|
|
162
|
+
end
|
|
163
|
+
raise AttemptErrors::InvalidState, "publication is not pending" unless record["dispatch_phase"] == "otp_pending"
|
|
164
|
+
payload = record.slice("request_id", "input_digest", "claim_binding", "publication_challenge_digest").merge("generation" => generation + 1)
|
|
165
|
+
time = Time.now.utc
|
|
166
|
+
event = Models::EvidenceEvent.build(type: "service_publication_issuing", attempt_id: record.fetch("attempt_id"),
|
|
167
|
+
payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
|
|
168
|
+
replacement = record.merge("dispatch_phase" => "issuing", "publication_issuing_event_digest" => event.fetch("digest"))
|
|
169
|
+
{events: [{type: "service_publication_issuing", payload: payload, recorded_at: time}],
|
|
170
|
+
data: service_projection(replacement).merge("invocation" => "permitted"), service_updates: [{request_id: record.fetch("request_id"),
|
|
171
|
+
expected: record, replacement: replacement, event_type: "service_transition"}]}
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|