ace-lab 0.6.0 → 0.7.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 +13 -0
- data/lib/ace/lab/cli/commands/service.rb +80 -0
- data/lib/ace/lab/cli.rb +3 -1
- data/lib/ace/lab/molecules/forge_merge_config.rb +127 -0
- data/lib/ace/lab/molecules/forgejo_merge_adapter.rb +150 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +15 -13
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +112 -0
- data/lib/ace/lab/molecules/ordinary_ff_merge.rb +86 -0
- data/lib/ace/lab/molecules/ordinary_listener.rb +238 -0
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +86 -0
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +663 -0
- data/lib/ace/lab/version.rb +1 -1
- metadata +25 -4
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "ordinary_socket"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Lab
|
|
10
|
+
module Molecules
|
|
11
|
+
# The ordinary receiver's serve-side transport (spec 8x3.t.xz9.4 serve
|
|
12
|
+
# entrypoint). Binds the configured unix socket and answers the exact
|
|
13
|
+
# framed protocol the OrdinarySocket client speaks: one bounded JSON
|
|
14
|
+
# document each way behind the shared handshake line. Authentication is
|
|
15
|
+
# MUTUAL through SO_PEERCRED: every connection's peer UID must equal
|
|
16
|
+
# the configured producer principal UID, and the listener itself runs
|
|
17
|
+
# as the configured receiver UID — the identity the client verifies on
|
|
18
|
+
# its side. Every identity, framing, deadline or parse failure is
|
|
19
|
+
# fail-closed: the peer receives the closed refusal envelope (or the
|
|
20
|
+
# connection closes) and nothing is ever dispatched. This is not an
|
|
21
|
+
# HTTP transport and there is no cross-VM fallback.
|
|
22
|
+
class OrdinaryListener
|
|
23
|
+
HANDSHAKE = OrdinarySocket::HANDSHAKE
|
|
24
|
+
LIMIT = OrdinarySocket::LIMIT
|
|
25
|
+
DEADLINE_SECONDS = 30
|
|
26
|
+
|
|
27
|
+
def initialize(socket_path:, peer_uid:, handler:, lock_path: nil, logger: nil)
|
|
28
|
+
unless socket_path.is_a?(String) && socket_path.start_with?("/") &&
|
|
29
|
+
peer_uid.is_a?(Integer) && peer_uid >= 0 && handler.respond_to?(:call)
|
|
30
|
+
raise ArgumentError, "ordinary listener needs an absolute socket path, a peer uid and a handler"
|
|
31
|
+
end
|
|
32
|
+
@socket_path = socket_path
|
|
33
|
+
@peer_uid = peer_uid
|
|
34
|
+
@handler = handler
|
|
35
|
+
@lock_path = lock_path || "#{@socket_path}.lock"
|
|
36
|
+
@logger = logger
|
|
37
|
+
@stopping = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Serve until #stop. Excludes a second listener through one owned
|
|
41
|
+
# lock file, removes only its own stale endpoint, and always removes
|
|
42
|
+
# the endpoint it created.
|
|
43
|
+
def serve
|
|
44
|
+
acquire_lock!
|
|
45
|
+
remove_stale_endpoint!
|
|
46
|
+
@listener = UNIXServer.new(@socket_path)
|
|
47
|
+
File.chmod(0o600, @socket_path)
|
|
48
|
+
@endpoint_identity = File.stat(@socket_path)
|
|
49
|
+
until @stopping
|
|
50
|
+
socket = @listener.accept
|
|
51
|
+
serve_connection(socket)
|
|
52
|
+
end
|
|
53
|
+
rescue IOError, Errno::EBADF
|
|
54
|
+
raise unless @stopping
|
|
55
|
+
ensure
|
|
56
|
+
@listener&.close
|
|
57
|
+
remove_owned_endpoint!
|
|
58
|
+
@lock&.flock(File::LOCK_UN)
|
|
59
|
+
@lock&.close
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stop
|
|
63
|
+
@stopping = true
|
|
64
|
+
@listener&.close
|
|
65
|
+
rescue IOError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# One bounded connection: verify the peer identity, read one framed
|
|
70
|
+
# request, answer one framed envelope, close. Fail-closed on every
|
|
71
|
+
# violation; a dispatch never happens from an unverified frame.
|
|
72
|
+
def serve_connection(socket)
|
|
73
|
+
deadline = Time.now.utc + DEADLINE_SECONDS
|
|
74
|
+
begin
|
|
75
|
+
verify_peer!(socket)
|
|
76
|
+
submission = read_request!(socket, deadline)
|
|
77
|
+
response = @handler.call(submission)
|
|
78
|
+
write_response!(socket, response, deadline)
|
|
79
|
+
if response.is_a?(Hash) && response["status"] == "ok" && @handler.respond_to?(:execute)
|
|
80
|
+
begin
|
|
81
|
+
# The durable acknowledgment is already on the wire; the
|
|
82
|
+
# execution lifetime belongs to the canonical record, not
|
|
83
|
+
# the short connection. A lost lifecycle stays pending or
|
|
84
|
+
# uncertain and reconciles by the original reference.
|
|
85
|
+
@handler.execute(response.dig("data", "request_id"))
|
|
86
|
+
rescue StandardError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
rescue OrdinaryRefused => e
|
|
91
|
+
write_response!(socket, e.envelope, deadline)
|
|
92
|
+
rescue OrdinaryListenerError
|
|
93
|
+
# Fail closed without a fabricated envelope; the client classifies
|
|
94
|
+
# a lost/unreadable response as ordinary_merge_unavailable.
|
|
95
|
+
nil
|
|
96
|
+
rescue StandardError
|
|
97
|
+
write_response!(socket, refusal_envelope("ordinary_merge_unavailable",
|
|
98
|
+
"receiver failed to handle the request"), deadline)
|
|
99
|
+
ensure
|
|
100
|
+
begin
|
|
101
|
+
socket.close
|
|
102
|
+
rescue IOError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
class OrdinaryListenerError < ::StandardError; end
|
|
109
|
+
|
|
110
|
+
# A verified-connection refusal that still carries a closed envelope.
|
|
111
|
+
class OrdinaryRefused < OrdinaryListenerError
|
|
112
|
+
attr_reader :envelope
|
|
113
|
+
|
|
114
|
+
def initialize(envelope)
|
|
115
|
+
@envelope = envelope
|
|
116
|
+
super(envelope.dig("error", "message").to_s)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
# Mutual half of SO_PEERCRED: the connecting process must run as the
|
|
123
|
+
# configured producer principal UID. Anything else is refused before
|
|
124
|
+
# one byte of request is read.
|
|
125
|
+
def verify_peer!(socket)
|
|
126
|
+
credentials = socket.getsockopt(:SOCKET, :PEERCRED)
|
|
127
|
+
_peer_pid, peer_uid, = credentials.unpack("L3")
|
|
128
|
+
return if peer_uid == @peer_uid
|
|
129
|
+
raise OrdinaryRefused, refusal_envelope("ordinary_merge_unauthenticated",
|
|
130
|
+
"connecting principal is not the configured ordinary overseer")
|
|
131
|
+
rescue SystemCallError, IOError
|
|
132
|
+
raise OrdinaryListenerError, "peer credentials are unavailable"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def read_request!(socket, deadline)
|
|
136
|
+
header = read_line!(socket, deadline)
|
|
137
|
+
match = /\A#{Regexp.escape(HANDSHAKE)} (\d{1,5})\n\z/o.match(header)
|
|
138
|
+
raise OrdinaryListenerError, "request header is invalid" unless match
|
|
139
|
+
length = Integer(match[1])
|
|
140
|
+
raise OrdinaryListenerError, "request exceeds the wire bound" if length > LIMIT
|
|
141
|
+
payload = read_exact!(socket, length, deadline)
|
|
142
|
+
JSON.parse(payload, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
|
|
143
|
+
rescue JSON::ParserError
|
|
144
|
+
raise OrdinaryRefused, refusal_envelope("ordinary_merge_invalid_request",
|
|
145
|
+
"request document is not valid JSON")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def write_response!(socket, response, deadline)
|
|
149
|
+
payload = JSON.generate(response)
|
|
150
|
+
raise OrdinaryListenerError, "response exceeds the wire bound" if payload.bytesize > LIMIT
|
|
151
|
+
frame = "#{HANDSHAKE} #{payload.bytesize}\n#{payload}"
|
|
152
|
+
written = 0
|
|
153
|
+
while written < frame.bytesize
|
|
154
|
+
raise OrdinaryListenerError, "response write deadline expired" if Time.now.utc >= deadline
|
|
155
|
+
begin
|
|
156
|
+
written += socket.write_nonblock(frame.byteslice(written..))
|
|
157
|
+
rescue IO::WaitWritable
|
|
158
|
+
socket.wait_writable(remaining!(deadline)) or
|
|
159
|
+
raise OrdinaryListenerError, "response write deadline expired"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
rescue JSON::GeneratorError
|
|
163
|
+
raise OrdinaryListenerError, "response is not serializable"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def refusal_envelope(code, message)
|
|
167
|
+
{"status" => "error", "error" => {
|
|
168
|
+
"schema" => "ace.lab.service.ordinary-merge-refusal/v1",
|
|
169
|
+
"code" => code,
|
|
170
|
+
"message" => message.to_s.scrub.gsub(/\s+/, " ").strip[0, 512],
|
|
171
|
+
"request_id" => "unknown",
|
|
172
|
+
"input_digest" => nil
|
|
173
|
+
}}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def read_line!(socket, deadline)
|
|
177
|
+
line = +""
|
|
178
|
+
until line.end_with?("\n")
|
|
179
|
+
raise OrdinaryListenerError, "request header is unbounded" if line.bytesize > 128
|
|
180
|
+
line << read_exact!(socket, 1, deadline)
|
|
181
|
+
end
|
|
182
|
+
line
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def read_exact!(socket, length, deadline)
|
|
186
|
+
buffer = +""
|
|
187
|
+
while buffer.bytesize < length
|
|
188
|
+
raise OrdinaryListenerError, "request read deadline expired" if Time.now.utc >= deadline
|
|
189
|
+
begin
|
|
190
|
+
chunk = socket.read_nonblock(length - buffer.bytesize)
|
|
191
|
+
raise OrdinaryListenerError, "peer closed the connection" if chunk.nil?
|
|
192
|
+
buffer << chunk
|
|
193
|
+
rescue IO::WaitReadable
|
|
194
|
+
socket.wait_readable(remaining!(deadline)) or
|
|
195
|
+
raise OrdinaryListenerError, "request read deadline expired"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
buffer
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def remaining!(deadline)
|
|
202
|
+
left = deadline - Time.now.utc
|
|
203
|
+
raise OrdinaryListenerError, "connection deadline expired" if left <= 0
|
|
204
|
+
left
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def acquire_lock!
|
|
208
|
+
@lock = File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
209
|
+
stat = @lock.stat
|
|
210
|
+
unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
|
|
211
|
+
raise Ace::Lab::InvalidConfigurationError, "receiver listener lock differs"
|
|
212
|
+
end
|
|
213
|
+
unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
214
|
+
raise Ace::Lab::InvalidConfigurationError, "receiver listener already active"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def remove_stale_endpoint!
|
|
219
|
+
stat = File.lstat(@socket_path)
|
|
220
|
+
unless stat.socket? && stat.uid == Process.uid
|
|
221
|
+
raise Ace::Lab::InvalidConfigurationError, "receiver endpoint differs"
|
|
222
|
+
end
|
|
223
|
+
File.unlink(@socket_path)
|
|
224
|
+
rescue Errno::ENOENT
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def remove_owned_endpoint!
|
|
229
|
+
return unless @endpoint_identity
|
|
230
|
+
stat = File.stat(@socket_path)
|
|
231
|
+
File.unlink(@socket_path) if stat.socket? && stat.ino == @endpoint_identity.ino
|
|
232
|
+
rescue Errno::ENOENT
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "service_policy"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Lab
|
|
9
|
+
module Molecules
|
|
10
|
+
# Project-scoped, explicitly filed receiver configuration (spec
|
|
11
|
+
# 8x3.t.xz9.4 execution clauses). The host owns the file, its
|
|
12
|
+
# placement, its credentials and its deployment; this loader only
|
|
13
|
+
# validates the exact closed shape and fails closed on anything else.
|
|
14
|
+
# It invents no credential and accepts no caller-selected endpoint:
|
|
15
|
+
# every value comes from one explicit deployment-owned YAML file.
|
|
16
|
+
class OrdinaryReceiverConfig
|
|
17
|
+
KEYS = %w[project receiver_id socket_path receiver_uid principal principal_uid
|
|
18
|
+
repo_path policy_path execution_identity].freeze
|
|
19
|
+
IDENTITY_KEYS = %w[checkout_id worktree_id branch].freeze
|
|
20
|
+
ID = ServicePolicy::ID
|
|
21
|
+
|
|
22
|
+
def self.load(path)
|
|
23
|
+
raise Ace::Lab::InvalidConfigurationError, "receiver config path is required" unless path.is_a?(String) && !path.empty?
|
|
24
|
+
document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: false)
|
|
25
|
+
new(document, source: path)
|
|
26
|
+
rescue Errno::ENOENT, Errno::EACCES, Psych::Exception
|
|
27
|
+
raise Ace::Lab::InvalidConfigurationError, "receiver configuration is unreadable: #{path}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :project, :receiver_id, :socket_path, :receiver_uid, :principal,
|
|
31
|
+
:principal_uid, :repo_path, :policy_path, :execution_identity, :source
|
|
32
|
+
|
|
33
|
+
def initialize(document, source: nil)
|
|
34
|
+
unless document.is_a?(Hash) && (document.keys - KEYS).empty? &&
|
|
35
|
+
(KEYS - document.keys).empty?
|
|
36
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
37
|
+
"receiver configuration must contain exactly: #{KEYS.join(', ')}"
|
|
38
|
+
end
|
|
39
|
+
@source = source
|
|
40
|
+
@project = string!(document.fetch("project"), "project")
|
|
41
|
+
@receiver_id = string!(document.fetch("receiver_id"), "receiver_id")
|
|
42
|
+
@principal = string!(document.fetch("principal"), "principal")
|
|
43
|
+
@socket_path = absolute!(document.fetch("socket_path"), "socket_path")
|
|
44
|
+
@repo_path = absolute!(document.fetch("repo_path"), "repo_path")
|
|
45
|
+
@policy_path = absolute!(document.fetch("policy_path"), "policy_path")
|
|
46
|
+
@receiver_uid = uid!(document.fetch("receiver_uid"), "receiver_uid")
|
|
47
|
+
@principal_uid = uid!(document.fetch("principal_uid"), "principal_uid")
|
|
48
|
+
identity = document.fetch("execution_identity")
|
|
49
|
+
unless identity.is_a?(Hash) && (identity.keys - IDENTITY_KEYS).empty? &&
|
|
50
|
+
IDENTITY_KEYS.all? { |key| identity[key].is_a?(String) && !identity[key].empty? }
|
|
51
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
52
|
+
"receiver execution_identity is incomplete"
|
|
53
|
+
end
|
|
54
|
+
@execution_identity = IDENTITY_KEYS.to_h { |key| [key, identity.fetch(key)] }.freeze
|
|
55
|
+
unless @repo_path.start_with?("/") && File.directory?(@repo_path)
|
|
56
|
+
raise Ace::Lab::InvalidConfigurationError, "receiver repo_path is not a directory"
|
|
57
|
+
end
|
|
58
|
+
freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def string!(value, name)
|
|
64
|
+
unless value.is_a?(String) && !value.empty? && value.length <= 256 && value.match?(ID)
|
|
65
|
+
raise Ace::Lab::InvalidConfigurationError, "receiver #{name} is invalid"
|
|
66
|
+
end
|
|
67
|
+
value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def absolute!(value, name)
|
|
71
|
+
unless value.is_a?(String) && value.start_with?("/") && value.length <= 1024
|
|
72
|
+
raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be an absolute path"
|
|
73
|
+
end
|
|
74
|
+
value
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def uid!(value, name)
|
|
78
|
+
unless value.is_a?(Integer) && value >= 0
|
|
79
|
+
raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be a non-negative integer"
|
|
80
|
+
end
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|