ace-lab 0.7.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 +21 -0
- data/docs/usage.md +343 -0
- data/lib/ace/lab/cli/commands/assignment.rb +92 -0
- 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/commands/service.rb +12 -28
- data/lib/ace/lab/cli.rb +16 -1
- 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_admission_resolver.rb +20 -1
- data/lib/ace/lab/molecules/ordinary_listener.rb +41 -17
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +45 -0
- data/lib/ace/lab/molecules/ordinary_native_read.rb +106 -0
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +152 -0
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +232 -0
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +12 -4
- data/lib/ace/lab/molecules/ordinary_socket.rb +6 -9
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +216 -0
- data/lib/ace/lab/molecules/service_executor.rb +9 -39
- data/lib/ace/lab/molecules/service_policy.rb +15 -4
- data/lib/ace/lab/organisms/authority_composition.rb +1 -1
- 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 +120 -0
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +153 -0
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +77 -61
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +41 -7
- data/lib/ace/lab/version.rb +1 -1
- metadata +33 -6
- data/lib/ace/lab/molecules/ordinary_ff_merge.rb +0 -86
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require_relative "../errors"
|
|
5
5
|
require_relative "service_policy"
|
|
6
|
+
require_relative "grant_resolver"
|
|
6
7
|
|
|
7
8
|
module Ace
|
|
8
9
|
module Lab
|
|
@@ -16,26 +17,32 @@ module Ace
|
|
|
16
17
|
class OrdinaryReceiverConfig
|
|
17
18
|
KEYS = %w[project receiver_id socket_path receiver_uid principal principal_uid
|
|
18
19
|
repo_path policy_path execution_identity].freeze
|
|
20
|
+
OPTIONAL_KEYS = %w[socket_gid forge_config_path].freeze
|
|
19
21
|
IDENTITY_KEYS = %w[checkout_id worktree_id branch].freeze
|
|
20
22
|
ID = ServicePolicy::ID
|
|
21
23
|
|
|
22
24
|
def self.load(path)
|
|
23
25
|
raise Ace::Lab::InvalidConfigurationError, "receiver config path is required" unless path.is_a?(String) && !path.empty?
|
|
24
|
-
|
|
26
|
+
bytes = GrantResolver.read_verified(path)
|
|
27
|
+
raise Ace::Lab::InvalidConfigurationError, "receiver configuration is missing" unless bytes
|
|
28
|
+
document = YAML.safe_load(bytes, permitted_classes: [Date], aliases: false)
|
|
25
29
|
new(document, source: path)
|
|
26
30
|
rescue Errno::ENOENT, Errno::EACCES, Psych::Exception
|
|
27
31
|
raise Ace::Lab::InvalidConfigurationError, "receiver configuration is unreadable: #{path}"
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
attr_reader :project, :receiver_id, :socket_path, :receiver_uid, :principal,
|
|
31
|
-
:principal_uid, :repo_path, :policy_path, :execution_identity, :source
|
|
35
|
+
:principal_uid, :repo_path, :policy_path, :execution_identity, :source, :socket_gid, :forge_config_path
|
|
32
36
|
|
|
33
37
|
def initialize(document, source: nil)
|
|
34
|
-
unless document.is_a?(Hash) && (document.keys - KEYS).empty? &&
|
|
38
|
+
unless document.is_a?(Hash) && (document.keys - KEYS - OPTIONAL_KEYS).empty? &&
|
|
35
39
|
(KEYS - document.keys).empty?
|
|
36
40
|
raise Ace::Lab::InvalidConfigurationError,
|
|
37
|
-
"receiver configuration
|
|
41
|
+
"receiver configuration requires #{KEYS.join(', ')}; optional: #{OPTIONAL_KEYS.join(', ')}"
|
|
38
42
|
end
|
|
43
|
+
@socket_gid = uid!(document.fetch("socket_gid"), "socket_gid") if document.key?("socket_gid")
|
|
44
|
+
@forge_config_path = absolute!(document.fetch("forge_config_path"), "forge_config_path") if
|
|
45
|
+
document.key?("forge_config_path")
|
|
39
46
|
@source = source
|
|
40
47
|
@project = string!(document.fetch("project"), "project")
|
|
41
48
|
@receiver_id = string!(document.fetch("receiver_id"), "receiver_id")
|
|
@@ -44,6 +51,7 @@ module Ace
|
|
|
44
51
|
@repo_path = absolute!(document.fetch("repo_path"), "repo_path")
|
|
45
52
|
@policy_path = absolute!(document.fetch("policy_path"), "policy_path")
|
|
46
53
|
@receiver_uid = uid!(document.fetch("receiver_uid"), "receiver_uid")
|
|
54
|
+
raise Ace::Lab::InvalidConfigurationError, "ordinary receiver must have a non-root owner" if @receiver_uid.zero?
|
|
47
55
|
@principal_uid = uid!(document.fetch("principal_uid"), "principal_uid")
|
|
48
56
|
identity = document.fetch("execution_identity")
|
|
49
57
|
unless identity.is_a?(Hash) && (identity.keys - IDENTITY_KEYS).empty? &&
|
|
@@ -9,7 +9,7 @@ module Ace
|
|
|
9
9
|
# Admission transport between the ordinary fixed merge producer and the
|
|
10
10
|
# installed ordinary receiver: one bounded JSON document each way over
|
|
11
11
|
# a configured unix stream socket with a fixed deadline. Authentication
|
|
12
|
-
# is mutual through
|
|
12
|
+
# is mutual through OS peer credentials — the producer verifies the receiver's
|
|
13
13
|
# configured UID and transmits the verified local caller identity, so
|
|
14
14
|
# no caller-selected credential, token or endpoint exists. This is not
|
|
15
15
|
# an HTTP transport; a lost or unauthenticated response is a
|
|
@@ -33,19 +33,19 @@ module Ace
|
|
|
33
33
|
def submit(document)
|
|
34
34
|
payload = JSON.generate(document)
|
|
35
35
|
raise OrdinaryUnavailable, "request document exceeds the wire bound" if payload.bytesize > LIMIT
|
|
36
|
-
socket =
|
|
36
|
+
socket = nil
|
|
37
37
|
begin
|
|
38
|
-
socket.
|
|
38
|
+
socket = UNIXSocket.new(@socket_path)
|
|
39
39
|
verify_peer!(socket)
|
|
40
40
|
write_all!(socket, "#{HANDSHAKE} #{payload.bytesize}\n#{payload}")
|
|
41
41
|
response = read_response!(socket)
|
|
42
42
|
rescue OrdinaryUnavailable
|
|
43
43
|
raise
|
|
44
|
-
rescue SystemCallError, IOError => e
|
|
44
|
+
rescue SystemCallError, IOError, SocketError, NotImplementedError => e
|
|
45
45
|
raise OrdinaryUnavailable, "receiver transport failed: #{e.class}"
|
|
46
46
|
ensure
|
|
47
47
|
begin
|
|
48
|
-
socket
|
|
48
|
+
socket&.close
|
|
49
49
|
rescue IOError
|
|
50
50
|
nil
|
|
51
51
|
end
|
|
@@ -58,10 +58,7 @@ module Ace
|
|
|
58
58
|
private
|
|
59
59
|
|
|
60
60
|
def verify_peer!(socket)
|
|
61
|
-
|
|
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")
|
|
61
|
+
peer_uid, = socket.getpeereid
|
|
65
62
|
return if peer_uid == @receiver_uid
|
|
66
63
|
raise OrdinaryUnavailable, "receiver endpoint identity is not the installed receiver"
|
|
67
64
|
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/assign/molecules/installed_journal_read"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Molecules
|
|
8
|
+
# Observe a mutable ordinary worker checkout. The root-installed project
|
|
9
|
+
# selection supplies both boundaries; caller input never selects a Git
|
|
10
|
+
# repository. This is historical observation, not a checkout freeze.
|
|
11
|
+
class OrdinaryWorktreeRead
|
|
12
|
+
def initialize(root:, common_dir:, deadline:)
|
|
13
|
+
@root, @common, @deadline = root, common_dir, deadline
|
|
14
|
+
[root, common_dir].each { |path| absolute!(path) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def observe(cwd)
|
|
18
|
+
absolute!(cwd)
|
|
19
|
+
remaining
|
|
20
|
+
unless within?(cwd, @root)
|
|
21
|
+
unavailable!("worker working directory is outside installed worktrees")
|
|
22
|
+
end
|
|
23
|
+
before = selection(cwd)
|
|
24
|
+
head = git(before, "rev-parse", "--verify", "HEAD^{commit}").strip
|
|
25
|
+
unavailable!("worker HEAD is unavailable") unless head.match?(/\A[0-9a-f]{40}\z/)
|
|
26
|
+
after = selection(cwd)
|
|
27
|
+
unless before == after && git(after, "rev-parse", "--verify", "HEAD^{commit}").strip == head
|
|
28
|
+
unavailable!("worker checkout changed during observation")
|
|
29
|
+
end
|
|
30
|
+
{"cwd" => cwd, "worktree" => before.fetch("worktree"), "git_dir" => before.fetch("git_dir"),
|
|
31
|
+
"common_dir" => @common, "head" => head}
|
|
32
|
+
rescue SystemCallError, IOError, Timeout::Error, Ace::Herdr::Molecules::BoundedProcess::PostLaunchError
|
|
33
|
+
unavailable!("worker checkout cannot be observed")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ref_head(cwd, ref)
|
|
37
|
+
unless ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_./-]+\z}) && !ref.include?("..")
|
|
38
|
+
unavailable!("selected Git ref is invalid")
|
|
39
|
+
end
|
|
40
|
+
with_selected_read(cwd) do |selected|
|
|
41
|
+
head = git(selected, "rev-parse", "--verify", "#{ref}^{commit}").strip
|
|
42
|
+
unavailable!("selected ref is not a commit") unless head.match?(/\A[0-9a-f]{40}\z/)
|
|
43
|
+
head
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def commit?(cwd, head)
|
|
48
|
+
exact_head!(head)
|
|
49
|
+
with_selected_read(cwd) do |selected|
|
|
50
|
+
git(selected, "rev-parse", "--verify", "--quiet", "#{head}^{commit}", allowed_status: [0, 1], result: true).status.success?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ancestor?(cwd, ancestor, descendant)
|
|
55
|
+
exact_head!(ancestor); exact_head!(descendant)
|
|
56
|
+
with_selected_read(cwd) do |selected|
|
|
57
|
+
git(selected, "merge-base", "--is-ancestor", ancestor, descendant, allowed_status: [0, 1], result: true).status.success?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def exact_head!(head)
|
|
64
|
+
unavailable!("selected Git head is invalid") unless head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def with_selected_read(cwd)
|
|
68
|
+
absolute!(cwd)
|
|
69
|
+
unavailable!("selected repository leaves installed worktrees") unless within?(cwd, @root)
|
|
70
|
+
before = selection(cwd)
|
|
71
|
+
value = yield before
|
|
72
|
+
unavailable!("selected repository changed during read") unless before == selection(cwd)
|
|
73
|
+
value
|
|
74
|
+
rescue SystemCallError, IOError, Timeout::Error, Ace::Herdr::Molecules::BoundedProcess::PostLaunchError => error
|
|
75
|
+
unavailable!("selected repository cannot be observed (#{error.class})")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def selection(cwd)
|
|
79
|
+
directory!(cwd)
|
|
80
|
+
directory!(@common)
|
|
81
|
+
worktree = cwd
|
|
82
|
+
loop do
|
|
83
|
+
break if File.exist?(File.join(worktree, ".git")) || File.symlink?(File.join(worktree, ".git"))
|
|
84
|
+
unavailable!("worker checkout is unavailable") if worktree == @root
|
|
85
|
+
worktree = File.dirname(worktree)
|
|
86
|
+
end
|
|
87
|
+
marker = File.join(worktree, ".git")
|
|
88
|
+
metadata = {}
|
|
89
|
+
git_dir = if File.lstat(marker).directory?
|
|
90
|
+
directory!(marker)
|
|
91
|
+
unavailable!("worker belongs to another project") unless marker == @common
|
|
92
|
+
marker
|
|
93
|
+
else
|
|
94
|
+
bytes = regular!(marker, metadata, 4096)
|
|
95
|
+
match = bytes.match(/\Agitdir: ([^\n\0]+)\n?\z/)
|
|
96
|
+
unavailable!("worker Git pointer is invalid") unless match
|
|
97
|
+
selected = File.expand_path(match[1], worktree)
|
|
98
|
+
unless File.dirname(selected) == File.join(@common, "worktrees")
|
|
99
|
+
unavailable!("worker Git pointer leaves installed project")
|
|
100
|
+
end
|
|
101
|
+
directory!(selected)
|
|
102
|
+
common = regular!(File.join(selected, "commondir"), metadata, 4096).strip
|
|
103
|
+
unavailable!("worker common directory differs") unless File.expand_path(common, selected) == @common
|
|
104
|
+
back = regular!(File.join(selected, "gitdir"), metadata, 4096).strip
|
|
105
|
+
unavailable!("worker Git backpointer differs") unless back == marker
|
|
106
|
+
selected
|
|
107
|
+
end
|
|
108
|
+
@entries = 0
|
|
109
|
+
%w[objects refs].each { |name| storage!(File.join(@common, name)) }
|
|
110
|
+
%w[commondir shallow reftable info/grafts objects/info/alternates objects/info/http-alternates].each do |name|
|
|
111
|
+
path = File.join(@common, name)
|
|
112
|
+
unavailable!("worker object storage is redirected") if File.exist?(path) || File.symlink?(path)
|
|
113
|
+
end
|
|
114
|
+
regular!(File.join(git_dir, "HEAD"), metadata, 4096)
|
|
115
|
+
packed = File.join(@common, "packed-refs")
|
|
116
|
+
storage!(packed) if File.exist?(packed) || File.symlink?(packed)
|
|
117
|
+
local_refs = File.join(git_dir, "refs")
|
|
118
|
+
storage!(local_refs) if git_dir != @common && (File.exist?(local_refs) || File.symlink?(local_refs))
|
|
119
|
+
config!(File.join(@common, "config"), metadata)
|
|
120
|
+
local = File.join(git_dir, "config.worktree")
|
|
121
|
+
config!(local, metadata) if File.exist?(local) || File.symlink?(local)
|
|
122
|
+
{"worktree" => worktree, "git_dir" => git_dir, "metadata" => metadata,
|
|
123
|
+
"directories" => [cwd, worktree, git_dir, @common].to_h { |path| [path, identity(File.lstat(path))] }}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def config!(path, metadata)
|
|
127
|
+
bytes = regular!(path, metadata, 65_536)
|
|
128
|
+
output = run(["config", "--no-includes", "--null", "--list", "--file", "/dev/stdin"], stdin: bytes)
|
|
129
|
+
output.split("\0").each do |entry|
|
|
130
|
+
key, value = entry.split("\n", 2)
|
|
131
|
+
if key.start_with?("include.", "includeif.", "extensions.") || key == "core.worktree" ||
|
|
132
|
+
key == "core.repositoryformatversion" && value != "0"
|
|
133
|
+
unavailable!("worker Git configuration redirects project storage")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def git(selected, *argv, allowed_status: [0], result: false)
|
|
139
|
+
captured = run_result(["--git-dir=#{selected.fetch('git_dir')}", "--work-tree=#{selected.fetch('worktree')}",
|
|
140
|
+
"-c", "safe.directory=#{selected.fetch('worktree')}", "-c", "core.hooksPath=/dev/null",
|
|
141
|
+
"-c", "core.fsmonitor=false", "-c", "protocol.allow=never", *argv],
|
|
142
|
+
environment: {"GIT_COMMON_DIR" => @common, "GIT_OBJECT_DIRECTORY" => File.join(@common, "objects")}, allowed_status: allowed_status)
|
|
143
|
+
result ? captured : captured.stdout
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def run(argv, stdin: "", environment: {})
|
|
147
|
+
run_result(argv, stdin: stdin, environment: environment).stdout
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def run_result(argv, stdin: "", environment: {}, allowed_status: [0])
|
|
151
|
+
result = Ace::Herdr::Molecules::BoundedProcess.call(["/usr/bin/git", "--no-pager", *argv],
|
|
152
|
+
environment: Ace::Assign::Molecules::InstalledJournalRead::ENVIRONMENT.merge(environment), chdir: "/",
|
|
153
|
+
stdin_data: stdin, timeout_s: remaining, output_limit: 65_536, cleanup_group: true)
|
|
154
|
+
unavailable!("worker Git read failed or exceeded its bound") unless allowed_status.include?(result.status.exitstatus) && !result.oversized
|
|
155
|
+
result
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def regular!(path, metadata, limit)
|
|
159
|
+
directory!(File.dirname(path))
|
|
160
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
161
|
+
before = file.stat
|
|
162
|
+
unavailable!("worker metadata must be a bounded regular file") unless before.file? && before.size <= limit
|
|
163
|
+
bytes = file.read(limit + 1)
|
|
164
|
+
unless bytes.bytesize <= limit && stamp(before) == stamp(file.stat) && stamp(before) == stamp(File.lstat(path))
|
|
165
|
+
unavailable!("worker metadata changed during read")
|
|
166
|
+
end
|
|
167
|
+
metadata[path] = [stamp(before), bytes]
|
|
168
|
+
bytes
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def directory!(path)
|
|
173
|
+
current = path
|
|
174
|
+
loop do
|
|
175
|
+
remaining
|
|
176
|
+
stat = File.lstat(current)
|
|
177
|
+
unavailable!("worker directory is redirected") unless stat.directory? && !stat.symlink?
|
|
178
|
+
break if current == "/"
|
|
179
|
+
current = File.dirname(current)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def storage!(path)
|
|
184
|
+
remaining
|
|
185
|
+
@entries += 1
|
|
186
|
+
unavailable!("worker storage inventory exceeds its bound") if @entries > Ace::Assign::Molecules::InstalledJournalRead::ENTRY_LIMIT
|
|
187
|
+
stat = File.lstat(path)
|
|
188
|
+
unavailable!("worker storage is redirected") unless !stat.symlink? && (stat.file? || stat.directory?)
|
|
189
|
+
Dir.each_child(path) { |name| storage!(File.join(path, name)) } if stat.directory?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def absolute!(path)
|
|
193
|
+
unless path.is_a?(String) && path.bytesize.between?(1, 4096) && !path.include?("\0") &&
|
|
194
|
+
path.start_with?("/") && File.expand_path(path) == path
|
|
195
|
+
unavailable!("worker path is invalid")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def within?(path, parent) = path == parent || path.start_with?(parent + "/")
|
|
200
|
+
def identity(stat) = [stat.dev, stat.ino]
|
|
201
|
+
def stamp(stat) = [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime]
|
|
202
|
+
|
|
203
|
+
def remaining
|
|
204
|
+
unless @deadline.is_a?(Numeric) && @deadline.finite? && (left = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).positive?
|
|
205
|
+
unavailable!("worker observation deadline elapsed")
|
|
206
|
+
end
|
|
207
|
+
left
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def unavailable!(reason)
|
|
211
|
+
raise Ace::Assign::AttemptErrors::EvidenceUnavailable, reason
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "
|
|
4
|
+
require "ace/herdr/molecules/bounded_process"
|
|
5
5
|
require "socket"
|
|
6
6
|
require "timeout"
|
|
7
7
|
require "time"
|
|
@@ -40,7 +40,8 @@ module Ace
|
|
|
40
40
|
return nil unless valid_response?(response, request)
|
|
41
41
|
{"outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"),
|
|
42
42
|
"executor_uid" => current.fetch("executor_uid")}
|
|
43
|
-
rescue JSON::ParserError, Timeout::Error, SystemCallError, IOError
|
|
43
|
+
rescue JSON::ParserError, Timeout::Error, SystemCallError, IOError,
|
|
44
|
+
Ace::Herdr::Molecules::BoundedProcess::PostLaunchError
|
|
44
45
|
# Transport failures after the claim (broken pipe, connection
|
|
45
46
|
# reset, socket loss) leave the effect unknown: uncertainty, not a
|
|
46
47
|
# classified crash, is the honest outcome.
|
|
@@ -49,49 +50,18 @@ module Ace
|
|
|
49
50
|
|
|
50
51
|
private
|
|
51
52
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# instead of being buffered without bound in this process.
|
|
53
|
+
# The existing process owner bounds both streams and the whole run,
|
|
54
|
+
# and cleans the owned process group on timeout and normal exit.
|
|
55
55
|
def invoke_local(operation, request, input)
|
|
56
56
|
unless Process.uid == operation.fetch("executor_uid") && Process.euid == operation.fetch("executor_uid")
|
|
57
57
|
raise SecurityError, "current OS identity is not the configured executor"
|
|
58
58
|
end
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
begin
|
|
64
|
-
Timeout.timeout(30) do
|
|
65
|
-
stdin.write(JSON.generate({"request" => request, "input" => input}))
|
|
66
|
-
stdin.close
|
|
67
|
-
errors = Thread.new { bounded_read(stderr, 8192) }
|
|
68
|
-
out = bounded_read(stdout, 16 * 1024 + 1)
|
|
69
|
-
errors.join
|
|
70
|
-
Process.kill("KILL", pid) if out && out.bytesize > 16 * 1024
|
|
71
|
-
status = waiter.value
|
|
72
|
-
end
|
|
73
|
-
rescue Timeout::Error
|
|
74
|
-
Process.kill("KILL", pid) rescue nil
|
|
75
|
-
waiter.value
|
|
76
|
-
raise
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
out if out && out.bytesize <= 16 * 1024 && status&.success?
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def bounded_read(io, limit)
|
|
83
|
-
io.read(limit).to_s
|
|
84
|
-
rescue IOError, Errno::EBADF
|
|
85
|
-
""
|
|
86
|
-
ensure
|
|
87
|
-
begin
|
|
88
|
-
io.close
|
|
89
|
-
rescue IOError, Errno::EBADF
|
|
90
|
-
nil
|
|
91
|
-
end
|
|
59
|
+
result = Ace::Herdr::Molecules::BoundedProcess.call(operation.fetch("argv"),
|
|
60
|
+
stdin_data: JSON.generate({"request" => request, "input" => input}),
|
|
61
|
+
timeout_s: 30, output_limit: 16 * 1024, stderr_limit: 8192, cleanup_group: true)
|
|
62
|
+
result.stdout if result.status.success? && !result.oversized
|
|
92
63
|
end
|
|
93
64
|
|
|
94
|
-
|
|
95
65
|
def invoke_unix(operation, request, input)
|
|
96
66
|
path = operation.fetch("socket_path")
|
|
97
67
|
stat = File.lstat(path)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../errors"
|
|
4
|
+
require_relative "../atoms/service_input"
|
|
4
5
|
require "ace/assign/errors"
|
|
5
6
|
require "pathname"
|
|
6
7
|
require "time"
|
|
@@ -24,10 +25,14 @@ module Ace
|
|
|
24
25
|
end
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
# The trusted installation document as loaded
|
|
28
|
-
#
|
|
28
|
+
# The trusted installation document as loaded. Decisions are mutable;
|
|
29
|
+
# all other configuration remains bound for the receiver lifetime.
|
|
29
30
|
attr_reader :document
|
|
30
31
|
|
|
32
|
+
def self.configuration_digest(document)
|
|
33
|
+
Atoms::ServiceInput.digest(document.except("authorizations"))
|
|
34
|
+
end
|
|
35
|
+
|
|
31
36
|
def operation!(name, project:, service_id:)
|
|
32
37
|
raise ArgumentError, "invalid operation name" unless name.to_s.match?(NAME)
|
|
33
38
|
operation = @operations[name]
|
|
@@ -115,7 +120,7 @@ module Ace
|
|
|
115
120
|
# admission flag is rollback: new admission is unavailable while
|
|
116
121
|
# canonical records and read-only status stay preserved.
|
|
117
122
|
ORDINARY_SECTION_KEYS = %w[projects].freeze
|
|
118
|
-
ORDINARY_PROJECT_KEYS = %w[receiver_id socket_path receiver_uid principal admission].freeze
|
|
123
|
+
ORDINARY_PROJECT_KEYS = %w[receiver_id socket_path receiver_uid principal admission journal_repository journal_ref].freeze
|
|
119
124
|
ORDINARY_AUTHORIZATION_KEYS = %w[operation project_id assignment_id attempt_id queue_entry
|
|
120
125
|
input_digest forge pr_url head_repository head_ref candidate_head base_repository
|
|
121
126
|
base_ref expected_base_head expires_at].freeze
|
|
@@ -160,7 +165,7 @@ module Ace
|
|
|
160
165
|
end
|
|
161
166
|
unless entry["receiver_id"].is_a?(String) && entry["receiver_id"].match?(ID) &&
|
|
162
167
|
entry["socket_path"].is_a?(String) && entry["socket_path"].start_with?("/") &&
|
|
163
|
-
entry["receiver_uid"].is_a?(Integer) && entry["receiver_uid"]
|
|
168
|
+
entry["receiver_uid"].is_a?(Integer) && entry["receiver_uid"].positive? &&
|
|
164
169
|
entry["principal"].is_a?(String) && !entry["principal"].empty?
|
|
165
170
|
raise Ace::Lab::InvalidConfigurationError, "ordinary receiver mapping is incomplete"
|
|
166
171
|
end
|
|
@@ -168,6 +173,12 @@ module Ace
|
|
|
168
173
|
unless %w[enabled disabled].include?(admission)
|
|
169
174
|
raise Ace::Lab::InvalidConfigurationError, "ordinary admission flag is invalid"
|
|
170
175
|
end
|
|
176
|
+
repository, ref = entry.values_at("journal_repository", "journal_ref")
|
|
177
|
+
unless repository.is_a?(String) && repository.start_with?("/") && !repository.include?("\0") &&
|
|
178
|
+
File.expand_path(repository) == repository &&
|
|
179
|
+
ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z})
|
|
180
|
+
raise Ace::Lab::InvalidConfigurationError, "ordinary journal selection is invalid"
|
|
181
|
+
end
|
|
171
182
|
end
|
|
172
183
|
true
|
|
173
184
|
end
|
|
@@ -65,7 +65,7 @@ module Ace
|
|
|
65
65
|
service_policy: policy, deployment_history: history)
|
|
66
66
|
router = Ace::Assign::Authority::Router.new(launch: launch, handlers: [endcap])
|
|
67
67
|
Ace::Assign::Authority::Server.new(authority_id: @authority_id, deployment: @deployment, kernel: @kernel,
|
|
68
|
-
lifecycle: router, composition: "services",
|
|
68
|
+
lifecycle: router, composition: "services", services: [publication_hitl(endcap, journals)])
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
private
|