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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
  3. data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
  4. data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
  5. data/CHANGELOG.md +27 -0
  6. data/handbook/skills/as-assign-drive/SKILL.md +5 -2
  7. data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
  8. data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
  9. data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
  10. data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
  11. data/lib/ace/assign/authority/assignment_definition.rb +35 -0
  12. data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
  13. data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +57 -27
  15. data/lib/ace/assign/authority/command_execution.rb +180 -0
  16. data/lib/ace/assign/authority/endcap.rb +4 -2
  17. data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
  18. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
  19. data/lib/ace/assign/authority/endcap_publication.rb +0 -166
  20. data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
  21. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
  22. data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
  23. data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
  24. data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
  25. data/lib/ace/assign/authority/ordinary_client.rb +70 -0
  26. data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
  27. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
  28. data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
  29. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
  30. data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
  31. data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
  32. data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
  33. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
  34. data/lib/ace/assign/authority/ordinary_results.rb +206 -0
  35. data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
  36. data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
  37. data/lib/ace/assign/authority/ordinary_server.rb +75 -0
  38. data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
  39. data/lib/ace/assign/authority/prepared_work.rb +9 -4
  40. data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
  41. data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
  42. data/lib/ace/assign/authority/retained_input.rb +53 -0
  43. data/lib/ace/assign/authority/router.rb +11 -6
  44. data/lib/ace/assign/authority/server.rb +11 -180
  45. data/lib/ace/assign/authority/socket_server.rb +255 -0
  46. data/lib/ace/assign/authority/transfer_codec.rb +5 -3
  47. data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
  48. data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
  49. data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
  50. data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
  51. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
  52. data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
  53. data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
  54. data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
  55. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
  56. data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
  57. data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
  58. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  59. data/lib/ace/assign/organisms/attempt_coordinator.rb +88 -36
  60. data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
  61. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
  62. data/lib/ace/assign/version.rb +1 -1
  63. 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