ace-lab 0.7.0 → 0.8.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 +9 -0
- data/docs/usage.md +155 -0
- data/lib/ace/lab/cli/commands/assignment.rb +45 -0
- data/lib/ace/lab/cli/commands/service.rb +12 -28
- data/lib/ace/lab/cli.rb +6 -1
- 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 +139 -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/ordinary_assignment_client.rb +149 -0
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +123 -0
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +68 -43
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +41 -7
- data/lib/ace/lab/version.rb +1 -1
- metadata +27 -6
- data/lib/ace/lab/molecules/ordinary_ff_merge.rb +0 -86
|
@@ -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
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "ace/assign/authority/ordinary_client"
|
|
5
|
+
require "ace/assign/authority/ordinary_lifecycle"
|
|
6
|
+
require "ace/assign/organisms/prepared_work_builder"
|
|
7
|
+
require_relative "../molecules/ordinary_owner_config"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Lab
|
|
11
|
+
module Organisms
|
|
12
|
+
# File inputs retain the exact mutation and bytes across response loss.
|
|
13
|
+
# Only Assign's installed owner registers, advances and accepts execution.
|
|
14
|
+
class OrdinaryAssignmentClient
|
|
15
|
+
A = Ace::Assign::Authority
|
|
16
|
+
REQUEST_KEYS = %w[version project_id operation mutation_id params parts].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(project:, config: nil, client_factory: nil)
|
|
19
|
+
@project = project
|
|
20
|
+
@config = config || Molecules::OrdinaryOwnerConfig.load(project: project)
|
|
21
|
+
unless @config.is_a?(Molecules::OrdinaryOwnerConfig) && @config.project == project
|
|
22
|
+
raise ArgumentError, "ordinary assignment project selection differs"
|
|
23
|
+
end
|
|
24
|
+
@client_factory = client_factory || ->(**options) { A::OrdinaryClient.new(**options) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Preparation produces input only, so it is also available on the host.
|
|
28
|
+
# Reusing an existing output directory never rebuilds or resubmits work.
|
|
29
|
+
def self.prepare(project:, task:, pane:, output:, dependency_reports: [])
|
|
30
|
+
unless project.is_a?(String) && Molecules::OrdinaryOwnerConfig::ID.match?(project) &&
|
|
31
|
+
pane.is_a?(String) && Molecules::OrdinaryOwnerConfig::PANE.match?(pane)
|
|
32
|
+
raise ArgumentError, "preparation requires project and native pane selectors"
|
|
33
|
+
end
|
|
34
|
+
directory = create_output(output)
|
|
35
|
+
prepared = Ace::Assign::Organisms::PreparedWorkBuilder.new(export_root: directory)
|
|
36
|
+
.call(task_ref: task, project_id: project, dependency_reports: dependency_reports)
|
|
37
|
+
definition = JSON.parse(prepared.fetch("definition_bytes"))
|
|
38
|
+
reference = definition.fetch("prepared_work")
|
|
39
|
+
request = {"version" => 1, "project_id" => project, "operation" => "register_assignment",
|
|
40
|
+
"mutation_id" => SecureRandom.hex(16), "params" => {
|
|
41
|
+
"assignment_id" => prepared.fetch("assignment_id"), "pane" => pane, "expected_generation" => 0,
|
|
42
|
+
"definition_digest" => Digest::SHA256.hexdigest(prepared.fetch("definition_bytes")),
|
|
43
|
+
"prepared_head" => prepared.fetch("head"), "prepared_tree" => prepared.fetch("tree"),
|
|
44
|
+
"manifest_sha256" => reference.fetch("manifest_sha256")},
|
|
45
|
+
"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")
|
|
49
|
+
{"project_id" => project, "assignment_id" => prepared.fetch("assignment_id"),
|
|
50
|
+
"scope" => prepared.fetch("scope"), "request" => File.join(directory, "request.json"), "prepared" => true}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def request(input:, output: nil)
|
|
54
|
+
request = JSON.parse(self.class.read_file(input, 16_384), allow_duplicate_key: false, create_additions: false, max_nesting: 24)
|
|
55
|
+
unless request.is_a?(Hash) && request.keys.sort == REQUEST_KEYS.sort && request["version"].is_a?(Integer) && request["version"] == 1 &&
|
|
56
|
+
request["project_id"] == @project && A::OrdinaryLifecycle::OPERATIONS.include?(request["operation"]) &&
|
|
57
|
+
request["params"].is_a?(Hash) && !request["params"].key?("transfer") && request["parts"].is_a?(Array)
|
|
58
|
+
raise ArgumentError, "retained ordinary request selection is invalid"
|
|
59
|
+
end
|
|
60
|
+
operation = request.fetch("operation")
|
|
61
|
+
read = %w[registration_status assignment_snapshot result_status result_snapshot].include?(operation)
|
|
62
|
+
mutation = request.fetch("mutation_id")
|
|
63
|
+
unless read ? mutation.nil? : mutation.is_a?(String) && Ace::Assign::Molecules::JournalMutation::ID.match?(mutation)
|
|
64
|
+
raise ArgumentError, "retained mutation ID must be explicit; read operations require null"
|
|
65
|
+
end
|
|
66
|
+
transfer = A::OrdinaryLifecycle::TRANSFER_OPERATIONS[operation]
|
|
67
|
+
download = transfer && transfer.fetch(:direction) == :download
|
|
68
|
+
upload = transfer && transfer.fetch(:direction) == :upload
|
|
69
|
+
unless (!!output == !!download) && (request["parts"].empty? || upload)
|
|
70
|
+
raise ArgumentError, "download requires a new --output directory; only uploads accept parts"
|
|
71
|
+
end
|
|
72
|
+
parts = upload && read_parts(request.fetch("parts"), input, transfer.fetch(:purpose))
|
|
73
|
+
# No local metadata is interpreted as successful execution. Download
|
|
74
|
+
# files are merely a projection of the authenticated canonical reply.
|
|
75
|
+
directory = download && self.class.create_output(output)
|
|
76
|
+
client = @client_factory.call(project_id: @project, service: @config.service,
|
|
77
|
+
verify_selection: @config.method(:verify_owner!), scratch_root: directory || nil)
|
|
78
|
+
reply = client.call(operation, request.fetch("params"), mutation_id: request.fetch("mutation_id"),
|
|
79
|
+
upload_parts: parts || nil, download: !!download, purpose: transfer && transfer.fetch(:purpose))
|
|
80
|
+
envelope = {"status" => "ok", "data" => reply.data, "transport" => {"replayed" => reply.replayed}}
|
|
81
|
+
if download
|
|
82
|
+
files = reply.parts.each_with_index.map do |bytes, index|
|
|
83
|
+
name = format("part-%02d.bytes", index)
|
|
84
|
+
self.class.write_file(directory, name, bytes)
|
|
85
|
+
{"file" => File.join(directory, name), "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)}
|
|
86
|
+
end
|
|
87
|
+
envelope["files"] = files
|
|
88
|
+
self.class.write_file(directory, "response.json", JSON.generate(envelope) + "\n")
|
|
89
|
+
end
|
|
90
|
+
envelope
|
|
91
|
+
rescue JSON::ParserError
|
|
92
|
+
raise ArgumentError, "retained request is not valid bounded JSON"
|
|
93
|
+
end
|
|
94
|
+
|
|
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
|
+
private
|
|
127
|
+
|
|
128
|
+
def read_parts(entries, input, purpose)
|
|
129
|
+
total, count, per_part = A::TransferCodec::LIMITS.fetch(purpose)
|
|
130
|
+
unless entries.size.between?(1, count) && entries.all? { |entry|
|
|
131
|
+
entry.is_a?(Hash) && entry.keys.sort == %w[bytes file sha256] && entry["file"].is_a?(String) &&
|
|
132
|
+
entry["file"].bytesize.between?(1, 4096) && !entry["file"].include?("\0") &&
|
|
133
|
+
entry["bytes"].is_a?(Integer) && entry["bytes"].between?(0, per_part) &&
|
|
134
|
+
entry["sha256"].is_a?(String) && A::TransferCodec::SHA256.match?(entry["sha256"])
|
|
135
|
+
} && entries.sum { |entry| entry.fetch("bytes") } <= total
|
|
136
|
+
raise ArgumentError, "retained upload inventory is invalid"
|
|
137
|
+
end
|
|
138
|
+
entries.map do |entry|
|
|
139
|
+
bytes = self.class.read_file(File.expand_path(entry.fetch("file"), File.dirname(File.expand_path(input))), per_part)
|
|
140
|
+
unless bytes.bytesize == entry.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == entry.fetch("sha256")
|
|
141
|
+
raise ArgumentError, "retained upload bytes changed; preserve the original mutation"
|
|
142
|
+
end
|
|
143
|
+
bytes
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/assign/authority/ordinary_lifecycle"
|
|
4
|
+
require "ace/assign/authority/ordinary_server"
|
|
5
|
+
require "ace/assign/authority/router"
|
|
6
|
+
require "ace/hitl/providers/lab"
|
|
7
|
+
require "ace/hitl/lifecycle/service"
|
|
8
|
+
require "ace/hitl/lifecycle/tls_authority"
|
|
9
|
+
require "ace/hitl/lifecycle/tls_service"
|
|
10
|
+
require_relative "../molecules/ordinary_owner_admission"
|
|
11
|
+
require_relative "../molecules/ordinary_listener"
|
|
12
|
+
require_relative "ordinary_merge_receiver"
|
|
13
|
+
|
|
14
|
+
module Ace
|
|
15
|
+
module Lab
|
|
16
|
+
module Organisms
|
|
17
|
+
# Ordinary delivery has one installed journal writer and exclusion owner.
|
|
18
|
+
# Existing Assign, HITL and merge protocols share that process lifetime;
|
|
19
|
+
# no requester cache or daemon working directory chooses accepted state.
|
|
20
|
+
class OrdinaryAuthorityComposition
|
|
21
|
+
def initialize(receiver_config:, owner_config: nil)
|
|
22
|
+
unless receiver_config.is_a?(Molecules::OrdinaryReceiverConfig)
|
|
23
|
+
raise ArgumentError, "ordinary composition needs installed receiver selection"
|
|
24
|
+
end
|
|
25
|
+
@receiver = receiver_config
|
|
26
|
+
@config = owner_config || Molecules::OrdinaryOwnerConfig.load(project: @receiver.project)
|
|
27
|
+
unless @config.is_a?(Molecules::OrdinaryOwnerConfig)
|
|
28
|
+
raise ArgumentError, "ordinary composition needs installed assignment selection"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build
|
|
33
|
+
@config.verify_listener!
|
|
34
|
+
policy_document = Molecules::GrantResolver.trusted_document(@receiver.policy_path)
|
|
35
|
+
verify_join!(policy_document)
|
|
36
|
+
admission = Molecules::OrdinaryOwnerAdmission.new(config: @config)
|
|
37
|
+
reader = nil
|
|
38
|
+
journal = Ace::Assign::Molecules::EvidenceJournal.new(repo_root: @config.journal.fetch("repository"),
|
|
39
|
+
ref: @config.journal.fetch("ref"), checkout_root: @config.journal.fetch("checkout_root"),
|
|
40
|
+
evidence_reader: ->(reference, record, state, pending) { reader.call(reference, record, state, pending) })
|
|
41
|
+
reader = Ace::Assign::Authority::ServiceEvidence.new(journal: journal)
|
|
42
|
+
exclusion = Ace::Assign::Molecules::LifecycleExclusion.new(root: @config.lifecycle_root)
|
|
43
|
+
coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(cache_base: @config.assignment_root,
|
|
44
|
+
repo_root: @config.journal.fetch("repository"), journal: journal, lifecycle_exclusion: exclusion,
|
|
45
|
+
recorded_binding_observer: admission.method(:recorded_binding!))
|
|
46
|
+
lifecycle = Ace::Assign::Authority::OrdinaryLifecycle.new(project: @config.project,
|
|
47
|
+
admission: admission, journal: journal, exclusion: exclusion, input_root: @config.assignment_root)
|
|
48
|
+
router = Ace::Assign::Authority::Router.new(handlers: [lifecycle])
|
|
49
|
+
merge = OrdinaryMergeReceiver.new(config: @receiver, coordinator: coordinator,
|
|
50
|
+
policy_document: policy_document, owner_guard: -> { @config.selected_principal!(@receiver.principal_uid) },
|
|
51
|
+
merge_factory: ->(document) {
|
|
52
|
+
Molecules::OrdinaryMergeRead.new(repo_path: @receiver.repo_path, base_ref: document.fetch("base_ref"),
|
|
53
|
+
root: @receiver.repo_path, common_dir: @config.worktrees.fetch("common_dir"))
|
|
54
|
+
})
|
|
55
|
+
listener = Molecules::OrdinaryListener.new(socket_path: @receiver.socket_path,
|
|
56
|
+
peer_uid: @receiver.principal_uid, socket_gid: @receiver.socket_gid, handler: merge,
|
|
57
|
+
logger: ->(message) { warn message })
|
|
58
|
+
Ace::Assign::Authority::OrdinaryServer.new(admission: admission, lifecycle: router,
|
|
59
|
+
services: hitl_services(coordinator) + [listener])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def verify_join!(document)
|
|
65
|
+
configured = Molecules::ServicePolicy.new(document).ordinary_receiver!(@config.project)
|
|
66
|
+
expected = {"receiver_id" => @receiver.receiver_id, "socket_path" => @receiver.socket_path,
|
|
67
|
+
"receiver_uid" => @config.service.fetch("uid"), "principal" => @receiver.principal,
|
|
68
|
+
"journal_repository" => @config.journal.fetch("repository"), "journal_ref" => @config.journal.fetch("ref")}
|
|
69
|
+
unless @receiver.project == @config.project && @receiver.receiver_uid == @config.service.fetch("uid") &&
|
|
70
|
+
configured && configured.slice(*expected.keys) == expected &&
|
|
71
|
+
@config.participants.key?(@receiver.principal_uid.to_s) &&
|
|
72
|
+
![@config.service.fetch("socket_path"), @config.native.fetch("socket_path"), @config.hitl.fetch("socket_path")].include?(@receiver.socket_path)
|
|
73
|
+
raise InvalidConfigurationError, "ordinary receiver and assignment owner selections differ"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def hitl_services(coordinator)
|
|
78
|
+
selected = @config.hitl
|
|
79
|
+
policy = Ace::Hitl::Providers::Lab.grants_policy(grants_path: selected.fetch("grants_path"))
|
|
80
|
+
unless policy.service_uid == @config.service.fetch("uid")
|
|
81
|
+
raise InvalidConfigurationError, "ordinary HITL service principal differs from assignment owner"
|
|
82
|
+
end
|
|
83
|
+
guard = lambda do
|
|
84
|
+
@config.verify_owner!
|
|
85
|
+
unless policy.service_uid == @config.service.fetch("uid")
|
|
86
|
+
raise Ace::Hitl::Lifecycle::BindingError, "installed HITL service principal changed"
|
|
87
|
+
end
|
|
88
|
+
rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error => error
|
|
89
|
+
raise Ace::Hitl::Lifecycle::BindingError, error.message
|
|
90
|
+
end
|
|
91
|
+
binding = Ace::Hitl::Providers::Lab::AssignmentBinding.new(coordinator: coordinator,
|
|
92
|
+
project: @config.project, owner_guard: guard)
|
|
93
|
+
# Unix requesters and the existing Hermes mTLS transport share the
|
|
94
|
+
# same owner and one in-memory OTP vault. No second daemon opens the
|
|
95
|
+
# journal and no synthetic Unix principal represents a certificate.
|
|
96
|
+
vault = Ace::Hitl::Lifecycle::OtpVault::MemoryVault.new
|
|
97
|
+
services = [Ace::Hitl::Lifecycle::Service.new(root: selected.fetch("root"),
|
|
98
|
+
socket_path: selected.fetch("socket_path"), group: selected.fetch("group"),
|
|
99
|
+
binding: binding, policy: policy, vault: vault)]
|
|
100
|
+
if selected.key?("tls_authority")
|
|
101
|
+
authority = Ace::Hitl::Lifecycle::TlsAuthority.load(selected.fetch("tls_authority"))
|
|
102
|
+
verify_tls_identity!(authority)
|
|
103
|
+
services << Ace::Hitl::Lifecycle::TlsService.new(root: selected.fetch("root"),
|
|
104
|
+
binding: binding, project: @config.project, credential: authority.credential,
|
|
105
|
+
ssl_context: authority.ssl_context!, listen: authority.listen, port: authority.port,
|
|
106
|
+
group: selected.fetch("group"), vault: vault, decision_policy: policy)
|
|
107
|
+
end
|
|
108
|
+
services
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def verify_tls_identity!(authority)
|
|
112
|
+
unless authority.project_id == @config.project &&
|
|
113
|
+
Etc.getpwnam(authority.service_user).uid == @config.service.fetch("uid") &&
|
|
114
|
+
Etc.getgrnam(authority.service_group).gid == @config.service.fetch("gid")
|
|
115
|
+
raise InvalidConfigurationError, "ordinary HITL TLS project or service identity differs"
|
|
116
|
+
end
|
|
117
|
+
rescue ArgumentError
|
|
118
|
+
raise InvalidConfigurationError, "ordinary HITL TLS service account is unavailable"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|