ace-lab 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/docs/usage.md +188 -0
- data/lib/ace/lab/cli/commands/assignment.rb +48 -1
- data/lib/ace/lab/cli/commands/command_execute.rb +28 -0
- data/lib/ace/lab/cli/commands/run.rb +65 -0
- data/lib/ace/lab/cli.rb +10 -0
- data/lib/ace/lab/molecules/command_owner_admission.rb +183 -0
- data/lib/ace/lab/molecules/command_policy.rb +158 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +15 -0
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +13 -0
- data/lib/ace/lab/organisms/command_executor.rb +220 -0
- data/lib/ace/lab/organisms/command_run_client.rb +131 -0
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +12 -41
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +35 -5
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +9 -18
- data/lib/ace/lab/version.rb +1 -1
- metadata +7 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require "ace/assign/authority/retained_input"
|
|
4
5
|
require "ace/assign/authority/ordinary_client"
|
|
5
6
|
require "ace/assign/authority/ordinary_lifecycle"
|
|
6
7
|
require "ace/assign/organisms/prepared_work_builder"
|
|
@@ -13,6 +14,7 @@ module Ace
|
|
|
13
14
|
# Only Assign's installed owner registers, advances and accepts execution.
|
|
14
15
|
class OrdinaryAssignmentClient
|
|
15
16
|
A = Ace::Assign::Authority
|
|
17
|
+
Files = A::RetainedInput
|
|
16
18
|
REQUEST_KEYS = %w[version project_id operation mutation_id params parts].freeze
|
|
17
19
|
|
|
18
20
|
def initialize(project:, config: nil, client_factory: nil)
|
|
@@ -31,7 +33,7 @@ module Ace
|
|
|
31
33
|
pane.is_a?(String) && Molecules::OrdinaryOwnerConfig::PANE.match?(pane)
|
|
32
34
|
raise ArgumentError, "preparation requires project and native pane selectors"
|
|
33
35
|
end
|
|
34
|
-
directory = create_output(output)
|
|
36
|
+
directory = Files.create_output(output)
|
|
35
37
|
prepared = Ace::Assign::Organisms::PreparedWorkBuilder.new(export_root: directory)
|
|
36
38
|
.call(task_ref: task, project_id: project, dependency_reports: dependency_reports)
|
|
37
39
|
definition = JSON.parse(prepared.fetch("definition_bytes"))
|
|
@@ -43,22 +45,22 @@ module Ace
|
|
|
43
45
|
"prepared_head" => prepared.fetch("head"), "prepared_tree" => prepared.fetch("tree"),
|
|
44
46
|
"manifest_sha256" => reference.fetch("manifest_sha256")},
|
|
45
47
|
"parts" => [{"file" => "prepared.bundle", "bytes" => prepared.fetch("bytes"), "sha256" => prepared.fetch("sha256")}]}
|
|
46
|
-
write_file(directory, "definition.json", prepared.fetch("definition_bytes"))
|
|
47
|
-
write_file(directory, "prepared.bundle", prepared.fetch("bundle"))
|
|
48
|
-
write_file(directory, "request.json", JSON.generate(request) + "\n")
|
|
48
|
+
Files.write_file(directory, "definition.json", prepared.fetch("definition_bytes"))
|
|
49
|
+
Files.write_file(directory, "prepared.bundle", prepared.fetch("bundle"))
|
|
50
|
+
Files.write_file(directory, "request.json", JSON.generate(request) + "\n")
|
|
49
51
|
{"project_id" => project, "assignment_id" => prepared.fetch("assignment_id"),
|
|
50
52
|
"scope" => prepared.fetch("scope"), "request" => File.join(directory, "request.json"), "prepared" => true}
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
def request(input:, output: nil)
|
|
54
|
-
request = JSON.parse(
|
|
56
|
+
request = JSON.parse(Files.read_file(input, 16_384), allow_duplicate_key: false, create_additions: false, max_nesting: 24)
|
|
55
57
|
unless request.is_a?(Hash) && request.keys.sort == REQUEST_KEYS.sort && request["version"].is_a?(Integer) && request["version"] == 1 &&
|
|
56
58
|
request["project_id"] == @project && A::OrdinaryLifecycle::OPERATIONS.include?(request["operation"]) &&
|
|
57
59
|
request["params"].is_a?(Hash) && !request["params"].key?("transfer") && request["parts"].is_a?(Array)
|
|
58
60
|
raise ArgumentError, "retained ordinary request selection is invalid"
|
|
59
61
|
end
|
|
60
62
|
operation = request.fetch("operation")
|
|
61
|
-
read = %w[registration_status assignment_snapshot result_status result_snapshot].include?(operation)
|
|
63
|
+
read = %w[registration_status assignment_snapshot result_status result_snapshot supervisor_status export_candidate review_status merge_evidence].include?(operation)
|
|
62
64
|
mutation = request.fetch("mutation_id")
|
|
63
65
|
unless read ? mutation.nil? : mutation.is_a?(String) && Ace::Assign::Molecules::JournalMutation::ID.match?(mutation)
|
|
64
66
|
raise ArgumentError, "retained mutation ID must be explicit; read operations require null"
|
|
@@ -72,7 +74,7 @@ module Ace
|
|
|
72
74
|
parts = upload && read_parts(request.fetch("parts"), input, transfer.fetch(:purpose))
|
|
73
75
|
# No local metadata is interpreted as successful execution. Download
|
|
74
76
|
# files are merely a projection of the authenticated canonical reply.
|
|
75
|
-
directory = download &&
|
|
77
|
+
directory = download && Files.create_output(output)
|
|
76
78
|
client = @client_factory.call(project_id: @project, service: @config.service,
|
|
77
79
|
verify_selection: @config.method(:verify_owner!), scratch_root: directory || nil)
|
|
78
80
|
reply = client.call(operation, request.fetch("params"), mutation_id: request.fetch("mutation_id"),
|
|
@@ -81,48 +83,17 @@ module Ace
|
|
|
81
83
|
if download
|
|
82
84
|
files = reply.parts.each_with_index.map do |bytes, index|
|
|
83
85
|
name = format("part-%02d.bytes", index)
|
|
84
|
-
|
|
86
|
+
Files.write_file(directory, name, bytes)
|
|
85
87
|
{"file" => File.join(directory, name), "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)}
|
|
86
88
|
end
|
|
87
89
|
envelope["files"] = files
|
|
88
|
-
|
|
90
|
+
Files.write_file(directory, "response.json", JSON.generate(envelope) + "\n")
|
|
89
91
|
end
|
|
90
92
|
envelope
|
|
91
93
|
rescue JSON::ParserError
|
|
92
94
|
raise ArgumentError, "retained request is not valid bounded JSON"
|
|
93
95
|
end
|
|
94
96
|
|
|
95
|
-
def self.read_file(path, limit)
|
|
96
|
-
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
97
|
-
before = file.stat
|
|
98
|
-
raise ArgumentError, "retained input is not a bounded regular file" unless before.file? && before.size.between?(0, limit)
|
|
99
|
-
bytes = (file.read(limit + 1) || "").b
|
|
100
|
-
after, selected = file.stat, File.lstat(path)
|
|
101
|
-
identity = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
|
|
102
|
-
unless bytes.bytesize == before.size && identity.call(before) == identity.call(after) &&
|
|
103
|
-
identity.call(after) == identity.call(selected) && !selected.symlink?
|
|
104
|
-
raise ArgumentError, "retained input changed during read"
|
|
105
|
-
end
|
|
106
|
-
bytes.freeze
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def self.create_output(path)
|
|
111
|
-
raise ArgumentError, "output must be a new directory path" unless path.is_a?(String) && !path.empty?
|
|
112
|
-
directory = File.expand_path(path)
|
|
113
|
-
Dir.mkdir(directory, 0o700)
|
|
114
|
-
A::PrivateDirectory.verify!(directory)
|
|
115
|
-
directory
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def self.write_file(directory, name, bytes)
|
|
119
|
-
File.open(File.join(directory, name), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
120
|
-
file.write(bytes)
|
|
121
|
-
file.flush
|
|
122
|
-
file.fsync
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
97
|
private
|
|
127
98
|
|
|
128
99
|
def read_parts(entries, input, purpose)
|
|
@@ -136,7 +107,7 @@ module Ace
|
|
|
136
107
|
raise ArgumentError, "retained upload inventory is invalid"
|
|
137
108
|
end
|
|
138
109
|
entries.map do |entry|
|
|
139
|
-
bytes =
|
|
110
|
+
bytes = Files.read_file(File.expand_path(entry.fetch("file"), File.dirname(File.expand_path(input))), per_part)
|
|
140
111
|
unless bytes.bytesize == entry.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == entry.fetch("sha256")
|
|
141
112
|
raise ArgumentError, "retained upload bytes changed; preserve the original mutation"
|
|
142
113
|
end
|
|
@@ -8,6 +8,8 @@ require "ace/hitl/lifecycle/service"
|
|
|
8
8
|
require "ace/hitl/lifecycle/tls_authority"
|
|
9
9
|
require "ace/hitl/lifecycle/tls_service"
|
|
10
10
|
require_relative "../molecules/ordinary_owner_admission"
|
|
11
|
+
require_relative "../molecules/caller_authorizer"
|
|
12
|
+
require_relative "../molecules/command_owner_admission"
|
|
11
13
|
require_relative "../molecules/ordinary_listener"
|
|
12
14
|
require_relative "ordinary_merge_receiver"
|
|
13
15
|
|
|
@@ -40,12 +42,18 @@ module Ace
|
|
|
40
42
|
evidence_reader: ->(reference, record, state, pending) { reader.call(reference, record, state, pending) })
|
|
41
43
|
reader = Ace::Assign::Authority::ServiceEvidence.new(journal: journal)
|
|
42
44
|
exclusion = Ace::Assign::Molecules::LifecycleExclusion.new(root: @config.lifecycle_root)
|
|
45
|
+
command_admission = Molecules::CommandOwnerAdmission.new(base: admission, project: @config.project)
|
|
46
|
+
command = Ace::Assign::Authority::CommandExecution.new(project: @config.project, journal: journal,
|
|
47
|
+
admission: command_admission)
|
|
48
|
+
coordinator = nil
|
|
49
|
+
lifecycle = Ace::Assign::Authority::OrdinaryLifecycle.new(project: @config.project,
|
|
50
|
+
admission: admission, journal: journal, exclusion: exclusion, input_root: @config.assignment_root,
|
|
51
|
+
command_evidence: command,
|
|
52
|
+
release_authorizer: ->(binding) { authorize_release_artifact!(binding, coordinator) })
|
|
43
53
|
coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(cache_base: @config.assignment_root,
|
|
44
54
|
repo_root: @config.journal.fetch("repository"), journal: journal, lifecycle_exclusion: exclusion,
|
|
45
|
-
recorded_binding_observer: admission.method(:recorded_binding!))
|
|
46
|
-
|
|
47
|
-
admission: admission, journal: journal, exclusion: exclusion, input_root: @config.assignment_root)
|
|
48
|
-
router = Ace::Assign::Authority::Router.new(handlers: [lifecycle])
|
|
55
|
+
recorded_binding_observer: admission.method(:recorded_binding!), ordinary_merge_evidence: lifecycle)
|
|
56
|
+
router = Ace::Assign::Authority::Router.new(handlers: [lifecycle, command])
|
|
49
57
|
merge = OrdinaryMergeReceiver.new(config: @receiver, coordinator: coordinator,
|
|
50
58
|
policy_document: policy_document, owner_guard: -> { @config.selected_principal!(@receiver.principal_uid) },
|
|
51
59
|
merge_factory: ->(document) {
|
|
@@ -55,12 +63,34 @@ module Ace
|
|
|
55
63
|
listener = Molecules::OrdinaryListener.new(socket_path: @receiver.socket_path,
|
|
56
64
|
peer_uid: @receiver.principal_uid, socket_gid: @receiver.socket_gid, handler: merge,
|
|
57
65
|
logger: ->(message) { warn message })
|
|
58
|
-
Ace::Assign::Authority::OrdinaryServer.new(admission:
|
|
66
|
+
Ace::Assign::Authority::OrdinaryServer.new(admission: command_admission, lifecycle: router,
|
|
59
67
|
services: hitl_services(coordinator) + [listener])
|
|
60
68
|
end
|
|
61
69
|
|
|
62
70
|
private
|
|
63
71
|
|
|
72
|
+
# Retention reads the existing exact release decision. It neither
|
|
73
|
+
# consumes an authorization nor dispatches a publication effect.
|
|
74
|
+
def authorize_release_artifact!(binding, coordinator)
|
|
75
|
+
@config.verify_owner!
|
|
76
|
+
document = Molecules::GrantResolver.trusted_document(@receiver.policy_path)
|
|
77
|
+
verify_join!(document)
|
|
78
|
+
uid = binding.fetch("caller_uid")
|
|
79
|
+
unless uid.is_a?(Integer) && uid.positive? && binding.fetch("project_id") == @config.project
|
|
80
|
+
raise SecurityError, "release artifact caller or project differs"
|
|
81
|
+
end
|
|
82
|
+
username = begin
|
|
83
|
+
Etc.getpwuid(uid)&.name
|
|
84
|
+
rescue ArgumentError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
visibility = Molecules::CallerAuthorizer.new(principals: document.fetch("principals", {}),
|
|
88
|
+
identity: Molecules::CallerAuthorizer.matchable_identities(username, uid))
|
|
89
|
+
raise SecurityError, "release artifact project grant is unavailable" unless visibility.authorized?(@config.project)
|
|
90
|
+
Molecules::ServicePolicy.new(document, coordinator.method(:proposal_authorization))
|
|
91
|
+
.authorize!(binding.fetch("authorization"), binding)
|
|
92
|
+
end
|
|
93
|
+
|
|
64
94
|
def verify_join!(document)
|
|
65
95
|
configured = Molecules::ServicePolicy.new(document).ordinary_receiver!(@config.project)
|
|
66
96
|
expected = {"receiver_id" => @receiver.receiver_id, "socket_path" => @receiver.socket_path,
|
|
@@ -140,11 +140,9 @@ module Ace
|
|
|
140
140
|
unless digest == submission.fetch("input_digest")
|
|
141
141
|
invalid!("canonical request digest does not re-derive from the submission", request_id)
|
|
142
142
|
end
|
|
143
|
-
#
|
|
144
|
-
#
|
|
145
|
-
# handed-over review must be independent of the executor.
|
|
143
|
+
# Forge selection precedes authorization. Reviewer independence is
|
|
144
|
+
# resolved by the canonical owner from native starts, not actor names.
|
|
146
145
|
forge_identity!(document, request_id)
|
|
147
|
-
reviewer_independence!(document, request_id)
|
|
148
146
|
decision = @resolver.decide(submission.fetch("authorization"), authorization_binding(submission))
|
|
149
147
|
unless decision.authorized?
|
|
150
148
|
return OrdinaryMergeRequest.refusal_envelope(decision.code, decision.message,
|
|
@@ -190,7 +188,12 @@ module Ace
|
|
|
190
188
|
settle_no_effect(request_id, record, "canonical proposal is no longer authorized before effect")
|
|
191
189
|
return :refused
|
|
192
190
|
end
|
|
193
|
-
|
|
191
|
+
begin
|
|
192
|
+
@coordinator.with_fixed_merge_evidence(record) { execute_effect(record) }
|
|
193
|
+
rescue Ace::Assign::AttemptErrors::ReceiptRejected
|
|
194
|
+
settle_no_effect(request_id, record, "canonical review or executed test proof changed before effect")
|
|
195
|
+
return :refused
|
|
196
|
+
end
|
|
194
197
|
:executed
|
|
195
198
|
rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::NotFound,
|
|
196
199
|
Ace::Assign::AttemptErrors::Conflict
|
|
@@ -378,6 +381,7 @@ module Ace
|
|
|
378
381
|
# immediately before each privileged effect. No cached decision is a lease.
|
|
379
382
|
def current_authorization_refusal(record)
|
|
380
383
|
@owner_guard&.call
|
|
384
|
+
@coordinator.verify_fixed_merge_evidence!(record)
|
|
381
385
|
decision = @resolver.decide(record.fetch("authorization"), authorization_binding(record))
|
|
382
386
|
decision.message unless decision.authorized?
|
|
383
387
|
rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error => e
|
|
@@ -645,19 +649,6 @@ module Ace
|
|
|
645
649
|
{"owner" => match[1], "repo" => match[2], "index" => match[3].to_i}
|
|
646
650
|
end
|
|
647
651
|
|
|
648
|
-
# The independent final-head review is handed over in the request;
|
|
649
|
-
# the executor's own identity is never an independent reviewer.
|
|
650
|
-
# Evidence naming the receiver or its principal refuses pre-effect —
|
|
651
|
-
# it can never be attached as independent review and never grounds
|
|
652
|
-
# the merge.
|
|
653
|
-
def reviewer_independence!(document, request_id)
|
|
654
|
-
reviewer = document.dig("review_evidence", "reviewer")
|
|
655
|
-
return unless [config.receiver_id, config.principal].include?(reviewer)
|
|
656
|
-
raise OrdinaryMergeRefusal.new("ordinary_merge_evidence_invalid",
|
|
657
|
-
"review evidence names the receiver executor, not an independent reviewer",
|
|
658
|
-
request_id: request_id)
|
|
659
|
-
end
|
|
660
|
-
|
|
661
652
|
def selection_refusal!(message, request_id)
|
|
662
653
|
raise OrdinaryMergeRefusal.new("ordinary_merge_selection_mismatch", message,
|
|
663
654
|
request_id: request_id)
|
data/lib/ace/lab/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-lab
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
@@ -234,9 +234,11 @@ files:
|
|
|
234
234
|
- lib/ace/lab/cli.rb
|
|
235
235
|
- lib/ace/lab/cli/commands/agents.rb
|
|
236
236
|
- lib/ace/lab/cli/commands/assignment.rb
|
|
237
|
+
- lib/ace/lab/cli/commands/command_execute.rb
|
|
237
238
|
- lib/ace/lab/cli/commands/projects.rb
|
|
238
239
|
- lib/ace/lab/cli/commands/resolve.rb
|
|
239
240
|
- lib/ace/lab/cli/commands/route.rb
|
|
241
|
+
- lib/ace/lab/cli/commands/run.rb
|
|
240
242
|
- lib/ace/lab/cli/commands/service.rb
|
|
241
243
|
- lib/ace/lab/cli/commands/services.rb
|
|
242
244
|
- lib/ace/lab/cli/commands/support.rb
|
|
@@ -247,6 +249,8 @@ files:
|
|
|
247
249
|
- lib/ace/lab/models/topology_entry.rb
|
|
248
250
|
- lib/ace/lab/molecules/caller_authorizer.rb
|
|
249
251
|
- lib/ace/lab/molecules/capability_router.rb
|
|
252
|
+
- lib/ace/lab/molecules/command_owner_admission.rb
|
|
253
|
+
- lib/ace/lab/molecules/command_policy.rb
|
|
250
254
|
- lib/ace/lab/molecules/exact_resolver.rb
|
|
251
255
|
- lib/ace/lab/molecules/forge_merge_config.rb
|
|
252
256
|
- lib/ace/lab/molecules/forgejo_merge_adapter.rb
|
|
@@ -272,6 +276,8 @@ files:
|
|
|
272
276
|
- lib/ace/lab/molecules/service_policy.rb
|
|
273
277
|
- lib/ace/lab/molecules/topology_loader.rb
|
|
274
278
|
- lib/ace/lab/organisms/authority_composition.rb
|
|
279
|
+
- lib/ace/lab/organisms/command_executor.rb
|
|
280
|
+
- lib/ace/lab/organisms/command_run_client.rb
|
|
275
281
|
- lib/ace/lab/organisms/ordinary_assignment_client.rb
|
|
276
282
|
- lib/ace/lab/organisms/ordinary_authority_composition.rb
|
|
277
283
|
- lib/ace/lab/organisms/ordinary_merge_receiver.rb
|