ace-lab 0.5.1 → 0.6.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/lib/ace/lab/cli/commands/service.rb +67 -4
- data/lib/ace/lab/molecules/ordinary_socket.rb +129 -0
- data/lib/ace/lab/molecules/service_policy.rb +71 -0
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +422 -0
- data/lib/ace/lab/version.rb +1 -1
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ad491380d732534c8ae2cbabe68bb796cf997adb19ff6dbdb4b6b66353470c6
|
|
4
|
+
data.tar.gz: c1ba284a9cf628746450b50e1ecf6dcc03589a771fc6f1d44c2eded6afa24861
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d6c8605dabf1a3f4bdac9f39a8afeac4c606c30832380f4b93921fc91d9bcd3ac364d1056fbb426130d25bf23410abd29cbd1dd80ec31ad99a563fe224f2c3bc
|
|
7
|
+
data.tar.gz: d8ca70d0072169a318b7bf2cc57d291f6d9db14250c5af774920e0fb212735570ede41fdc360c1e099f8a41d4de43355bd1e7e6a7aca8a86fef8a21289ba04de
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-10-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add the ordinary fixed merge producer mode (`ace-lab service request/status --mode ordinary-merge`) per xz9.4: durable-once admission that issues the opaque reference only through the committed journal record, canonical SHA-256 request digests over the full eight-field binding, identical-replay acknowledgment, authorization/binding conflict refusals, read-only status, and terminal receipts bound to the admitted record and its execution identity.
|
|
15
|
+
|
|
16
|
+
## [0.5.2] - 2026-10-11
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Follow the `ace-hitl` 0.14 lifecycle boundary line with dedicated per-authority socket-group configuration.
|
|
21
|
+
|
|
10
22
|
## [0.5.1] - 2026-10-10
|
|
11
23
|
|
|
12
24
|
### Fixed
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "ace/support/cli"
|
|
5
|
+
require "ace/assign"
|
|
5
6
|
require_relative "support"
|
|
6
7
|
require_relative "../../organisms/protected_service_request"
|
|
8
|
+
require_relative "../../organisms/ordinary_merge_request"
|
|
7
9
|
|
|
8
10
|
module Ace
|
|
9
11
|
module Lab
|
|
@@ -20,7 +22,7 @@ module Ace
|
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
def protected_refusal(error)
|
|
23
|
-
code = error.is_a?(ArgumentError) || error.is_a?(SecurityError) ? "protected_service_refused" : "protected_service_unavailable"
|
|
25
|
+
code = (error.is_a?(ArgumentError) || error.is_a?(SecurityError)) ? "protected_service_refused" : "protected_service_unavailable"
|
|
24
26
|
message = error.is_a?(JSON::ParserError) ? "protected installed selection unavailable" : error.message.to_s.scrub[0, 512]
|
|
25
27
|
emit({"status" => "error", "error" => {"code" => code, "message" => message}})
|
|
26
28
|
end
|
|
@@ -49,6 +51,8 @@ module Ace
|
|
|
49
51
|
option :authorization, type: :string, required: true, desc: "Exact authorization reference"
|
|
50
52
|
option :request_id, type: :string, required: true, desc: "Idempotency key"
|
|
51
53
|
option :dry_run, type: :boolean, desc: "Validate without claiming or executing"
|
|
54
|
+
option :mode, type: :string, desc: "ordinary-merge selects the ordinary fixed merge producer"
|
|
55
|
+
option :queue_entry, type: :string, desc: "Original queue entry (ordinary merge)"
|
|
52
56
|
|
|
53
57
|
option :mapping, type: :string, desc: "Original protected mapping ID"
|
|
54
58
|
option :scope, type: :string, desc: "Original hierarchical prepared scope"
|
|
@@ -59,6 +63,26 @@ module Ace
|
|
|
59
63
|
|
|
60
64
|
def call(project:, assignment:, attempt:, operation:, input:, authorization:, request_id:, **options)
|
|
61
65
|
reject_identity_flags!(options)
|
|
66
|
+
mode = options[:mode]
|
|
67
|
+
if mode && mode != Organisms::OrdinaryMergeRequest::MODE
|
|
68
|
+
return emit(Organisms::OrdinaryMergeRequest.refusal_envelope("ordinary_merge_invalid_request",
|
|
69
|
+
"unknown service mode", request_id: request_id, input_digest: nil))
|
|
70
|
+
end
|
|
71
|
+
if mode
|
|
72
|
+
begin
|
|
73
|
+
return emit(ordinary_request(project: project, assignment: assignment, attempt: attempt,
|
|
74
|
+
queue_entry: options[:queue_entry], operation: operation, input: input,
|
|
75
|
+
authorization: authorization, request_id: request_id, **options))
|
|
76
|
+
rescue Ace::Assign::AttemptErrors::EvidenceUnavailable, Ace::Lab::InvalidConfigurationError => e
|
|
77
|
+
# Infra failures (unreadable canonical evidence, malformed
|
|
78
|
+
# installed configuration, unavailable topology) are the
|
|
79
|
+
# caller-side blocker, never a raw crash and never a durable
|
|
80
|
+
# reference (review r1 finding B).
|
|
81
|
+
return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
|
|
82
|
+
"ordinary_merge_unavailable", e.message, request_id: request_id, input_digest: nil
|
|
83
|
+
))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
62
86
|
selected = protected_service.selected?(options)
|
|
63
87
|
if selected
|
|
64
88
|
return emit(protected_service.request(project: project, assignment: assignment, attempt: attempt,
|
|
@@ -68,9 +92,21 @@ module Ace
|
|
|
68
92
|
operation: operation, input_path: input, authorization: authorization,
|
|
69
93
|
request_id: request_id, dry_run: options[:dry_run]))
|
|
70
94
|
rescue ArgumentError, Ace::Assign::Error, Ace::Git::Error, Ace::Runtime::RuntimeUnavailableError, JSON::ParserError, SecurityError, KeyError, SystemCallError => error
|
|
71
|
-
raise if selected == false
|
|
95
|
+
raise if selected == false || mode
|
|
72
96
|
protected_refusal(error)
|
|
73
97
|
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def ordinary_producer
|
|
102
|
+
@ordinary_producer ||= Organisms::OrdinaryMergeRequest.new
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The ordinary refusal envelope is emitted exactly as received;
|
|
106
|
+
# it is classified for the non-zero exit like every refusal.
|
|
107
|
+
def ordinary_request(**arguments)
|
|
108
|
+
ordinary_producer.request(**arguments)
|
|
109
|
+
end
|
|
74
110
|
end
|
|
75
111
|
|
|
76
112
|
class Status < Ace::Support::Cli::Command
|
|
@@ -81,6 +117,9 @@ module Ace
|
|
|
81
117
|
desc "Read the authoritative outcome of a service request"
|
|
82
118
|
option :request, type: :string, required: true, desc: "Request ID"
|
|
83
119
|
option :format, type: :string, desc: "Output format (json only)"
|
|
120
|
+
option :mode, type: :string, desc: "ordinary-merge selects the read-only ordinary status"
|
|
121
|
+
option :reference, type: :string, desc: "Opaque ordinary merge reference"
|
|
122
|
+
option :queue_entry, type: :string, desc: "Original queue entry (ordinary merge)"
|
|
84
123
|
|
|
85
124
|
option :project, type: :string, desc: "Original project ID"
|
|
86
125
|
option :assignment, type: :string, desc: "Original assignment ID"
|
|
@@ -95,16 +134,40 @@ module Ace
|
|
|
95
134
|
|
|
96
135
|
def call(request:, **options)
|
|
97
136
|
reject_identity_flags!(options)
|
|
98
|
-
|
|
137
|
+
mode = options[:mode]
|
|
138
|
+
if mode && mode != Organisms::OrdinaryMergeRequest::MODE
|
|
139
|
+
return emit(Organisms::OrdinaryMergeRequest.refusal_envelope("ordinary_merge_invalid_request",
|
|
140
|
+
"unknown service mode", request_id: request, input_digest: nil,
|
|
141
|
+
schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID))
|
|
142
|
+
end
|
|
143
|
+
if mode
|
|
144
|
+
ensure_json_format!(options)
|
|
145
|
+
begin
|
|
146
|
+
return emit(ordinary_producer.status(project: options[:project], assignment: options[:assignment],
|
|
147
|
+
attempt: options[:attempt], queue_entry: options[:queue_entry], reference: options[:reference],
|
|
148
|
+
request: request, input_digest: options[:input_digest], **options))
|
|
149
|
+
rescue Ace::Assign::AttemptErrors::EvidenceUnavailable, Ace::Lab::InvalidConfigurationError => e
|
|
150
|
+
return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
|
|
151
|
+
"ordinary_merge_unavailable", e.message, request_id: request, input_digest: nil,
|
|
152
|
+
schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID
|
|
153
|
+
))
|
|
154
|
+
end
|
|
155
|
+
end
|
|
99
156
|
selected = protected_service.selected?(options)
|
|
100
157
|
if selected
|
|
101
158
|
return emit(protected_service.status(request_id: request, **options))
|
|
102
159
|
end
|
|
103
160
|
emit(service.status(request_id: request))
|
|
104
161
|
rescue ArgumentError, Ace::Assign::Error, Ace::Git::Error, Ace::Runtime::RuntimeUnavailableError, JSON::ParserError, SecurityError, KeyError, SystemCallError => error
|
|
105
|
-
raise if selected == false
|
|
162
|
+
raise if selected == false || mode
|
|
106
163
|
protected_refusal(error)
|
|
107
164
|
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def ordinary_producer
|
|
169
|
+
@ordinary_producer ||= Organisms::OrdinaryMergeRequest.new
|
|
170
|
+
end
|
|
108
171
|
end
|
|
109
172
|
end
|
|
110
173
|
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Lab
|
|
8
|
+
module Molecules
|
|
9
|
+
# Admission transport between the ordinary fixed merge producer and the
|
|
10
|
+
# installed ordinary receiver: one bounded JSON document each way over
|
|
11
|
+
# a configured unix stream socket with a fixed deadline. Authentication
|
|
12
|
+
# is mutual through SO_PEERCRED — the producer verifies the receiver's
|
|
13
|
+
# configured UID and transmits the verified local caller identity, so
|
|
14
|
+
# no caller-selected credential, token or endpoint exists. This is not
|
|
15
|
+
# an HTTP transport; a lost or unauthenticated response is a
|
|
16
|
+
# caller-side blocker (ordinary_merge_unavailable), never a refusal.
|
|
17
|
+
class OrdinarySocket
|
|
18
|
+
LIMIT = 16_384
|
|
19
|
+
HANDSHAKE = "ace-lab/ordinary-merge/v1"
|
|
20
|
+
|
|
21
|
+
# @param socket_path [String] installed receiver endpoint (configuration)
|
|
22
|
+
# @param receiver_uid [Integer] the only UID the receiver may run as
|
|
23
|
+
# @param deadline [Time] absolute deadline for connect/write/read
|
|
24
|
+
def initialize(socket_path:, receiver_uid:, deadline: Time.now.utc + 30)
|
|
25
|
+
@socket_path = socket_path
|
|
26
|
+
@receiver_uid = receiver_uid
|
|
27
|
+
@deadline = deadline
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Submit one bounded request document; returns the parsed response
|
|
31
|
+
# document. Any framing, deadline or identity failure raises
|
|
32
|
+
# OrdinaryUnavailable so the producer can classify the blocker.
|
|
33
|
+
def submit(document)
|
|
34
|
+
payload = JSON.generate(document)
|
|
35
|
+
raise OrdinaryUnavailable, "request document exceeds the wire bound" if payload.bytesize > LIMIT
|
|
36
|
+
socket = Socket.new(:UNIX, :STREAM, 0)
|
|
37
|
+
begin
|
|
38
|
+
socket.connect(Addrinfo.unix(@socket_path))
|
|
39
|
+
verify_peer!(socket)
|
|
40
|
+
write_all!(socket, "#{HANDSHAKE} #{payload.bytesize}\n#{payload}")
|
|
41
|
+
response = read_response!(socket)
|
|
42
|
+
rescue OrdinaryUnavailable
|
|
43
|
+
raise
|
|
44
|
+
rescue SystemCallError, IOError => e
|
|
45
|
+
raise OrdinaryUnavailable, "receiver transport failed: #{e.class}"
|
|
46
|
+
ensure
|
|
47
|
+
begin
|
|
48
|
+
socket.close
|
|
49
|
+
rescue IOError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
response
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class OrdinaryUnavailable < ::StandardError; end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def verify_peer!(socket)
|
|
61
|
+
credentials = socket.getsockopt(:SOCKET, :PEERCRED)
|
|
62
|
+
# struct ucred is {pid, uid, gid} on Linux; the producer binds the
|
|
63
|
+
# receiver to its installed UID and nothing else.
|
|
64
|
+
_peer_pid, peer_uid, = credentials.unpack("L3")
|
|
65
|
+
return if peer_uid == @receiver_uid
|
|
66
|
+
raise OrdinaryUnavailable, "receiver endpoint identity is not the installed receiver"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def write_all!(socket, bytes)
|
|
70
|
+
written = 0
|
|
71
|
+
while written < bytes.bytesize
|
|
72
|
+
begin
|
|
73
|
+
raise OrdinaryUnavailable, "receiver write deadline expired" if expired?
|
|
74
|
+
written += socket.write_nonblock(bytes.byteslice(written..))
|
|
75
|
+
rescue IO::WaitWritable
|
|
76
|
+
socket.wait_writable(remaining!) or raise OrdinaryUnavailable, "receiver write deadline expired"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def read_response!(socket)
|
|
82
|
+
header = read_line!(socket)
|
|
83
|
+
match = /\A#{Regexp.escape(HANDSHAKE)} (\d{1,5})\n\z/o.match(header)
|
|
84
|
+
raise OrdinaryUnavailable, "receiver response header is invalid" unless match
|
|
85
|
+
length = Integer(match[1])
|
|
86
|
+
raise OrdinaryUnavailable, "receiver response exceeds the wire bound" if length > LIMIT
|
|
87
|
+
body = read_exact!(socket, length)
|
|
88
|
+
JSON.parse(body, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
raise OrdinaryUnavailable, "receiver response is not valid JSON"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def read_line!(socket)
|
|
94
|
+
line = +""
|
|
95
|
+
until line.end_with?("\n")
|
|
96
|
+
raise OrdinaryUnavailable, "receiver response header is unbounded" if line.bytesize > 128
|
|
97
|
+
line << read_exact!(socket, 1)
|
|
98
|
+
end
|
|
99
|
+
line
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def read_exact!(socket, length)
|
|
103
|
+
buffer = +""
|
|
104
|
+
while buffer.bytesize < length
|
|
105
|
+
begin
|
|
106
|
+
raise OrdinaryUnavailable, "receiver read deadline expired" if expired?
|
|
107
|
+
chunk = socket.read_nonblock(length - buffer.bytesize)
|
|
108
|
+
raise OrdinaryUnavailable, "receiver closed the connection" if chunk.nil?
|
|
109
|
+
buffer << chunk
|
|
110
|
+
rescue IO::WaitReadable
|
|
111
|
+
socket.wait_readable(remaining!) or raise OrdinaryUnavailable, "receiver read deadline expired"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
buffer
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def expired?
|
|
118
|
+
Time.now.utc >= @deadline
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def remaining!
|
|
122
|
+
left = @deadline - Time.now.utc
|
|
123
|
+
raise OrdinaryUnavailable, "receiver deadline expired" if left <= 0
|
|
124
|
+
left
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -15,6 +15,7 @@ module Ace
|
|
|
15
15
|
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
16
16
|
|
|
17
17
|
def initialize(document, proposal_resolver = nil)
|
|
18
|
+
@document = document
|
|
18
19
|
@operations = document.fetch("operations", {})
|
|
19
20
|
@authorizations = document.fetch("authorizations", {})
|
|
20
21
|
@proposal_resolver = proposal_resolver
|
|
@@ -23,6 +24,10 @@ module Ace
|
|
|
23
24
|
end
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
# The trusted installation document as loaded; the ordinary producer
|
|
28
|
+
# binds its configuration digest to these exact bytes.
|
|
29
|
+
attr_reader :document
|
|
30
|
+
|
|
26
31
|
def operation!(name, project:, service_id:)
|
|
27
32
|
raise ArgumentError, "invalid operation name" unless name.to_s.match?(NAME)
|
|
28
33
|
operation = @operations[name]
|
|
@@ -101,6 +106,72 @@ module Ace
|
|
|
101
106
|
decision
|
|
102
107
|
end
|
|
103
108
|
|
|
109
|
+
# ---- ordinary fixed merge admission policy (spec 8x3.t.xz9.4) ------
|
|
110
|
+
#
|
|
111
|
+
# The installed ordinary section is the separately configured
|
|
112
|
+
# ordinary receiver mapping and admission policy. It supplies
|
|
113
|
+
# identity and placement only: the caller selects no endpoint,
|
|
114
|
+
# credential or receiver. An absent project section or a disabled
|
|
115
|
+
# admission flag is rollback: new admission is unavailable while
|
|
116
|
+
# canonical records and read-only status stay preserved.
|
|
117
|
+
ORDINARY_SECTION_KEYS = %w[projects].freeze
|
|
118
|
+
ORDINARY_PROJECT_KEYS = %w[receiver_id socket_path receiver_uid principal admission].freeze
|
|
119
|
+
ORDINARY_AUTHORIZATION_KEYS = %w[operation project_id assignment_id attempt_id queue_entry
|
|
120
|
+
input_digest forge pr_url head_repository head_ref candidate_head base_repository
|
|
121
|
+
base_ref expected_base_head expires_at].freeze
|
|
122
|
+
# expires_at bounds the decision itself; it is never exact-matched
|
|
123
|
+
# against the request binding, which carries no expiry.
|
|
124
|
+
ORDINARY_BINDING_KEYS = ORDINARY_AUTHORIZATION_KEYS - %w[expires_at].freeze
|
|
125
|
+
|
|
126
|
+
def ordinary_receiver!(project)
|
|
127
|
+
section = @document["ordinary"]
|
|
128
|
+
return nil unless section.is_a?(Hash)
|
|
129
|
+
validate_ordinary_section!(section)
|
|
130
|
+
entry = section.fetch("projects")[project.to_s]
|
|
131
|
+
return nil unless entry.is_a?(Hash)
|
|
132
|
+
entry
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The ordinary admission decision: exact-match on every closed
|
|
136
|
+
# binding key, like authorize!, but over the ordinary request
|
|
137
|
+
# identity (forge/PR/repository/ref) instead of execution selectors.
|
|
138
|
+
def authorize_ordinary!(reference, binding)
|
|
139
|
+
raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
|
|
140
|
+
decision = @authorizations[reference]
|
|
141
|
+
raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
|
|
142
|
+
unless ORDINARY_AUTHORIZATION_KEYS.all? { |key| decision.key?(key) }
|
|
143
|
+
raise SecurityError, "authorization reference is not an ordinary merge decision"
|
|
144
|
+
end
|
|
145
|
+
unless ORDINARY_BINDING_KEYS.all? { |key| decision[key] == binding[key] }
|
|
146
|
+
raise SecurityError, "authorization does not match the exact ordinary merge request"
|
|
147
|
+
end
|
|
148
|
+
raise SecurityError, "authorization has expired" if parse_time(decision["expires_at"]) <= Time.now.utc
|
|
149
|
+
decision
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_ordinary_section!(section)
|
|
153
|
+
unless (section.keys - ORDINARY_SECTION_KEYS).empty? && section["projects"].is_a?(Hash)
|
|
154
|
+
raise Ace::Lab::InvalidConfigurationError, "ordinary section must map projects"
|
|
155
|
+
end
|
|
156
|
+
section.fetch("projects").each do |project, entry|
|
|
157
|
+
unless project.is_a?(String) && project.match?(ID) && entry.is_a?(Hash) &&
|
|
158
|
+
(entry.keys - ORDINARY_PROJECT_KEYS).empty?
|
|
159
|
+
raise Ace::Lab::InvalidConfigurationError, "ordinary project section is invalid"
|
|
160
|
+
end
|
|
161
|
+
unless entry["receiver_id"].is_a?(String) && entry["receiver_id"].match?(ID) &&
|
|
162
|
+
entry["socket_path"].is_a?(String) && entry["socket_path"].start_with?("/") &&
|
|
163
|
+
entry["receiver_uid"].is_a?(Integer) && entry["receiver_uid"] >= 0 &&
|
|
164
|
+
entry["principal"].is_a?(String) && !entry["principal"].empty?
|
|
165
|
+
raise Ace::Lab::InvalidConfigurationError, "ordinary receiver mapping is incomplete"
|
|
166
|
+
end
|
|
167
|
+
admission = entry.fetch("admission", "enabled")
|
|
168
|
+
unless %w[enabled disabled].include?(admission)
|
|
169
|
+
raise Ace::Lab::InvalidConfigurationError, "ordinary admission flag is invalid"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
true
|
|
173
|
+
end
|
|
174
|
+
|
|
104
175
|
# Inspection never renews effect permission or selects effect argv.
|
|
105
176
|
def inspection!(name, project:, service_id:, executor_uid:)
|
|
106
177
|
raise ArgumentError, "invalid inspection operation" unless name.is_a?(String) && name.match?(NAME)
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require_relative "protected_service_request"
|
|
7
|
+
require_relative "../molecules/ordinary_socket"
|
|
8
|
+
require "ace/assign/atoms/fixed_merge_schemas"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Lab
|
|
12
|
+
module Organisms
|
|
13
|
+
# The ordinary fixed merge admission producer (spec 8x3.t.xz9.4). One
|
|
14
|
+
# mode of the maintained `service request/status` vocabulary: it
|
|
15
|
+
# canonicalizes the immutable request binding, authenticates the
|
|
16
|
+
# verified project overseer against the installed ordinary mapping,
|
|
17
|
+
# resolves the named queue entry and attempt, transports the request
|
|
18
|
+
# to the configured ordinary receiver, and emits the adopted ace-lab
|
|
19
|
+
# envelopes — never a second protocol. Refusals are pre-effect and
|
|
20
|
+
# carry no durable reference; unavailability is a caller-side blocker;
|
|
21
|
+
# the canonical Assign attempt/evidence state stays the sole
|
|
22
|
+
# queue/effect record, so this class owns no ledger.
|
|
23
|
+
class OrdinaryMergeRequest
|
|
24
|
+
MODE = "ordinary-merge"
|
|
25
|
+
OPERATION = "merge"
|
|
26
|
+
|
|
27
|
+
# The closed pre-effect refusal codes (exhaustive pre-effect mapping).
|
|
28
|
+
CODES = %w[ordinary_merge_invalid_request ordinary_merge_unauthenticated
|
|
29
|
+
ordinary_merge_forbidden ordinary_merge_selection_mismatch
|
|
30
|
+
ordinary_merge_authorization_invalid ordinary_merge_provenance_mismatch
|
|
31
|
+
ordinary_merge_target_changed ordinary_merge_evidence_invalid
|
|
32
|
+
ordinary_merge_request_conflict ordinary_merge_unavailable].freeze
|
|
33
|
+
|
|
34
|
+
# Selector keys this producer accepts; anything else — including the
|
|
35
|
+
# protected mapping/scope/service/generation selectors and any
|
|
36
|
+
# executable/argv/UID/socket/evidence-path/credential/token selector
|
|
37
|
+
# — refuses before effect.
|
|
38
|
+
REQUEST_KEYS = %i[project assignment attempt queue_entry operation input authorization
|
|
39
|
+
request_id mode].freeze
|
|
40
|
+
STATUS_KEYS = %i[project assignment attempt queue_entry reference request input_digest
|
|
41
|
+
mode format].freeze
|
|
42
|
+
|
|
43
|
+
ID = Molecules::ServicePolicy::ID
|
|
44
|
+
|
|
45
|
+
def initialize(topology: nil, policy: nil, coordinator: nil, repo_root: nil, ingress: nil)
|
|
46
|
+
@topology = topology || TopologyService.from_config
|
|
47
|
+
@repo_root = repo_root || git_toplevel
|
|
48
|
+
@coordinator = coordinator ||
|
|
49
|
+
Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: @repo_root)
|
|
50
|
+
@policy = policy || Molecules::ServicePolicy.new(
|
|
51
|
+
Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
|
|
52
|
+
)
|
|
53
|
+
@ingress = ingress
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def request(project:, assignment:, attempt:, queue_entry:, operation:, input:,
|
|
57
|
+
authorization:, request_id:, **options)
|
|
58
|
+
digest = nil
|
|
59
|
+
unless (options.keys - REQUEST_KEYS).empty?
|
|
60
|
+
invalid!("unknown request selector: #{unknown(options.keys, REQUEST_KEYS)}", request_id)
|
|
61
|
+
end
|
|
62
|
+
invalid!("operation must be merge", request_id) unless operation == OPERATION
|
|
63
|
+
identifiers!(project: project, assignment: assignment, attempt: attempt,
|
|
64
|
+
queue_entry: queue_entry, request_id: request_id)
|
|
65
|
+
unless authorization.is_a?(String) && authorization.match?(ID)
|
|
66
|
+
invalid!("invalid authorization reference", request_id)
|
|
67
|
+
end
|
|
68
|
+
document = load_request_document(input, request_id)
|
|
69
|
+
validate_document!(document, request_id)
|
|
70
|
+
digest = input_digest(project: project, assignment: assignment, attempt: attempt,
|
|
71
|
+
queue_entry: queue_entry, authorization: authorization, request_id: request_id,
|
|
72
|
+
document: document)
|
|
73
|
+
principal = authenticate!(project)
|
|
74
|
+
existing = @coordinator.fixed_merge_record(request_id)
|
|
75
|
+
if existing
|
|
76
|
+
return ack_envelope(existing) if exact_replay?(existing, project: project,
|
|
77
|
+
assignment: assignment, attempt: attempt, queue_entry: queue_entry,
|
|
78
|
+
authorization: authorization, digest: digest)
|
|
79
|
+
conflict!("request ID already exists with a different binding", request_id, digest)
|
|
80
|
+
end
|
|
81
|
+
receiver = receiver_mapping!(project)
|
|
82
|
+
resolve_selection!(project: project, assignment: assignment, attempt: attempt,
|
|
83
|
+
queue_entry: queue_entry)
|
|
84
|
+
authorize!(authorization, project: project, assignment: assignment, attempt: attempt,
|
|
85
|
+
queue_entry: queue_entry, document: document, digest: digest)
|
|
86
|
+
verify_candidate_head!(document)
|
|
87
|
+
submission = {"project_id" => project, "assignment_id" => assignment,
|
|
88
|
+
"attempt_id" => attempt, "queue_entry" => queue_entry, "operation" => OPERATION,
|
|
89
|
+
"authorization" => authorization, "request_id" => request_id,
|
|
90
|
+
"input_digest" => digest, "request" => document, "principal" => principal,
|
|
91
|
+
"receiver_id" => receiver.fetch("receiver_id"),
|
|
92
|
+
"configuration_sha256" => receiver.fetch("configuration_sha256")}
|
|
93
|
+
response = deliver(receiver, submission)
|
|
94
|
+
normalize_response(response, request_id: request_id, input_digest: digest)
|
|
95
|
+
rescue OrdinaryMergeRefusal => e
|
|
96
|
+
self.class.refusal_envelope(e.code, e.detail, request_id: e.request_id || request_id,
|
|
97
|
+
input_digest: e.input_digest || digest)
|
|
98
|
+
rescue Ace::Assign::AttemptErrors::AuthorizationConflict => e
|
|
99
|
+
self.class.refusal_envelope("ordinary_merge_authorization_invalid", e.message,
|
|
100
|
+
request_id: request_id, input_digest: digest)
|
|
101
|
+
rescue Ace::Assign::AttemptErrors::Conflict => e
|
|
102
|
+
self.class.refusal_envelope("ordinary_merge_request_conflict", e.message,
|
|
103
|
+
request_id: request_id, input_digest: digest)
|
|
104
|
+
rescue Molecules::OrdinarySocket::OrdinaryUnavailable => e
|
|
105
|
+
self.class.refusal_envelope("ordinary_merge_unavailable", e.message,
|
|
106
|
+
request_id: request_id, input_digest: digest)
|
|
107
|
+
rescue Ace::Assign::AttemptErrors::EvidenceUnavailable, Ace::Lab::InvalidConfigurationError => e
|
|
108
|
+
# Canonical evidence or installed configuration is unreadable: a
|
|
109
|
+
# caller-side blocker (endpoint/configuration failed), never a
|
|
110
|
+
# refusal and never a durable reference.
|
|
111
|
+
self.class.refusal_envelope("ordinary_merge_unavailable", e.message,
|
|
112
|
+
request_id: request_id, input_digest: digest)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Read-only status: resolve the opaque reference against the canonical
|
|
116
|
+
# record with every selector; performs no dispatch, retry,
|
|
117
|
+
# authorization refresh or selector repair. Read-only status survives
|
|
118
|
+
# rollback and receiver downtime because the canonical Assign state —
|
|
119
|
+
# never a receiver — owns the record.
|
|
120
|
+
def status(project:, assignment:, attempt:, queue_entry:, reference:, request:,
|
|
121
|
+
input_digest:, **options)
|
|
122
|
+
unless (options.keys - STATUS_KEYS).empty?
|
|
123
|
+
return status_error(self.class.refusal_envelope("ordinary_merge_invalid_request",
|
|
124
|
+
"unknown status selector: #{unknown(options.keys, STATUS_KEYS)}",
|
|
125
|
+
request_id: request, input_digest: nil,
|
|
126
|
+
schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID))
|
|
127
|
+
end
|
|
128
|
+
identifiers!(project: project, assignment: assignment, attempt: attempt,
|
|
129
|
+
queue_entry: queue_entry, request_id: request)
|
|
130
|
+
unless options[:format] == "json"
|
|
131
|
+
invalid!("status requires --format json", request)
|
|
132
|
+
end
|
|
133
|
+
unless reference.is_a?(String) && Ace::Assign::Molecules::FixedEffectReference.valid?(reference)
|
|
134
|
+
invalid!("invalid reference", request)
|
|
135
|
+
end
|
|
136
|
+
unless input_digest.is_a?(String) && input_digest.match?(/\A[0-9a-f]{64}\z/)
|
|
137
|
+
invalid!("invalid input digest", request)
|
|
138
|
+
end
|
|
139
|
+
authenticate!(project)
|
|
140
|
+
record = @coordinator.fixed_merge_status(reference: reference, request_id: request,
|
|
141
|
+
project_id: project, assignment_id: assignment, attempt_id: attempt,
|
|
142
|
+
queue_entry: queue_entry, input_digest: input_digest)
|
|
143
|
+
# Unknown or mismatched references answer identically, without
|
|
144
|
+
# revealing whether another project holds the reference.
|
|
145
|
+
return status_error(selection_error) unless record
|
|
146
|
+
{"status" => "ok", "data" => {
|
|
147
|
+
"schema" => Ace::Assign::Atoms::FixedMergeSchemas::STATUS_SCHEMA_ID,
|
|
148
|
+
"state" => record.fetch("state"),
|
|
149
|
+
"reference" => record.fetch("reference"),
|
|
150
|
+
"request_id" => record.fetch("request_id"),
|
|
151
|
+
"input_digest" => record.fetch("input_digest"),
|
|
152
|
+
"receipt" => %w[pending uncertain].include?(record.fetch("state")) ? nil : record["receipt"]
|
|
153
|
+
}}
|
|
154
|
+
rescue OrdinaryMergeRefusal => e
|
|
155
|
+
status_error(self.class.refusal_envelope(e.code, e.detail, request_id: request, input_digest: nil,
|
|
156
|
+
schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID))
|
|
157
|
+
rescue Ace::Assign::AttemptErrors::EvidenceUnavailable, Ace::Lab::InvalidConfigurationError => e
|
|
158
|
+
# Canonical state or installed configuration unreadable: the status
|
|
159
|
+
# command degrades to the caller-side blocker envelope, never to a
|
|
160
|
+
# fabricated state or a raw crash.
|
|
161
|
+
status_error(self.class.refusal_envelope("ordinary_merge_unavailable", e.message,
|
|
162
|
+
request_id: request, input_digest: nil,
|
|
163
|
+
schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# ---- envelope composition (the adopted ace-lab vocabulary) ---------
|
|
167
|
+
|
|
168
|
+
def ack_envelope(record)
|
|
169
|
+
{"status" => "ok", "data" => {
|
|
170
|
+
"schema" => Ace::Assign::Atoms::FixedMergeSchemas::ACK_SCHEMA_ID,
|
|
171
|
+
"type" => "accepted",
|
|
172
|
+
"request_id" => record.fetch("request_id"),
|
|
173
|
+
"input_digest" => record.fetch("input_digest"),
|
|
174
|
+
"reference" => record.fetch("reference")
|
|
175
|
+
}}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def self.sanitize(message)
|
|
179
|
+
cleaned = message.to_s.scrub.gsub(/\s+/, " ").strip[0, 512]
|
|
180
|
+
cleaned.empty? ? "ordinary merge request refused" : cleaned
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Envelope composition is instance-independent; the CLI reuses it for
|
|
184
|
+
# mode-selection refusals before any producer exists.
|
|
185
|
+
def self.refusal_envelope(code, message, request_id:, input_digest:,
|
|
186
|
+
schema: Ace::Assign::Atoms::FixedMergeSchemas::REFUSAL_SCHEMA_ID)
|
|
187
|
+
raise ArgumentError, "unknown ordinary merge refusal code" unless CODES.include?(code)
|
|
188
|
+
echo = request_id.to_s[0, 256]
|
|
189
|
+
echo = "unknown" if echo.empty?
|
|
190
|
+
{"status" => "error", "error" => {
|
|
191
|
+
"schema" => schema,
|
|
192
|
+
"code" => code,
|
|
193
|
+
"message" => sanitize(message),
|
|
194
|
+
"request_id" => echo,
|
|
195
|
+
"input_digest" => input_digest
|
|
196
|
+
}}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
# The immutable binding digest — spec ruling r4: lowercase hex SHA-256
|
|
202
|
+
# over the canonical JSON (recursively key-sorted, compact, UTF-8) of
|
|
203
|
+
# the full eight-field binding. Computed once here at admission; the
|
|
204
|
+
# same shared implementation re-derives it for replay and receipt
|
|
205
|
+
# verification, so the pair can never diverge.
|
|
206
|
+
def input_digest(project:, assignment:, attempt:, queue_entry:, authorization:,
|
|
207
|
+
request_id:, document:)
|
|
208
|
+
Ace::Assign::Atoms::FixedMergeSchemas.canonical_request_digest(
|
|
209
|
+
project: project, assignment: assignment, attempt: attempt,
|
|
210
|
+
queue_entry: queue_entry, authorization: authorization,
|
|
211
|
+
request_id: request_id, input: document
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def validate_document!(document, request_id)
|
|
216
|
+
Ace::Assign::Atoms::FixedMergeSchemas.validate_request!(document)
|
|
217
|
+
Ace::Assign::Atoms::FixedMergeSchemas.require_request_joins!(document)
|
|
218
|
+
rescue Ace::Assign::Atoms::FixedMergeSchemas::SchemaError => e
|
|
219
|
+
invalid!(e.message, request_id)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The verified local process owner is the only caller identity; it
|
|
223
|
+
# must be the configured ordinary project overseer principal.
|
|
224
|
+
def authenticate!(project)
|
|
225
|
+
identities = Molecules::CallerAuthorizer.local_identity
|
|
226
|
+
refused("ordinary_merge_unauthenticated", "verified local identity is unavailable") if identities.empty?
|
|
227
|
+
receiver = @policy.ordinary_receiver!(project)
|
|
228
|
+
unless receiver.is_a?(Hash) && identities.include?(receiver.fetch("principal"))
|
|
229
|
+
refused("ordinary_merge_forbidden",
|
|
230
|
+
"authenticated principal is not the configured ordinary project overseer")
|
|
231
|
+
end
|
|
232
|
+
receiver.fetch("principal")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The installed ordinary section supplies identity and placement only.
|
|
236
|
+
# A disabled or absent admission mapping is rollback: a caller-side
|
|
237
|
+
# blocker that never redirects ordinary callers and never deletes the
|
|
238
|
+
# canonical records — read-only status stays fully available.
|
|
239
|
+
def receiver_mapping!(project)
|
|
240
|
+
receiver = @policy.ordinary_receiver!(project)
|
|
241
|
+
if receiver.nil? || receiver.fetch("admission", "enabled") != "enabled"
|
|
242
|
+
raise Molecules::OrdinarySocket::OrdinaryUnavailable,
|
|
243
|
+
"ordinary merge admission is not available in the installed configuration"
|
|
244
|
+
end
|
|
245
|
+
receiver.merge("configuration_sha256" => Atoms::ServiceInput.digest(@policy.document))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def resolve_selection!(project:, assignment:, attempt:, queue_entry:)
|
|
249
|
+
@coordinator.service_attempt("assignment_id" => assignment, "attempt_id" => attempt,
|
|
250
|
+
"project_id" => project)
|
|
251
|
+
rescue Ace::Assign::AttemptErrors::NotFound, Ace::Assign::AttemptErrors::ReceiptRejected,
|
|
252
|
+
Ace::Assign::AttemptErrors::InvalidState => e
|
|
253
|
+
refused("ordinary_merge_selection_mismatch", e.message)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def authorize!(authorization, project:, assignment:, attempt:, queue_entry:, document:, digest:)
|
|
257
|
+
binding = {"operation" => OPERATION, "project_id" => project,
|
|
258
|
+
"assignment_id" => assignment, "attempt_id" => attempt, "queue_entry" => queue_entry,
|
|
259
|
+
"input_digest" => digest, "forge" => document.fetch("forge"),
|
|
260
|
+
"pr_url" => document.fetch("pr_url"), "head_repository" => document.fetch("head_repository"),
|
|
261
|
+
"head_ref" => document.fetch("head_ref"), "candidate_head" => document.fetch("candidate_head"),
|
|
262
|
+
"base_repository" => document.fetch("base_repository"), "base_ref" => document.fetch("base_ref"),
|
|
263
|
+
"expected_base_head" => document.fetch("expected_base_head")}
|
|
264
|
+
@policy.authorize_ordinary!(authorization, binding)
|
|
265
|
+
rescue SecurityError => e
|
|
266
|
+
# Spec table (ruling r4): the authorization decision or reference is
|
|
267
|
+
# invalid for this request — missing, expired, revoked, out of scope,
|
|
268
|
+
# bound to another operation/project/PR/head, or not an ordinary
|
|
269
|
+
# merge decision at all. Authorization-decision mismatches NEVER use
|
|
270
|
+
# provenance_mismatch; that code is reserved for registered
|
|
271
|
+
# provenance drift verified by the receiver (and relayed verbatim).
|
|
272
|
+
refused("ordinary_merge_authorization_invalid", e.message)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def verify_candidate_head!(document)
|
|
276
|
+
unless current_head == document.fetch("candidate_head")
|
|
277
|
+
refused("ordinary_merge_target_changed", "candidate head moved from the immutable request")
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def deliver(receiver, submission)
|
|
282
|
+
ingress = @ingress || default_ingress(receiver)
|
|
283
|
+
ingress.call(submission: submission)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def default_ingress(receiver)
|
|
287
|
+
socket = Molecules::OrdinarySocket.new(socket_path: receiver.fetch("socket_path"),
|
|
288
|
+
receiver_uid: receiver.fetch("receiver_uid"))
|
|
289
|
+
->(submission:) { socket.submit(submission) }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# A valid receiver acknowledgment is relayed exactly; a valid receiver
|
|
293
|
+
# refusal is relayed with its closed code; anything else is a lost or
|
|
294
|
+
# unauthenticated response — a caller-side blocker, never a proven
|
|
295
|
+
# refusal and never a resubmission authorization.
|
|
296
|
+
def normalize_response(response, request_id:, input_digest:)
|
|
297
|
+
unless response.is_a?(Hash) && (response.keys - %w[status data error]).empty? &&
|
|
298
|
+
%w[ok error].include?(response["status"])
|
|
299
|
+
raise Molecules::OrdinarySocket::OrdinaryUnavailable, "receiver response envelope is invalid"
|
|
300
|
+
end
|
|
301
|
+
return normalize_ack(response, request_id: request_id, input_digest: input_digest) if
|
|
302
|
+
response.fetch("status") == "ok"
|
|
303
|
+
normalize_refusal(response, request_id: request_id, input_digest: input_digest)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def normalize_ack(response, request_id:, input_digest:)
|
|
307
|
+
data = response.fetch("data") { {} }
|
|
308
|
+
expected = %w[schema type request_id input_digest reference]
|
|
309
|
+
digest = data["input_digest"]
|
|
310
|
+
unless data.is_a?(Hash) && (data.keys - expected).empty? &&
|
|
311
|
+
expected.all? { |key| data[key].is_a?(String) && !data[key].empty? } &&
|
|
312
|
+
data.fetch("schema") == Ace::Assign::Atoms::FixedMergeSchemas::ACK_SCHEMA_ID &&
|
|
313
|
+
data.fetch("type") == "accepted" && data.fetch("request_id") == request_id &&
|
|
314
|
+
digest == input_digest && digest.match?(/\A[0-9a-f]{64}\z/) &&
|
|
315
|
+
Ace::Assign::Molecules::FixedEffectReference.valid?(data.fetch("reference"))
|
|
316
|
+
raise Molecules::OrdinarySocket::OrdinaryUnavailable,
|
|
317
|
+
"receiver acknowledgment is invalid or lost before durability could be confirmed"
|
|
318
|
+
end
|
|
319
|
+
{"status" => "ok", "data" => data.slice(*expected)}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def normalize_refusal(response, request_id:, input_digest:)
|
|
323
|
+
error = response.fetch("error") { {} }
|
|
324
|
+
expected = %w[schema code message request_id input_digest]
|
|
325
|
+
valid = error.is_a?(Hash) && (error.keys - expected).empty? &&
|
|
326
|
+
error.fetch("schema") == Ace::Assign::Atoms::FixedMergeSchemas::REFUSAL_SCHEMA_ID &&
|
|
327
|
+
CODES.include?(error.fetch("code")) &&
|
|
328
|
+
error.fetch("message").is_a?(String) && !error.fetch("message").empty? &&
|
|
329
|
+
error.fetch("message").length <= 512 &&
|
|
330
|
+
error.fetch("request_id") == request_id &&
|
|
331
|
+
(error.fetch("input_digest").nil? || error.fetch("input_digest") == input_digest)
|
|
332
|
+
unless valid
|
|
333
|
+
raise Molecules::OrdinarySocket::OrdinaryUnavailable, "receiver refusal envelope is invalid"
|
|
334
|
+
end
|
|
335
|
+
{"status" => "error", "error" => error.slice(*expected)}
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def selection_error
|
|
339
|
+
self.class.refusal_envelope("ordinary_merge_selection_mismatch",
|
|
340
|
+
"reference and selectors do not match a canonical ordinary merge record",
|
|
341
|
+
request_id: nil, input_digest: nil,
|
|
342
|
+
schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def status_error(envelope)
|
|
346
|
+
{"status" => "error", "error" => envelope.fetch("error")}
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def identifiers!(project:, assignment:, attempt:, queue_entry:, request_id:)
|
|
350
|
+
{"project" => project, "assignment" => assignment, "attempt" => attempt,
|
|
351
|
+
"queue_entry" => queue_entry, "request_id" => request_id}.each do |name, value|
|
|
352
|
+
invalid!("invalid #{name} ID", request_id) unless value.is_a?(String) && value.match?(ID)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def load_request_document(input, request_id)
|
|
357
|
+
Atoms::ServiceInput.load(input)
|
|
358
|
+
rescue ArgumentError => e
|
|
359
|
+
invalid!(e.message, request_id)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def unknown(keys, allowed)
|
|
363
|
+
(keys - allowed).sort.join(", ")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# A pre-effect refusal carries its closed code, sanitized message and
|
|
367
|
+
# the request binding for the wire envelope — never a reference.
|
|
368
|
+
OrdinaryMergeRefusal = Class.new(::StandardError) do
|
|
369
|
+
attr_reader :code, :detail, :request_id, :input_digest
|
|
370
|
+
|
|
371
|
+
def initialize(code, detail, request_id: nil, input_digest: nil)
|
|
372
|
+
@code = code
|
|
373
|
+
@detail = detail
|
|
374
|
+
@request_id = request_id
|
|
375
|
+
@input_digest = input_digest
|
|
376
|
+
super(detail)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def invalid!(message, request_id)
|
|
381
|
+
raise OrdinaryMergeRefusal.new("ordinary_merge_invalid_request", message, request_id: request_id)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def conflict!(message, request_id, digest)
|
|
385
|
+
raise OrdinaryMergeRefusal.new("ordinary_merge_request_conflict", message,
|
|
386
|
+
request_id: request_id, input_digest: digest)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# A replay must match every caller-supplied immutable field: command
|
|
390
|
+
# selectors, authorization, input byte meaning and canonical digest.
|
|
391
|
+
def exact_replay?(existing, project:, assignment:, attempt:, queue_entry:,
|
|
392
|
+
authorization:, digest:)
|
|
393
|
+
existing["state"] &&
|
|
394
|
+
existing.slice("project_id", "assignment_id", "attempt_id", "queue_entry",
|
|
395
|
+
"authorization", "input_digest") == {
|
|
396
|
+
"project_id" => project, "assignment_id" => assignment, "attempt_id" => attempt,
|
|
397
|
+
"queue_entry" => queue_entry, "authorization" => authorization, "input_digest" => digest
|
|
398
|
+
}
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def refused(code, message)
|
|
402
|
+
raise OrdinaryMergeRefusal.new(code, message)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# The git toplevel (not a package directory that happens to hold a
|
|
406
|
+
# Rakefile) bounds candidate-head verification for nested invocations.
|
|
407
|
+
def git_toplevel
|
|
408
|
+
out, status = Open3.capture2("git", "rev-parse", "--show-toplevel")
|
|
409
|
+
(status&.success? ? out.strip : Dir.pwd)
|
|
410
|
+
rescue Errno::ENOENT
|
|
411
|
+
Dir.pwd
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def current_head
|
|
415
|
+
out, _stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: @repo_root)
|
|
416
|
+
refused("ordinary_merge_target_changed", "candidate head is unavailable") unless status.success?
|
|
417
|
+
out.strip
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
data/lib/ace/lab/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-lab
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-14 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-runtime
|
|
@@ -71,28 +71,28 @@ dependencies:
|
|
|
71
71
|
requirements:
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: 0.
|
|
74
|
+
version: '0.68'
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: 0.
|
|
81
|
+
version: '0.68'
|
|
82
82
|
- !ruby/object:Gem::Dependency
|
|
83
83
|
name: ace-hitl
|
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
|
85
85
|
requirements:
|
|
86
86
|
- - "~>"
|
|
87
87
|
- !ruby/object:Gem::Version
|
|
88
|
-
version: 0.
|
|
88
|
+
version: '0.15'
|
|
89
89
|
type: :runtime
|
|
90
90
|
prerelease: false
|
|
91
91
|
version_requirements: !ruby/object:Gem::Requirement
|
|
92
92
|
requirements:
|
|
93
93
|
- - "~>"
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
|
-
version: 0.
|
|
95
|
+
version: '0.15'
|
|
96
96
|
- !ruby/object:Gem::Dependency
|
|
97
97
|
name: ace-support-test-helpers
|
|
98
98
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -222,6 +222,7 @@ files:
|
|
|
222
222
|
- lib/ace/lab/molecules/grant_resolver.rb
|
|
223
223
|
- lib/ace/lab/molecules/hitl_authorizer.rb
|
|
224
224
|
- lib/ace/lab/molecules/inventory_query.rb
|
|
225
|
+
- lib/ace/lab/molecules/ordinary_socket.rb
|
|
225
226
|
- lib/ace/lab/molecules/protected_cleanup_owner_admission.rb
|
|
226
227
|
- lib/ace/lab/molecules/protected_cleanup_owner_client.rb
|
|
227
228
|
- lib/ace/lab/molecules/protected_cleanup_owner_identity.rb
|
|
@@ -232,6 +233,7 @@ files:
|
|
|
232
233
|
- lib/ace/lab/molecules/service_policy.rb
|
|
233
234
|
- lib/ace/lab/molecules/topology_loader.rb
|
|
234
235
|
- lib/ace/lab/organisms/authority_composition.rb
|
|
236
|
+
- lib/ace/lab/organisms/ordinary_merge_request.rb
|
|
235
237
|
- lib/ace/lab/organisms/protected_cleanup_owner.rb
|
|
236
238
|
- lib/ace/lab/organisms/protected_service_client.rb
|
|
237
239
|
- lib/ace/lab/organisms/protected_service_listener.rb
|