ace-assign 0.69.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/.ace-defaults/assign/presets/deliver-release.yml +11 -8
- data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
- data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
- data/CHANGELOG.md +27 -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/assignment_scope.rb +5 -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/assignment_definition.rb +35 -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 +57 -27
- 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_attempts.rb +1 -1
- 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/launch_control_exclusions.rb +3 -17
- data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
- data/lib/ace/assign/authority/ordinary_client.rb +70 -0
- data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
- data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
- data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
- data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
- data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
- data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
- data/lib/ace/assign/authority/ordinary_results.rb +206 -0
- 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_server.rb +75 -0
- data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
- data/lib/ace/assign/authority/prepared_work.rb +9 -4
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- 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/router.rb +11 -6
- data/lib/ace/assign/authority/server.rb +11 -180
- data/lib/ace/assign/authority/socket_server.rb +255 -0
- data/lib/ace/assign/authority/transfer_codec.rb +5 -3
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- 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/canonical_read_commands.rb +84 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
- data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- 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 +88 -36
- data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +39 -10
|
@@ -6,7 +6,11 @@ require "fileutils"
|
|
|
6
6
|
require "tmpdir"
|
|
7
7
|
require "tempfile"
|
|
8
8
|
require "ace/herdr/molecules/bounded_process"
|
|
9
|
+
require "ace/review/atoms/campaign_contract"
|
|
9
10
|
require_relative "private_directory"
|
|
11
|
+
require_relative "../atoms/candidate_limits"
|
|
12
|
+
require_relative "candidate_head_comparison"
|
|
13
|
+
require_relative "candidate_blob_batch"
|
|
10
14
|
|
|
11
15
|
module Ace
|
|
12
16
|
module Assign
|
|
@@ -14,8 +18,17 @@ module Ace
|
|
|
14
18
|
# Untrusted bundles enter a clean bare quarantine. Only complete verified
|
|
15
19
|
# objects are retained; no uploader configuration or worktree is read.
|
|
16
20
|
class CandidateTransfer
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
include CandidateHeadComparison
|
|
22
|
+
include CandidateBlobBatch
|
|
23
|
+
MAX_BYTES = Atoms::CandidateLimits::BYTES
|
|
24
|
+
MAX_REVIEW_BYTES = Ace::Review::Atoms::CampaignContract::MAX_HEAD_CONTEXT_BYTES
|
|
25
|
+
# Measured on the real 82,809,675-byte candidate (9,203 entries,
|
|
26
|
+
# 71,583,200 expanded bytes): two full admits alone need ~14s of
|
|
27
|
+
# bounded Git work and the complete comparison ~23s on a loaded host
|
|
28
|
+
# (evidence in .ace-tasks/8xf.t.ei9-wave-3 evidence). 30s failed
|
|
29
|
+
# closed under legitimate load; 60s keeps a finite secondary guard
|
|
30
|
+
# with measured headroom. Inputs stay byte/count-bounded separately.
|
|
31
|
+
DEADLINE = 60
|
|
19
32
|
SHA = /\A[0-9a-f]{40}\z/
|
|
20
33
|
|
|
21
34
|
class CampaignReadView
|
|
@@ -115,14 +128,14 @@ module Ace
|
|
|
115
128
|
reject!("Prepared export filesystem is unavailable")
|
|
116
129
|
end
|
|
117
130
|
|
|
118
|
-
def admit(bytes:, sha256:, size:, head:, &consumer)
|
|
131
|
+
def admit(bytes:, sha256:, size:, head:, deadline: nil, &consumer)
|
|
119
132
|
unless bytes.is_a?(String) && size.is_a?(Integer) && size.positive? && size <= MAX_BYTES &&
|
|
120
133
|
size == bytes.bytesize && sha256.is_a?(String) &&
|
|
121
134
|
Digest::SHA256.hexdigest(bytes.b) == sha256 && head.is_a?(String) && head.match?(SHA)
|
|
122
135
|
reject!("Candidate bundle binding or size is invalid")
|
|
123
136
|
end
|
|
124
137
|
verify_root!
|
|
125
|
-
deadline = monotonic + DEADLINE
|
|
138
|
+
deadline = deadline ? [deadline, monotonic + DEADLINE].min : monotonic + DEADLINE
|
|
126
139
|
Dir.mktmpdir("quarantine-", @root) do |directory|
|
|
127
140
|
File.chmod(0700, directory)
|
|
128
141
|
bundle = File.join(directory, "candidate.bundle")
|
|
@@ -165,20 +178,29 @@ module Ace
|
|
|
165
178
|
File.chmod(0700, destination)
|
|
166
179
|
begin
|
|
167
180
|
entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
entries.each do |entry|
|
|
181
|
+
reject!("Candidate file inventory is oversized") if entries.size > MAX_OBJECTS
|
|
182
|
+
parsed = entries.map do |entry|
|
|
171
183
|
metadata, path = entry.split("\t", 2)
|
|
172
184
|
mode, kind, oid = metadata.to_s.split(" ")
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
185
|
+
# Total path bytes are bounded before any component or
|
|
186
|
+
# filesystem prefix work consumes them.
|
|
187
|
+
reject!("Candidate path exceeds bounded length") unless path && !path.start_with?("/") &&
|
|
188
|
+
path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
|
|
189
|
+
parts = path.split("/", -1)
|
|
190
|
+
unless parts.none? { |part| part.empty? || part.bytesize > Atoms::CandidateLimits::PATH_COMPONENT_BYTES ||
|
|
191
|
+
%w[. .. .git].include?(part.downcase) } &&
|
|
192
|
+
kind == "blob" && %w[100644 100755 120000].include?(mode) && oid&.match?(SHA)
|
|
177
193
|
reject!("Candidate contains unsafe filesystem entry")
|
|
178
194
|
end
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
195
|
+
[path, mode, oid]
|
|
196
|
+
end
|
|
197
|
+
by_oid = parsed.group_by(&:last)
|
|
198
|
+
sizes = by_oid.keys.zip(candidate_blob_sizes(repository, deadline, by_oid.keys)).to_h
|
|
199
|
+
reject!("Candidate expanded content is oversized") if parsed.sum { |_path, _mode, oid| sizes.fetch(oid) } > MAX_BYTES
|
|
200
|
+
links = []
|
|
201
|
+
ordered = parsed.each
|
|
202
|
+
each_candidate_blob(repository, deadline, parsed.map { |_path, _mode, oid| [oid, sizes.fetch(oid)] }) do |oid, content|
|
|
203
|
+
path, mode, _oid = ordered.next
|
|
182
204
|
target = File.join(destination, path)
|
|
183
205
|
FileUtils.mkdir_p(File.dirname(target), mode: 0700)
|
|
184
206
|
if mode == "120000"
|
|
@@ -242,7 +264,7 @@ module Ace
|
|
|
242
264
|
result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root) do |path, mode, oid, content|
|
|
243
265
|
entry = JSON.generate("path" => review_bytes(path), "mode" => mode, "oid" => oid, "content" => review_bytes(content))
|
|
244
266
|
separator = first ? "" : ","
|
|
245
|
-
reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 >
|
|
267
|
+
reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > Atoms::CandidateLimits::REVIEW_SNAPSHOT_BYTES
|
|
246
268
|
subject << separator << entry
|
|
247
269
|
first = false
|
|
248
270
|
end
|
|
@@ -256,19 +278,26 @@ module Ace
|
|
|
256
278
|
def prepared_files(repository:, deadline:, head:)
|
|
257
279
|
entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
|
|
258
280
|
reject!("Prepared file inventory is oversized") unless entries.size.between?(4, 4096)
|
|
259
|
-
|
|
260
|
-
files = entries.to_h do |entry|
|
|
281
|
+
parsed = entries.map do |entry|
|
|
261
282
|
metadata, path = entry.split("\t", 2)
|
|
262
283
|
mode, kind, oid = metadata.to_s.split(" ")
|
|
263
|
-
|
|
264
|
-
|
|
284
|
+
reject!("Prepared tree path exceeds bounded length") unless path && !path.start_with?("/") &&
|
|
285
|
+
path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
|
|
286
|
+
unless mode == "100644" && kind == "blob" && oid&.match?(SHA) &&
|
|
287
|
+
path.split("/", -1).none? { |part| part.empty? || part.bytesize > Atoms::CandidateLimits::PATH_COMPONENT_BYTES ||
|
|
288
|
+
%w[. .. .git].include?(part.downcase) }
|
|
265
289
|
reject!("Prepared tree contains unsafe entry")
|
|
266
290
|
end
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
291
|
+
[path, oid]
|
|
292
|
+
end
|
|
293
|
+
by_oid = parsed.group_by(&:last)
|
|
294
|
+
sizes = by_oid.keys.zip(candidate_blob_sizes(repository, deadline, by_oid.keys)).to_h
|
|
295
|
+
reject!("Prepared text is oversized") unless sizes.values.all? { |size| size.between?(1, 1024 * 1024) }
|
|
296
|
+
reject!("Prepared expanded content is oversized") if parsed.sum { |_path, oid| sizes.fetch(oid) } > Atoms::CandidateLimits::PREPARED_BYTES
|
|
297
|
+
files = {}
|
|
298
|
+
ordered = parsed.each
|
|
299
|
+
each_candidate_blob(repository, deadline, parsed.map { |_path, oid| [oid, sizes.fetch(oid)] }) do |_oid, content|
|
|
300
|
+
files[ordered.next.first] = content
|
|
272
301
|
end
|
|
273
302
|
reject!("Prepared inventory has duplicate paths") unless files.size == entries.size
|
|
274
303
|
files
|
|
@@ -289,18 +318,19 @@ module Ace
|
|
|
289
318
|
PrivateDirectory.verify!(@root)
|
|
290
319
|
end
|
|
291
320
|
|
|
292
|
-
def git(repository, deadline, *arguments, limit: 65_536)
|
|
321
|
+
def git(repository, deadline, *arguments, limit: 65_536, stdin_data: "")
|
|
293
322
|
remaining = deadline - monotonic
|
|
294
323
|
raise Timeout::Error if remaining <= 0
|
|
295
324
|
# Git variables may redirect object/config storage even in a clean
|
|
296
325
|
# repository. Strip every inherited Git setting and global config.
|
|
297
326
|
environment = ENV.keys.to_h { |key| [key, nil] }
|
|
298
327
|
environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => "/dev/null",
|
|
328
|
+
"GIT_ATTR_NOSYSTEM" => "1",
|
|
299
329
|
"GIT_TERMINAL_PROMPT" => "0", "GIT_ALLOW_PROTOCOL" => "file",
|
|
300
330
|
"GIT_NO_REPLACE_OBJECTS" => "1", "PATH" => "/usr/bin:/bin", "LC_ALL" => "C")
|
|
301
331
|
result = Herdr::Molecules::BoundedProcess.call([environment, "/usr/bin/git", "-C", repository,
|
|
302
|
-
"-c", "core.hooksPath=/dev/null", "-c", "protocol.file.allow=always", *arguments],
|
|
303
|
-
stdin_data:
|
|
332
|
+
"-c", "core.hooksPath=/dev/null", "-c", "core.attributesFile=/dev/null", "-c", "protocol.file.allow=always", *arguments],
|
|
333
|
+
stdin_data: stdin_data, timeout_s: remaining, output_limit: limit)
|
|
304
334
|
reject!("Candidate Git verification failed") unless result.status.success? && !result.oversized
|
|
305
335
|
result.stdout
|
|
306
336
|
end
|
|
@@ -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"
|
|
@@ -179,7 +179,7 @@ module Ace
|
|
|
179
179
|
@launch.campaign_children_settled!(journal: selected, commit: prefix, params: params, map: map)
|
|
180
180
|
@launch.completion_scope!(journal: selected, events: fresh, params: params, map: map, commit: prefix)
|
|
181
181
|
service_settlement_evidence!(journal: selected, events: fresh, params: params, map: map, commit: prefix)
|
|
182
|
-
plan = Organisms::AttemptCoordinator.finished_transition_plan(events: fresh, receipt: result.fetch("receipt"))
|
|
182
|
+
plan = Organisms::AttemptCoordinator.finished_transition_plan(events: fresh, receipt: result.fetch("receipt"), authority: :protected)
|
|
183
183
|
plan.merge(data: params.slice("attempt_id", "result_id", "head", "candidate_generation").merge(
|
|
184
184
|
"receipt_digest" => result.fetch("receipt_digest"), "state" => result.dig("receipt", "verdict")))
|
|
185
185
|
end
|
|
@@ -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
|