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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "ace/runtime/molecules/process_identity"
|
|
5
|
+
require "ace/herdr/molecules/bounded_observation"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Authority
|
|
10
|
+
# Ordinary UNIX peer attribution. This is not a protected launch observer:
|
|
11
|
+
# it confers no Yama, capability, cgroup or descendant-closure guarantee.
|
|
12
|
+
class OrdinaryPeer
|
|
13
|
+
def initialize(identity: nil)
|
|
14
|
+
@identity = identity
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def capture(socket, deadline: default_deadline)
|
|
18
|
+
identity = identity_observer(deadline)
|
|
19
|
+
uid, gid = socket.getpeereid
|
|
20
|
+
pid = if RUBY_PLATFORM.include?("darwin")
|
|
21
|
+
socket.getsockopt(0, 2).int # SOL_LOCAL / LOCAL_PEERPID
|
|
22
|
+
elsif Socket.const_defined?(:SO_PEERCRED)
|
|
23
|
+
observed_pid, observed_uid, observed_gid = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_PEERCRED).data.unpack("i!3")
|
|
24
|
+
unavailable! unless [observed_uid, observed_gid] == [uid, gid]
|
|
25
|
+
observed_pid
|
|
26
|
+
end
|
|
27
|
+
value = identity.capture(pid)
|
|
28
|
+
unavailable! unless value && value["uid"] == uid && socket.getpeereid == [uid, gid] && identity.capture(pid) == value
|
|
29
|
+
value.merge("gid" => gid).freeze
|
|
30
|
+
rescue SystemCallError, IOError, SocketError, NotImplementedError
|
|
31
|
+
unavailable!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def live!(peer, deadline: default_deadline)
|
|
35
|
+
unavailable! unless peer.is_a?(Hash) && identity_observer(deadline).capture(peer["pid"]) == peer.slice("pid", "uid", "started_at", "host")
|
|
36
|
+
peer
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def default_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
42
|
+
|
|
43
|
+
def identity_observer(deadline)
|
|
44
|
+
@identity || Ace::Runtime::Molecules::ProcessIdentity.new(
|
|
45
|
+
executor: Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def unavailable!
|
|
49
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary kernel peer is unavailable or changed"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
# Source-owned progress for the already accepted work. Queue completion
|
|
7
|
+
# remains a worker report, never a successful execution result or merge.
|
|
8
|
+
module OrdinaryProgress
|
|
9
|
+
PROGRESS_OPERATIONS = %w[queue_start queue_finish queue_fail assignment_snapshot].freeze
|
|
10
|
+
PROGRESS_TRANSFERS = {
|
|
11
|
+
"queue_finish" => {direction: :upload, purpose: :artifacts, roles: %i[worker supervisor]},
|
|
12
|
+
"assignment_snapshot" => {direction: :download, purpose: :assignment_snapshot, roles: %i[worker supervisor reviewer]}
|
|
13
|
+
}.freeze
|
|
14
|
+
PROGRESS_COMMON = %w[assignment_id attempt_id expected_generation pane].freeze
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def validate_progress!(request)
|
|
19
|
+
params = request.fetch("params")
|
|
20
|
+
operation = request.fetch("operation")
|
|
21
|
+
if operation == "assignment_snapshot"
|
|
22
|
+
strict!(params, %w[assignment_id attempt_id kind])
|
|
23
|
+
token!(params.fetch("attempt_id")) unless params.fetch("attempt_id").nil?
|
|
24
|
+
unless request.fetch("mutation_id").nil? && %w[prepared_work queue].include?(params.fetch("kind"))
|
|
25
|
+
raise ArgumentError, "ordinary snapshot must be a read of accepted work or queue"
|
|
26
|
+
end
|
|
27
|
+
else
|
|
28
|
+
fields = case operation
|
|
29
|
+
when "queue_start" then %w[step]
|
|
30
|
+
when "queue_finish" then %w[step transfer]
|
|
31
|
+
when "queue_fail" then %w[message]
|
|
32
|
+
else raise ArgumentError, "unknown ordinary progress operation"
|
|
33
|
+
end
|
|
34
|
+
strict!(params, PROGRESS_COMMON + fields)
|
|
35
|
+
token!(params.fetch("attempt_id")); token!(request.fetch("mutation_id"))
|
|
36
|
+
unless params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation").positive?
|
|
37
|
+
raise ArgumentError, "ordinary progress generation is invalid"
|
|
38
|
+
end
|
|
39
|
+
if params.key?("step") && !params["step"].nil? &&
|
|
40
|
+
!(params["step"].is_a?(String) && params["step"].bytesize.between?(1, 128) && PreparedWork::SCOPE.match?(params["step"]))
|
|
41
|
+
raise ArgumentError, "ordinary progress step is invalid"
|
|
42
|
+
end
|
|
43
|
+
if params.key?("message") && !(params["message"].is_a?(String) && params["message"].bytesize.between?(1, 1024) && !params["message"].include?("\0"))
|
|
44
|
+
raise ArgumentError, "ordinary failure message is invalid"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
token!(params.fetch("assignment_id"))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def assignment_snapshot(request, peer, role, deadline)
|
|
51
|
+
validate_progress!(request)
|
|
52
|
+
params = request.fetch("params")
|
|
53
|
+
commit = @journal.ref_value
|
|
54
|
+
registered = registration(params.fetch("assignment_id"), commit: commit)
|
|
55
|
+
raise AttemptErrors::NotFound, "ordinary assignment is not registered" unless registered
|
|
56
|
+
queue_record = current_queue_record(registered, commit)
|
|
57
|
+
selected_events = if params.fetch("attempt_id")
|
|
58
|
+
result_events(params, commit)
|
|
59
|
+
else
|
|
60
|
+
[]
|
|
61
|
+
end
|
|
62
|
+
kind = params.fetch("kind")
|
|
63
|
+
source = kind == "prepared_work" ? registered : queue_record
|
|
64
|
+
path_key, digest_key, size = if kind == "prepared_work"
|
|
65
|
+
["prepared_bundle_ref", "prepared_bundle_sha256", registered.fetch("prepared_bundle_bytes")]
|
|
66
|
+
else
|
|
67
|
+
["queue_ref", "queue_sha256", PreparedWork::MAX_TOTAL]
|
|
68
|
+
end
|
|
69
|
+
bytes = @journal.bounded_blob(source.fetch(path_key), commit: commit, max_bytes: size)
|
|
70
|
+
unless Digest::SHA256.hexdigest(bytes) == source.fetch(digest_key)
|
|
71
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary snapshot bytes differ from accepted selection"
|
|
72
|
+
end
|
|
73
|
+
authorize!(request, peer, role, deadline)
|
|
74
|
+
{data: {"project_id" => @project, "assignment_id" => params.fetch("assignment_id"), "kind" => kind,
|
|
75
|
+
"attempt_id" => params.fetch("attempt_id"), "generation" => @journal.authority_generation(selected_events),
|
|
76
|
+
"scope" => selected_events.find { |event| event["type"] == "intent" }&.dig("payload", "scope"),
|
|
77
|
+
"journal_commit" => commit, "sha256" => source.fetch(digest_key), "bytes" => bytes.bytesize,
|
|
78
|
+
"prepared_work" => registered.fetch("prepared_work"), "definition_digest" => registered.fetch("definition_digest")},
|
|
79
|
+
transfer_parts: [bytes], replayed: false}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def progress(request, peer, role, deadline, transfer)
|
|
83
|
+
validate_progress!(request)
|
|
84
|
+
unless %i[supervisor worker].include?(role)
|
|
85
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary progress requires its original worker"
|
|
86
|
+
end
|
|
87
|
+
params, operation = request.fetch("params"), request.fetch("operation")
|
|
88
|
+
report = if operation == "queue_finish"
|
|
89
|
+
raise ArgumentError, "ordinary progress report needs one bounded part" unless transfer && transfer.count == 1
|
|
90
|
+
bytes = transfer.bytes.dup.force_encoding(Encoding::UTF_8)
|
|
91
|
+
unless bytes.bytesize.between?(1, 65_536) && bytes.valid_encoding? && !bytes.include?("\0")
|
|
92
|
+
raise ArgumentError, "ordinary progress report must be bounded UTF-8"
|
|
93
|
+
end
|
|
94
|
+
bytes
|
|
95
|
+
elsif transfer
|
|
96
|
+
raise ArgumentError, "ordinary progress operation forbids transfer"
|
|
97
|
+
end
|
|
98
|
+
commit = @journal.ref_value
|
|
99
|
+
registered = registration(params.fetch("assignment_id"), commit: commit)
|
|
100
|
+
raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
|
|
101
|
+
work = registered_work(registered, commit, deadline)
|
|
102
|
+
digest = Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project, "peer_uid" => peer.fetch("uid")})
|
|
103
|
+
keys = [@exclusion.task_key(registered.fetch("task_id")), @exclusion.assignment_key(params.fetch("assignment_id"))]
|
|
104
|
+
@exclusion.with_shared_multi(keys, deadline: deadline) do
|
|
105
|
+
authorize!(request, peer, role, deadline)
|
|
106
|
+
@journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
107
|
+
mutation_id: request.fetch("mutation_id"), operation: operation, parameters_digest: digest,
|
|
108
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
|
|
109
|
+
replay_authorizer: -> { authorize_start_replay!(request, peer, role, deadline) }) do |events, prefix, _generation|
|
|
110
|
+
authorize_start_replay!(request, peer, role, deadline)
|
|
111
|
+
unless Molecules::CanonicalAttemptState.derive(events) == "running"
|
|
112
|
+
raise AttemptErrors::Conflict, "ordinary progress requires the original running attempt"
|
|
113
|
+
end
|
|
114
|
+
record = current_queue_record(registered, prefix)
|
|
115
|
+
scope = events.find { |event| event["type"] == "intent" }.fetch("payload").fetch("scope")
|
|
116
|
+
queue = OrdinaryQueue.new(work: work, root: @input_root, bytes: queue_bytes(record, prefix))
|
|
117
|
+
next_queue = case operation
|
|
118
|
+
when "queue_start" then queue.transition(operation: :start, scope: scope, step: params.fetch("step"))
|
|
119
|
+
when "queue_finish" then queue.transition(operation: :finish, scope: scope, step: params.fetch("step"), report: report)
|
|
120
|
+
when "queue_fail" then queue.transition(operation: :fail, scope: scope, message: params.fetch("message"))
|
|
121
|
+
end
|
|
122
|
+
sha = Digest::SHA256.hexdigest(next_queue.bytes)
|
|
123
|
+
path = "execution/definitions/#{params.fetch('assignment_id')}-queue-#{sha}.json"
|
|
124
|
+
{events: [], blobs: {path => next_queue.bytes}, data: {
|
|
125
|
+
"authority" => "ordinary", "assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
|
|
126
|
+
"project_id" => @project, "scope" => scope, "queue_ref" => path, "queue_sha256" => sha, "state" => "running",
|
|
127
|
+
"queue_complete" => next_queue.status.subtree_complete?(scope)}}
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def current_queue_record(registered, commit)
|
|
133
|
+
assignment = registered.fetch("assignment_id")
|
|
134
|
+
events = @journal.read_events(assignment, commit: commit)
|
|
135
|
+
events.group_by { |event| event.fetch("attempt_id") }.each_value do |chain|
|
|
136
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary queue history is corrupt" unless Models::EvidenceEvent.chain_valid?(chain)
|
|
137
|
+
end
|
|
138
|
+
updates = events.select do |entry|
|
|
139
|
+
entry["type"] == "authority_mutation" &&
|
|
140
|
+
%w[start_attempt queue_start queue_finish queue_fail].include?(entry.dig("payload", "operation"))
|
|
141
|
+
end
|
|
142
|
+
return registered if updates.empty?
|
|
143
|
+
event = @journal.latest_event!(assignment_id: assignment, event_digests: updates.map { |entry| entry.fetch("digest") }, commit: commit)
|
|
144
|
+
record = event.dig("payload", "data")
|
|
145
|
+
unless record && record.values_at("authority", "assignment_id", "attempt_id", "project_id") ==
|
|
146
|
+
["ordinary", assignment, event.fetch("attempt_id"), @project]
|
|
147
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary canonical queue is unavailable"
|
|
148
|
+
end
|
|
149
|
+
record
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def queue_bytes(record, commit)
|
|
153
|
+
bytes = @journal.bounded_blob(record.fetch("queue_ref"), commit: commit, max_bytes: PreparedWork::MAX_TOTAL)
|
|
154
|
+
unless Digest::SHA256.hexdigest(bytes) == record.fetch("queue_sha256")
|
|
155
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary canonical queue bytes differ"
|
|
156
|
+
end
|
|
157
|
+
bytes
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "prepared_work"
|
|
6
|
+
require_relative "private_directory"
|
|
7
|
+
require_relative "../organisms/assignment_executor"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Assign
|
|
11
|
+
module Authority
|
|
12
|
+
# A disposable projection for the existing queue engine. Only bytes read
|
|
13
|
+
# from (or about to be committed to) the canonical execution ref enter it.
|
|
14
|
+
# No files left by an earlier coordinator are recovery input.
|
|
15
|
+
class OrdinaryQueue
|
|
16
|
+
attr_reader :bytes
|
|
17
|
+
|
|
18
|
+
def initialize(work:, root:, bytes: nil)
|
|
19
|
+
raise ArgumentError, "ordinary queue requires prepared work" unless work.is_a?(PreparedWork)
|
|
20
|
+
@work, @root = work, PrivateDirectory.verify!(root)
|
|
21
|
+
value = if bytes
|
|
22
|
+
invalid! unless bytes.is_a?(String) && bytes.bytesize.between?(1, PreparedWork::MAX_TOTAL)
|
|
23
|
+
JSON.parse(bytes)
|
|
24
|
+
else
|
|
25
|
+
{"version" => 1, "steps" => work.manifest.fetch("steps").to_h { |step|
|
|
26
|
+
name = step.fetch("filename"); [name, work.files.fetch("steps/#{name}")]
|
|
27
|
+
}, "reports" => {}}
|
|
28
|
+
end
|
|
29
|
+
validate!(value, initial: bytes.nil?)
|
|
30
|
+
@bytes = Atoms::EvidenceDigest.canonical_json(value).freeze
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
invalid!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def status
|
|
36
|
+
value = JSON.parse(@bytes)
|
|
37
|
+
assignment = Models::Assignment.from_h(@work.definition, cache_dir: File.join(@root, @work.manifest.fetch("assignment_id")))
|
|
38
|
+
Molecules::QueueScanner.new.scan_captured(steps: value.fetch("steps"), reports: value.fetch("reports"), assignment: assignment)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# This returns proposed bytes only. The lifecycle owns authorization,
|
|
42
|
+
# generation, replay and publication together with its attempt events.
|
|
43
|
+
def transition(operation:, scope: @work.manifest.fetch("scope"), step: nil, report: nil, message: nil)
|
|
44
|
+
invalid! unless @work.manifest.fetch("steps").any? { |entry| entry.fetch("number") == scope }
|
|
45
|
+
value = JSON.parse(@bytes)
|
|
46
|
+
Dir.mktmpdir("ordinary-queue-", @root) do |scratch|
|
|
47
|
+
File.chmod(0o700, scratch)
|
|
48
|
+
directory = File.join(scratch, @work.manifest.fetch("assignment_id"))
|
|
49
|
+
%w[steps reports].each { |name| FileUtils.mkdir_p(File.join(directory, name), mode: 0700) }
|
|
50
|
+
assignment = Models::Assignment.from_h(@work.definition, cache_dir: directory)
|
|
51
|
+
write(File.join(directory, "assignment.yaml"), assignment.to_h.to_yaml)
|
|
52
|
+
write(File.join(directory, "job.yaml"), @work.files.fetch("job.yaml"))
|
|
53
|
+
%w[steps reports].each do |group|
|
|
54
|
+
value.fetch(group).each { |filename, content| write(File.join(directory, group, filename), content) }
|
|
55
|
+
end
|
|
56
|
+
executor = Organisms::AssignmentExecutor.new(cache_base: scratch, selected_assignment: assignment)
|
|
57
|
+
case operation
|
|
58
|
+
when :activate
|
|
59
|
+
state = executor.status.fetch(:state)
|
|
60
|
+
invalid! unless state.find_by_number(scope)&.status == :pending
|
|
61
|
+
executor.step_writer.mark_active(state.find_by_number(scope).file_path)
|
|
62
|
+
when :start
|
|
63
|
+
executor.start_step(step_number: step, fork_root: scope)
|
|
64
|
+
when :finish
|
|
65
|
+
executor.finish_step(report_content: report, step_number: step, fork_root: scope)
|
|
66
|
+
when :fail
|
|
67
|
+
executor.fail(message, fork_root: scope)
|
|
68
|
+
else
|
|
69
|
+
raise ArgumentError, "unknown ordinary queue transition"
|
|
70
|
+
end
|
|
71
|
+
result = {"version" => 1}
|
|
72
|
+
%w[steps reports].each do |group|
|
|
73
|
+
result[group] = Dir.children(File.join(directory, group)).to_h do |filename|
|
|
74
|
+
[filename, File.binread(File.join(directory, group, filename)).force_encoding(Encoding::UTF_8)]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
self.class.new(work: @work, root: @root, bytes: Atoms::EvidenceDigest.canonical_json(result))
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def validate!(value, initial:)
|
|
84
|
+
invalid! unless value.is_a?(Hash) && value.keys.sort == %w[reports steps version] && value["version"].is_a?(Integer) && value["version"] == 1 &&
|
|
85
|
+
value["steps"].is_a?(Hash) && value["reports"].is_a?(Hash)
|
|
86
|
+
expected = @work.manifest.fetch("steps").map { |step| step.fetch("filename") }
|
|
87
|
+
invalid! unless value.fetch("steps").keys.sort == expected.sort
|
|
88
|
+
reports = []
|
|
89
|
+
value.fetch("steps").each do |filename, content|
|
|
90
|
+
parsed = @work.parse_queue_step!(content)
|
|
91
|
+
progress = parsed.fetch(:frontmatter).keys & PreparedWork::PROGRESS
|
|
92
|
+
allowed = initial ? %w[status] : %w[status started_at completed_at error stall_reason]
|
|
93
|
+
invalid! unless (progress - allowed).empty?
|
|
94
|
+
original = @work.files.fetch("steps/#{filename}")
|
|
95
|
+
invalid! unless PreparedWork.work_projection(content) == PreparedWork.work_projection(original) &&
|
|
96
|
+
%w[pending active done failed].include?(parsed.fetch(:frontmatter).fetch("status", "pending"))
|
|
97
|
+
info = Atoms::StepFileParser.parse_filename(filename)
|
|
98
|
+
reports << Atoms::StepFileParser.generate_report_filename(info.fetch(:number), info.fetch(:name))
|
|
99
|
+
end
|
|
100
|
+
invalid! unless (value.fetch("reports").keys - reports).empty?
|
|
101
|
+
value.fetch("reports").each_value { |content| @work.parse_queue_step!(content) }
|
|
102
|
+
invalid! if Atoms::EvidenceDigest.canonical_json(value).bytesize > PreparedWork::MAX_TOTAL
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def write(path, content)
|
|
106
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) { |file| file.write(content) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def invalid!
|
|
110
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary canonical queue differs from its accepted work"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "release_artifact_manifest"
|
|
4
|
+
require_relative "candidate_transfer"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
# Retention is a canonical input, never a publication permit or result.
|
|
10
|
+
# Native/candidate/review and release policy are rechecked at every CAS.
|
|
11
|
+
module OrdinaryReleaseArtifacts
|
|
12
|
+
RELEASE_OPERATIONS = %w[retain_release_artifact].freeze
|
|
13
|
+
RELEASE_TRANSFERS = {"retain_release_artifact" =>
|
|
14
|
+
{direction: :upload, purpose: :release_artifact, roles: %i[worker supervisor]}}.freeze
|
|
15
|
+
RELEASE_PARAMETERS = %w[assignment_id attempt_id head candidate_generation pane expected_generation
|
|
16
|
+
authorization manifest transfer].freeze
|
|
17
|
+
|
|
18
|
+
# Read an accepted import at the current immutable canonical prefix only.
|
|
19
|
+
# Historical commit selection is not supported by the native origin reader.
|
|
20
|
+
# The later effect consumer must independently admit its fresh scope,
|
|
21
|
+
# candidate, review, authorization and fixed executor before dispatch.
|
|
22
|
+
def release_artifact_at!(events:, commit:, artifact_id:)
|
|
23
|
+
token!(artifact_id)
|
|
24
|
+
unless commit && Models::EvidenceEvent.chain_valid?(events)
|
|
25
|
+
raise AttemptErrors::EvidenceUnavailable, "release artifact history is unavailable"
|
|
26
|
+
end
|
|
27
|
+
accepted = events.select { |event| event["type"] == "authority_mutation" &&
|
|
28
|
+
event.dig("payload", "operation") == "retain_release_artifact" && event.dig("payload", "mutation_id") == artifact_id }
|
|
29
|
+
data = accepted.one? && accepted.first.dig("payload", "data")
|
|
30
|
+
unless data && data["artifact_id"] == artifact_id && data["input_digest"] == Atoms::EvidenceDigest.digest(data.fetch("input"))
|
|
31
|
+
raise AttemptErrors::EvidenceUnavailable, "release artifact acceptance is unavailable"
|
|
32
|
+
end
|
|
33
|
+
origin = data.fetch("producer_origin")
|
|
34
|
+
retained = @journal.read_events(origin.fetch("assignment_id"), commit: commit)
|
|
35
|
+
.select { |event| event["attempt_id"] == origin.fetch("attempt_id") }
|
|
36
|
+
unless retained == events && ordinary_origin_at!(events, commit, running: false) == origin
|
|
37
|
+
raise AttemptErrors::EvidenceUnavailable, "release artifact original scope differs"
|
|
38
|
+
end
|
|
39
|
+
context = release_evidence_context(data.fetch("input"), origin, data.fetch("authorization"), artifact_id)
|
|
40
|
+
bytes = Molecules::CanonicalEvidence.new(journal: @journal).read(data.fetch("artifact"), **context, commit: commit)
|
|
41
|
+
unless bytes.bytesize == data.dig("input", "publication", "artifact_bytes") &&
|
|
42
|
+
Digest::SHA256.hexdigest(bytes) == data.dig("input", "target", "artifact_digest")
|
|
43
|
+
raise AttemptErrors::EvidenceUnavailable, "release artifact retained bytes differ"
|
|
44
|
+
end
|
|
45
|
+
data.merge("bytes" => bytes)
|
|
46
|
+
rescue KeyError, TypeError
|
|
47
|
+
raise AttemptErrors::EvidenceUnavailable, "release artifact acceptance is incomplete"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def validate_ordinary_release!(request)
|
|
53
|
+
params = request.fetch("params")
|
|
54
|
+
strict!(params, RELEASE_PARAMETERS)
|
|
55
|
+
%w[assignment_id attempt_id authorization].each { |key| token!(params.fetch(key)) }
|
|
56
|
+
token!(request.fetch("mutation_id"))
|
|
57
|
+
unless params["head"].is_a?(String) && CandidateTransfer::SHA.match?(params["head"]) &&
|
|
58
|
+
%w[candidate_generation expected_generation].all? { |key| params[key].is_a?(Integer) && params[key].positive? }
|
|
59
|
+
raise ArgumentError, "invalid release artifact candidate selection"
|
|
60
|
+
end
|
|
61
|
+
ReleaseArtifactManifest.validate!(params.fetch("manifest"))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def authorize_ordinary_release!(request, peer, role, deadline)
|
|
65
|
+
validate_ordinary_release!(request)
|
|
66
|
+
params = request.fetch("params")
|
|
67
|
+
with_scope(assignment: params.fetch("assignment_id"), attempt: params.fetch("attempt_id"), deadline: deadline) do
|
|
68
|
+
prefix = @journal.ref_value
|
|
69
|
+
release_context_at!(request, peer, role, deadline, result_events(params, prefix), prefix)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Caller holds lifecycle exclusion before entering the journal writer.
|
|
74
|
+
# This method never acquires another lifecycle lock or trusts a path.
|
|
75
|
+
def release_context_at!(request, peer, role, deadline, events, commit)
|
|
76
|
+
authorize!(request, peer, role, deadline)
|
|
77
|
+
unless %i[worker supervisor].include?(role) && @release_authorizer
|
|
78
|
+
raise AttemptErrors::UnauthorizedIdentity, "installed release artifact authorization is unavailable"
|
|
79
|
+
end
|
|
80
|
+
params = request.fetch("params")
|
|
81
|
+
origin = origin_at!(events: events, commit: commit)
|
|
82
|
+
current = candidate_at!(events: events, commit: commit, head: params.fetch("head"), generation: params.fetch("candidate_generation"))
|
|
83
|
+
reference = candidate_reference_at!(events: events, commit: commit, head: params.fetch("head"), generation: params.fetch("candidate_generation"))
|
|
84
|
+
observed = @admission.execution!(peer: peer, pane: params.fetch("pane"), head: current.fetch("head"), deadline: deadline)
|
|
85
|
+
unless observed.fetch(:identity).runtime_binding == origin.fetch("runtime_binding")
|
|
86
|
+
raise AttemptErrors::UnauthorizedIdentity, "release artifact producer native identity changed"
|
|
87
|
+
end
|
|
88
|
+
review_at!(events: events, commit: commit, head: current.fetch("head"), generation: current.fetch("candidate_generation"))
|
|
89
|
+
input = ReleaseArtifactManifest.publication_input(manifest: params.fetch("manifest"), candidate: current, reference: reference)
|
|
90
|
+
binding = params.slice("assignment_id", "attempt_id", "authorization").merge(
|
|
91
|
+
"operation" => "publish", "project_id" => @project, "candidate_head" => current.fetch("head"),
|
|
92
|
+
"caller_uid" => origin.dig("runtime_binding", "process_identity", "uid"),
|
|
93
|
+
"input_digest" => Atoms::EvidenceDigest.digest(input), "target" => input.fetch("target"))
|
|
94
|
+
unless @release_authorizer.call(binding)
|
|
95
|
+
raise AttemptErrors::UnauthorizedIdentity, "release artifact manifest is not authorized"
|
|
96
|
+
end
|
|
97
|
+
{input: input, binding: binding, origin: origin}
|
|
98
|
+
rescue SecurityError
|
|
99
|
+
raise AttemptErrors::UnauthorizedIdentity, "release artifact manifest authorization is unavailable"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def retain_release_artifact(request, peer, role, deadline, transfer)
|
|
103
|
+
validate_ordinary_release!(request)
|
|
104
|
+
unless transfer && transfer.count == 1
|
|
105
|
+
raise AttemptErrors::MalformedTransfer, "release artifact requires its one bounded binary transfer"
|
|
106
|
+
end
|
|
107
|
+
params = request.fetch("params")
|
|
108
|
+
with_scope(assignment: params.fetch("assignment_id"), attempt: params.fetch("attempt_id"), deadline: deadline) do
|
|
109
|
+
prefix = @journal.ref_value
|
|
110
|
+
release_context_at!(request, peer, role, deadline, result_events(params, prefix), prefix)
|
|
111
|
+
bytes = transfer.bytes.dup.freeze
|
|
112
|
+
ReleaseArtifactManifest.verify!(bytes, params.fetch("manifest"))
|
|
113
|
+
digest = Atoms::EvidenceDigest.digest("project_id" => @project, "params" => params)
|
|
114
|
+
recheck = lambda do
|
|
115
|
+
current = @journal.ref_value
|
|
116
|
+
release_context_at!(request, peer, role, deadline, result_events(params, current), current)
|
|
117
|
+
end
|
|
118
|
+
@journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
119
|
+
mutation_id: request.fetch("mutation_id"), operation: "retain_release_artifact", parameters_digest: digest,
|
|
120
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
|
|
121
|
+
replay_authorizer: recheck) do |events, commit, _generation|
|
|
122
|
+
selected = release_context_at!(request, peer, role, deadline, events, commit)
|
|
123
|
+
artifact_id = request.fetch("mutation_id")
|
|
124
|
+
context = release_evidence_context(selected.fetch(:input), selected.fetch(:origin), params.fetch("authorization"), artifact_id)
|
|
125
|
+
plan = Molecules::CanonicalEvidence.new(journal: @journal).import_plan(**context,
|
|
126
|
+
artifacts: [bytes], admitted_after_event_digest: events.last.fetch("digest"))
|
|
127
|
+
plan.merge(data: {"artifact_id" => artifact_id, "input" => selected.fetch(:input),
|
|
128
|
+
"input_digest" => selected.fetch(:binding).fetch("input_digest"), "artifact" => plan.fetch(:references).first,
|
|
129
|
+
"authorization" => params.fetch("authorization"), "producer_origin" => selected.fetch(:origin)})
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def release_evidence_context(input, origin, authorization, artifact_id)
|
|
135
|
+
{kind: "release-artifact", project_id: @project, assignment_id: origin.fetch("assignment_id"),
|
|
136
|
+
attempt_id: origin.fetch("attempt_id"), peer_uid: origin.dig("runtime_binding", "process_identity", "uid"),
|
|
137
|
+
binding: {"input" => input, "producer_origin" => origin, "authorization" => authorization},
|
|
138
|
+
request_id_or_event_id: artifact_id, generation: input.fetch("publication").fetch("candidate_reference").fetch("candidate_generation")}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|