ace-assign 0.68.0 → 0.70.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/catalog/composition-rules.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/commit.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/lint.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/record-demo.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/research.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/task-load.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -0
- 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/quick-implement.yml +0 -0
- data/.ace-defaults/assign/presets/release-only.yml +0 -0
- data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
- data/.ace-defaults/assign/presets/work-on-docs.yml +0 -0
- data/.ace-defaults/assign/presets/work-on-task-in-place.yml +0 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +0 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +0 -0
- data/CHANGELOG.md +17 -0
- data/Rakefile +0 -0
- data/docs/demo/canonical-skill-source.gif +0 -0
- data/docs/demo/fork-provider.cast +0 -0
- data/docs/demo/fork-provider.gif +0 -0
- data/docs/demo/fork-provider.recording.json +0 -0
- data/docs/demo/fork-provider.tape.yml +0 -0
- data/docs/getting-started.md +0 -0
- data/docs/handbook.md +0 -0
- data/handbook/guides/fork-context.g.md +0 -0
- data/handbook/skills/as-assign-add-task/SKILL.md +0 -0
- data/handbook/skills/as-assign-compose/SKILL.md +0 -0
- data/handbook/skills/as-assign-create/SKILL.md +0 -0
- data/handbook/skills/as-assign-drive/SKILL.md +0 -0
- data/handbook/skills/as-assign-prepare/SKILL.md +0 -0
- data/handbook/skills/as-assign-recover-fork/SKILL.md +0 -0
- data/handbook/skills/as-assign-run-in-batches/SKILL.md +0 -0
- data/handbook/skills/as-assign-start/SKILL.md +0 -0
- data/handbook/skills/as-create-retro-internal/SKILL.md +0 -0
- data/handbook/skills/as-mark-task-done-internal/SKILL.md +0 -0
- data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +0 -0
- data/handbook/skills/as-task-load-internal/SKILL.md +0 -0
- data/handbook/workflow-instructions/assign/add-task.wf.md +0 -0
- data/handbook/workflow-instructions/assign/compose.wf.md +0 -0
- data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/create.wf.md +0 -0
- data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/recover-fork.wf.md +0 -0
- data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/run-in-batches.wf.md +0 -0
- data/handbook/workflow-instructions/assign/start.wf.md +0 -0
- data/handbook/workflow-instructions/assign/task-load-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +0 -0
- data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +0 -0
- data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
- data/lib/ace/assign/atoms/catalog_loader.rb +0 -0
- data/lib/ace/assign/atoms/composition_rules.rb +0 -0
- data/lib/ace/assign/atoms/number_generator.rb +0 -0
- data/lib/ace/assign/atoms/preset_expander.rb +0 -0
- data/lib/ace/assign/atoms/preset_loader.rb +0 -0
- data/lib/ace/assign/atoms/preset_step_resolver.rb +0 -0
- data/lib/ace/assign/atoms/step_file_parser.rb +0 -0
- data/lib/ace/assign/atoms/step_numbering.rb +0 -0
- data/lib/ace/assign/atoms/step_sorter.rb +0 -0
- data/lib/ace/assign/atoms/tree_formatter.rb +0 -0
- data/lib/ace/assign/authority/assignment_definition.rb +35 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +2 -2
- data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
- 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 +94 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +191 -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_results.rb +196 -0
- data/lib/ace/assign/authority/ordinary_server.rb +75 -0
- data/lib/ace/assign/authority/prepared_work.rb +2 -2
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- 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 +2 -2
- data/lib/ace/assign/cli/commands/add.rb +0 -0
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- data/lib/ace/assign/cli/commands/list.rb +0 -0
- data/lib/ace/assign/cli/commands/retry_cmd.rb +0 -0
- data/lib/ace/assign/models/assignment_info.rb +0 -0
- data/lib/ace/assign/models/queue_state.rb +0 -0
- data/lib/ace/assign/models/step.rb +0 -0
- data/lib/ace/assign/molecules/assignment_discoverer.rb +0 -0
- 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 +141 -34
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +5 -3
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/preset_inferrer.rb +0 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +0 -0
- data/lib/ace/assign/molecules/step_renumberer.rb +0 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +58 -36
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +24 -10
|
@@ -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,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "receipt_transfer"
|
|
4
|
+
require_relative "../molecules/canonical_evidence"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
# A worker submits an attributable result. This never accepts a terminal
|
|
10
|
+
# outcome, an independent review, or an external delivery effect.
|
|
11
|
+
module OrdinaryResults
|
|
12
|
+
RESULT_OPERATIONS = %w[submit_result result_status result_snapshot].freeze
|
|
13
|
+
RESULT_TRANSFERS = {
|
|
14
|
+
"submit_result" => {direction: :upload, purpose: :receipt_artifacts, roles: %i[worker supervisor]},
|
|
15
|
+
"result_snapshot" => {direction: :download, purpose: :receipt_artifacts, roles: %i[worker supervisor]}
|
|
16
|
+
}.freeze
|
|
17
|
+
RESULT_KEYS = %w[assignment_id attempt_id expected_generation pane head receipt_sha256 transfer].freeze
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def validate_result!(request)
|
|
22
|
+
params = request.fetch("params")
|
|
23
|
+
if request.fetch("operation") != "submit_result"
|
|
24
|
+
snapshot = request.fetch("operation") == "result_snapshot"
|
|
25
|
+
strict!(params, %w[assignment_id attempt_id] + (snapshot ? ["result_id"] : []))
|
|
26
|
+
token!(params.fetch("result_id")) if snapshot
|
|
27
|
+
raise ArgumentError, "ordinary result status mutation must be null" unless request.fetch("mutation_id").nil?
|
|
28
|
+
else
|
|
29
|
+
strict!(params, RESULT_KEYS)
|
|
30
|
+
token!(request.fetch("mutation_id"))
|
|
31
|
+
unless params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation").positive? &&
|
|
32
|
+
params.fetch("head").is_a?(String) && params.fetch("head").match?(/\A[0-9a-f]{40}\z/) &&
|
|
33
|
+
params.fetch("receipt_sha256").is_a?(String) && params.fetch("receipt_sha256").match?(/\A[0-9a-f]{64}\z/)
|
|
34
|
+
raise ArgumentError, "ordinary result selection is invalid"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
%w[assignment_id attempt_id].each { |key| token!(params.fetch(key)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ordinary_result(request, peer, role, deadline, transfer)
|
|
41
|
+
validate_result!(request)
|
|
42
|
+
params = request.fetch("params")
|
|
43
|
+
commit = @journal.ref_value
|
|
44
|
+
registered = registration(params.fetch("assignment_id"), commit: commit)
|
|
45
|
+
raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
|
|
46
|
+
if request.fetch("operation") != "submit_result"
|
|
47
|
+
events = result_events(params, commit)
|
|
48
|
+
result = retained_ordinary_result(params, events, commit)
|
|
49
|
+
if request.fetch("operation") == "result_snapshot"
|
|
50
|
+
unless result && result.fetch("result_id") == params.fetch("result_id")
|
|
51
|
+
raise AttemptErrors::NotFound, "ordinary exact result is unavailable"
|
|
52
|
+
end
|
|
53
|
+
receipt = result.fetch("receipt")
|
|
54
|
+
canonical = Molecules::CanonicalEvidence.new(journal: @journal)
|
|
55
|
+
parts = [JSON.generate(receipt)] + receipt.fetch("artifacts").map { |artifact|
|
|
56
|
+
canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
|
|
57
|
+
**ordinary_result_context(result.fetch("binding")), commit: commit)
|
|
58
|
+
}
|
|
59
|
+
authorize!(request, peer, role, deadline)
|
|
60
|
+
return {data: ordinary_result_projection(result).merge("journal_commit" => commit), transfer_parts: parts, replayed: false}
|
|
61
|
+
end
|
|
62
|
+
authorize!(request, peer, role, deadline)
|
|
63
|
+
return {data: {"assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
|
|
64
|
+
"journal_commit" => commit, "generation" => @journal.authority_generation(events),
|
|
65
|
+
"state" => Molecules::CanonicalAttemptState.derive(events), "submitted_result" => result && ordinary_result_projection(result)}, replayed: false}
|
|
66
|
+
end
|
|
67
|
+
unless %i[worker supervisor].include?(role)
|
|
68
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary result requires its original worker"
|
|
69
|
+
end
|
|
70
|
+
authorize_start_replay!(request, peer, role, deadline)
|
|
71
|
+
admitted = ReceiptTransfer.decode(input: transfer, receipt_sha256: params.fetch("receipt_sha256"),
|
|
72
|
+
artifact_field: "artifacts", reference_key: "path")
|
|
73
|
+
digest = Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project, "peer_uid" => peer.fetch("uid")})
|
|
74
|
+
keys = [@exclusion.task_key(registered.fetch("task_id")), @exclusion.assignment_key(params.fetch("assignment_id"))]
|
|
75
|
+
@exclusion.with_shared_multi(keys, deadline: deadline) do
|
|
76
|
+
@journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
77
|
+
mutation_id: request.fetch("mutation_id"), operation: "submit_result", parameters_digest: digest,
|
|
78
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
|
|
79
|
+
replay_authorizer: -> { authorize_start_replay!(request, peer, role, deadline) }) do |events, prefix, generation|
|
|
80
|
+
authorize_start_replay!(request, peer, role, deadline)
|
|
81
|
+
unless Molecules::CanonicalAttemptState.derive(events) == "running"
|
|
82
|
+
raise AttemptErrors::Conflict, "ordinary result requires the original running attempt"
|
|
83
|
+
end
|
|
84
|
+
raise AttemptErrors::Conflict, "ordinary attempt already submitted its result" if retained_ordinary_result(params, events, prefix)
|
|
85
|
+
observed = @admission.execution!(peer: peer, pane: params.fetch("pane"), head: params.fetch("head"), deadline: deadline)
|
|
86
|
+
start_replay_owner!(params, observed.fetch(:identity))
|
|
87
|
+
ordinary_result_plan(params, peer, admitted, observed, events, prefix, generation + 1)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def result_events(params, commit)
|
|
93
|
+
events = @journal.read_events(params.fetch("assignment_id"), commit: commit)
|
|
94
|
+
.select { |event| event.fetch("attempt_id") == params.fetch("attempt_id") }
|
|
95
|
+
unless Models::EvidenceEvent.chain_valid?(events) && events.one? { |event| event["type"] == "process_start" }
|
|
96
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary result attempt evidence is unavailable"
|
|
97
|
+
end
|
|
98
|
+
events
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ordinary_result_plan(params, peer, admitted, observed, events, commit, generation)
|
|
102
|
+
original = admitted.fetch(:receipt)
|
|
103
|
+
original_receipt = Models::ExecutionReceipt.from_h(original)
|
|
104
|
+
unless original_receipt.digest == Atoms::EvidenceDigest.digest(original_receipt.digest_payload)
|
|
105
|
+
raise AttemptErrors::ReceiptRejected, "ordinary original result digest differs"
|
|
106
|
+
end
|
|
107
|
+
identity = observed.fetch(:identity)
|
|
108
|
+
expected_producer = {"actor" => identity.actor, "role" => identity.role, "runtime" => identity.runtime}
|
|
109
|
+
validate_ordinary_result_receipt!(original, expected_producer)
|
|
110
|
+
id = SecureRandom.hex(16)
|
|
111
|
+
binding = params.slice("assignment_id", "attempt_id", "head").merge("authority" => "ordinary", "project_id" => @project,
|
|
112
|
+
"result_id" => id, "worker_uid" => peer.fetch("uid"), "producer" => expected_producer,
|
|
113
|
+
"runtime_binding" => identity.runtime_binding, "checkout" => observed.fetch(:checkout),
|
|
114
|
+
"generation" => generation, "uploaded_receipt_sha256" => admitted.fetch(:receipt_sha256),
|
|
115
|
+
"original_receipt_digest" => original_receipt.digest)
|
|
116
|
+
canonical = Molecules::CanonicalEvidence.new(journal: @journal)
|
|
117
|
+
plan = admitted.fetch(:artifacts).empty? ? {events: [], blobs: {}, references: []} :
|
|
118
|
+
canonical.import_plan(**ordinary_result_context(binding), artifacts: admitted.fetch(:artifacts),
|
|
119
|
+
admitted_after_event_digest: events.last.fetch("digest"))
|
|
120
|
+
receipt = original.except("digest", "recorded_at").merge("artifacts" => plan.fetch(:references).map { |reference|
|
|
121
|
+
{"path" => reference.fetch("ref"), "sha256" => reference.fetch("sha256")}
|
|
122
|
+
})
|
|
123
|
+
pending = @journal.send(:chain_mutation_events, params.fetch("attempt_id"), events.last.fetch("digest"), plan.fetch(:events))
|
|
124
|
+
reader = ->(_data, artifact) { canonical.read_pending({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
|
|
125
|
+
**ordinary_result_context(binding), current_events: events, pending_events: pending, blobs: plan.fetch(:blobs), commit: commit) }
|
|
126
|
+
verified = verify_ordinary_result_receipt(params, receipt, binding, commit, reader)
|
|
127
|
+
if JSON.generate(verified.to_h).bytesize > 16_384
|
|
128
|
+
raise AttemptErrors::ReceiptRejected, "canonical ordinary receipt exceeds its transfer limit"
|
|
129
|
+
end
|
|
130
|
+
payload = {"authority" => "ordinary", "result_id" => id, "binding" => binding, "receipt" => verified.to_h}
|
|
131
|
+
plan.merge(events: plan.fetch(:events) + [{type: "result_submitted", payload: payload}],
|
|
132
|
+
data: ordinary_result_projection(payload))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def ordinary_result_projection(payload)
|
|
136
|
+
payload.slice("result_id").merge(payload.fetch("binding").slice("head", "generation"),
|
|
137
|
+
"receipt_digest" => payload.fetch("receipt").fetch("digest"), "verdict" => payload.fetch("receipt").fetch("verdict"),
|
|
138
|
+
"state" => "running", "authority" => "ordinary")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def ordinary_result_context(binding)
|
|
142
|
+
{kind: "result", project_id: @project, assignment_id: binding.fetch("assignment_id"), attempt_id: binding.fetch("attempt_id"),
|
|
143
|
+
peer_uid: binding.fetch("worker_uid"), binding: binding, request_id_or_event_id: binding.fetch("result_id"), generation: binding.fetch("generation")}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_ordinary_result_receipt!(receipt, producer)
|
|
147
|
+
unless receipt["producer"] == producer && (receipt.keys - Models::ExecutionReceipt.from_h(receipt).to_h.keys).empty? &&
|
|
148
|
+
!receipt["campaign"] && !receipt["review"] && receipt["operation"] != "review" &&
|
|
149
|
+
!Molecules::ReceiptVerifier::EXTERNAL_EFFECT_OPERATIONS.include?(receipt["operation"])
|
|
150
|
+
raise AttemptErrors::ReceiptRejected, "ordinary result producer or operation requires another evidence owner"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def verify_ordinary_result_receipt(params, receipt, binding, commit, reader)
|
|
155
|
+
validate_ordinary_result_receipt!(receipt, binding.fetch("producer"))
|
|
156
|
+
attempt = @journal.derived_attempts(params.fetch("assignment_id"), commit: commit).find { |item| item.attempt_id == params.fetch("attempt_id") }
|
|
157
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary result attempt is unavailable" unless attempt
|
|
158
|
+
Array(receipt["artifacts"]).each { |artifact| reader.call(receipt, artifact) }
|
|
159
|
+
Molecules::ReceiptVerifier.new(artifact_reader: reader).verify_result!(receipt,
|
|
160
|
+
attempt: attempt, live_head: binding.fetch("head"), repo_root: @journal.repo_root)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def retained_ordinary_result(params, events, commit)
|
|
164
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary result chain is corrupt" unless Models::EvidenceEvent.chain_valid?(events)
|
|
165
|
+
records = events.select { |event| event["type"] == "result_submitted" }
|
|
166
|
+
replies = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "submit_result" }
|
|
167
|
+
return nil if records.empty? && replies.empty?
|
|
168
|
+
unless records.one? && replies.one?
|
|
169
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary result provenance is missing or duplicated"
|
|
170
|
+
end
|
|
171
|
+
payload = records.first.fetch("payload")
|
|
172
|
+
binding = payload.fetch("binding")
|
|
173
|
+
start = events.find { |event| event["type"] == "process_start" }.fetch("payload")
|
|
174
|
+
unless payload.keys.sort == %w[authority binding receipt result_id] && payload["authority"] == "ordinary" &&
|
|
175
|
+
payload["result_id"].is_a?(String) && payload["result_id"].match?(/\A[0-9a-f]{32}\z/) &&
|
|
176
|
+
binding.keys.sort == %w[assignment_id attempt_id authority checkout generation head original_receipt_digest producer project_id result_id runtime_binding uploaded_receipt_sha256 worker_uid] &&
|
|
177
|
+
binding["authority"] == "ordinary" && binding["result_id"] == payload["result_id"] &&
|
|
178
|
+
binding["head"] == binding.dig("checkout", "head") &&
|
|
179
|
+
binding.values_at("project_id", "assignment_id", "attempt_id", "runtime_binding") ==
|
|
180
|
+
[@project, params.fetch("assignment_id"), params.fetch("attempt_id"), start.fetch("runtime_binding")] &&
|
|
181
|
+
binding.fetch("producer") == start.slice("actor", "role", "runtime") &&
|
|
182
|
+
binding.fetch("worker_uid") == start.dig("runtime_binding", "process_identity", "uid") &&
|
|
183
|
+
payload.fetch("receipt").fetch("producer") == binding.fetch("producer") &&
|
|
184
|
+
replies.first.dig("payload", "data").slice(*ordinary_result_projection(payload).keys) == ordinary_result_projection(payload)
|
|
185
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary result owner differs"
|
|
186
|
+
end
|
|
187
|
+
canonical = Molecules::CanonicalEvidence.new(journal: @journal)
|
|
188
|
+
reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
|
|
189
|
+
**ordinary_result_context(binding), commit: commit) }
|
|
190
|
+
verify_ordinary_result_receipt(params, payload.fetch("receipt"), binding, commit, reader)
|
|
191
|
+
payload
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "socket_server"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Authority
|
|
8
|
+
# Explicit ordinary admission over Assign's existing wire/transfer owner.
|
|
9
|
+
# The Lab composition supplies a trusted installed admission object and
|
|
10
|
+
# closed handlers. No protected Deployment or kernel adapter is replaced.
|
|
11
|
+
class OrdinaryServer < SocketServer
|
|
12
|
+
def initialize(admission:, lifecycle:, services: [])
|
|
13
|
+
@admission = admission
|
|
14
|
+
super(service: admission.service, lifecycle: lifecycle, services: services)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def verify_listener!
|
|
20
|
+
@admission.verify_listener!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def authorize_transfer!(request:, peer:, role:, deadline:)
|
|
24
|
+
@lifecycle.authorize_transfer!(request: request, peer: peer, role: role, deadline: deadline)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dispatch_lifecycle(request:, peer:, role:, deadline:, transfer: nil)
|
|
28
|
+
@lifecycle.dispatch(request: request, peer: peer, role: role, deadline: deadline, transfer: transfer)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def receive(socket)
|
|
32
|
+
deadline = wire.deadline(30)
|
|
33
|
+
peer = @admission.peer(socket, deadline: deadline)
|
|
34
|
+
ingress = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
35
|
+
frame = wire.read(ingress, deadline: [deadline, wire.deadline(5)].min, limit: 16_384, with_size: true)
|
|
36
|
+
request = frame.fetch(:data)
|
|
37
|
+
unless request.is_a?(Hash) && request.keys.sort == %w[mutation_id operation params project_id version] &&
|
|
38
|
+
request["version"].is_a?(Integer) && request["version"] == 1 &&
|
|
39
|
+
request["operation"].is_a?(String) && request["params"].is_a?(Hash)
|
|
40
|
+
raise ArgumentError, "invalid ordinary authority envelope"
|
|
41
|
+
end
|
|
42
|
+
role = @admission.authorize!(request: request, peer: peer, deadline: deadline)
|
|
43
|
+
binding = @lifecycle.transfer_binding(request)
|
|
44
|
+
bodyless_read!(ingress, deadline) unless binding && binding.fetch(:direction) == :upload
|
|
45
|
+
# Both binary bodies and EOF use ancillary-rejecting reads. Replies
|
|
46
|
+
# use the original duplex socket through the wrapper below.
|
|
47
|
+
dispatch_request(Duplex.new(socket, ingress), request, peer, role, deadline, frame_bytes: frame.fetch(:bytesize))
|
|
48
|
+
rescue ArgumentError, KeyError, AttemptErrors::MalformedTransfer
|
|
49
|
+
refusal(socket, "invalid_input")
|
|
50
|
+
rescue AttemptErrors::UnauthorizedIdentity, SecurityError
|
|
51
|
+
refusal(socket, "unauthorized")
|
|
52
|
+
rescue AttemptErrors::Conflict
|
|
53
|
+
refusal(socket, "conflict")
|
|
54
|
+
rescue AttemptErrors::NotFound
|
|
55
|
+
refusal(socket, "missing")
|
|
56
|
+
rescue AttemptErrors::BoundedResultUnavailable
|
|
57
|
+
refusal(socket, "bounded_result")
|
|
58
|
+
rescue StandardError
|
|
59
|
+
refusal(socket, "evidence_unavailable")
|
|
60
|
+
ensure
|
|
61
|
+
socket.close unless socket.closed?
|
|
62
|
+
@mutex.synchronize { @connections.delete(socket) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class Duplex
|
|
66
|
+
def initialize(socket, ingress) = (@socket, @ingress = socket, ingress)
|
|
67
|
+
def to_io = @socket
|
|
68
|
+
def read_nonblock(*args, **options) = @ingress.read_nonblock(*args, **options)
|
|
69
|
+
def write_nonblock(*args, **options) = @socket.write_nonblock(*args, **options)
|
|
70
|
+
end
|
|
71
|
+
private_constant :Duplex
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -69,9 +69,9 @@ module Ace
|
|
|
69
69
|
# The existing transport verifies complete Git objects before this
|
|
70
70
|
# shared byte contract parses anything. Preserve the original input for
|
|
71
71
|
# the eventual registration journal; CandidateTransfer may reexport it.
|
|
72
|
-
def self.admit(bytes:, head:, tree:, sha256:, size:, root:)
|
|
72
|
+
def self.admit(bytes:, head:, tree:, sha256:, size:, root:, deadline: nil)
|
|
73
73
|
transfer = CandidateTransfer.new(root: root)
|
|
74
|
-
transfer.admit(bytes: bytes, head: head, sha256: sha256, size: size) do |repository, deadline, result|
|
|
74
|
+
transfer.admit(bytes: bytes, head: head, sha256: sha256, size: size, deadline: deadline) do |repository, deadline, result|
|
|
75
75
|
raise ArgumentError, "prepared_input_invalid: Git tree differs" unless result.fetch("tree") == tree
|
|
76
76
|
new(files: transfer.prepared_files(repository: repository, deadline: deadline, head: head))
|
|
77
77
|
end
|
|
@@ -12,7 +12,7 @@ module Ace
|
|
|
12
12
|
class ProtectedAssignmentContext
|
|
13
13
|
attr_reader :uid
|
|
14
14
|
|
|
15
|
-
def self.load
|
|
15
|
+
def self.load(uid: Process.uid)
|
|
16
16
|
paths = [Deployment::PATH, DeploymentHistory::PATH]
|
|
17
17
|
installed = paths.any? do |path|
|
|
18
18
|
begin
|
|
@@ -22,13 +22,13 @@ module Ace
|
|
|
22
22
|
false
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
|
-
return new(deployment: nil, history: nil) unless installed
|
|
25
|
+
return new(deployment: nil, history: nil, uid: uid) unless installed
|
|
26
26
|
deployment = Deployment.load
|
|
27
27
|
history = DeploymentHistory.load
|
|
28
28
|
unless history.selects?(deployment)
|
|
29
29
|
raise AttemptErrors::EvidenceUnavailable, "installed protected descriptor is not retained"
|
|
30
30
|
end
|
|
31
|
-
new(deployment: deployment, history: history)
|
|
31
|
+
new(deployment: deployment, history: history, uid: uid)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def initialize(deployment:, history:, uid: Process.uid, kernel: nil, env: ENV,
|
|
@@ -4,10 +4,10 @@ module Ace
|
|
|
4
4
|
module Authority
|
|
5
5
|
# Source-owned handlers share one listener. Requests never name a method.
|
|
6
6
|
class Router
|
|
7
|
-
def initialize(launch
|
|
7
|
+
def initialize(launch: nil, handlers: [])
|
|
8
8
|
@launch = launch
|
|
9
9
|
@routes = {}
|
|
10
|
-
([launch] + handlers).each do |handler|
|
|
10
|
+
([launch].compact + handlers).each do |handler|
|
|
11
11
|
handler.class::OPERATIONS.each do |operation|
|
|
12
12
|
raise ArgumentError, "duplicate authority operation" if @routes.key?(operation)
|
|
13
13
|
@routes[operation] = handler
|
|
@@ -16,11 +16,12 @@ module Ace
|
|
|
16
16
|
@routes.freeze
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def dispatch(request:, peer:, role:, transfer: nil)
|
|
19
|
+
def dispatch(request:, peer:, role:, transfer: nil, deadline: nil)
|
|
20
20
|
validate_review_namespace!(request)
|
|
21
21
|
handler = @routes.fetch(request.fetch("operation")) { raise ArgumentError, "unknown authority operation" }
|
|
22
22
|
options = {request: request, peer: peer, role: role}
|
|
23
23
|
options[:transfer] = transfer if transfer
|
|
24
|
+
options[:deadline] = deadline if deadline
|
|
24
25
|
handler.dispatch(**options)
|
|
25
26
|
end
|
|
26
27
|
|
|
@@ -42,10 +43,12 @@ module Ace
|
|
|
42
43
|
binding
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
def authorize_transfer!(request:, peer:, role:)
|
|
46
|
+
def authorize_transfer!(request:, peer:, role:, deadline: nil)
|
|
46
47
|
validate_review_namespace!(request)
|
|
47
48
|
handler = @routes.fetch(request.fetch("operation"))
|
|
48
|
-
|
|
49
|
+
options = {request: request, peer: peer, role: role}
|
|
50
|
+
options[:deadline] = deadline if deadline
|
|
51
|
+
handler.authorize_transfer!(**options)
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def validate_review_namespace!(request)
|
|
@@ -60,14 +63,16 @@ module Ace
|
|
|
60
63
|
private :validate_review_namespace!
|
|
61
64
|
|
|
62
65
|
def close
|
|
63
|
-
@
|
|
66
|
+
@routes.values.uniq.each { |handler| handler.close if handler.respond_to?(:close) }
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
def serve_launch_control!(**options)
|
|
70
|
+
raise AttemptErrors::EvidenceUnavailable, "launch control is unavailable in this composition" unless @launch
|
|
67
71
|
@launch.serve_launch_control!(**options)
|
|
68
72
|
end
|
|
69
73
|
|
|
70
74
|
def gate_ready(**options)
|
|
75
|
+
raise AttemptErrors::EvidenceUnavailable, "launch gate is unavailable in this composition" unless @launch
|
|
71
76
|
@launch.gate_ready(**options)
|
|
72
77
|
end
|
|
73
78
|
end
|