ace-lab 0.9.0 → 0.13.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 +363 -104
- data/README.md +66 -100
- data/docs/usage.md +529 -592
- data/lib/ace/lab/atoms/install_schema.rb +373 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +118 -0
- data/lib/ace/lab/cli/commands/project_add.rb +101 -0
- data/lib/ace/lab/cli/commands/status.rb +109 -0
- data/lib/ace/lab/cli/commands/verify.rb +115 -0
- data/lib/ace/lab/cli.rb +21 -23
- data/lib/ace/lab/install_manifest_path.rb +23 -0
- data/lib/ace/lab/models/status_report.rb +38 -0
- data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
- data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
- data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
- data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
- data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
- data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
- data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
- data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
- data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
- data/lib/ace/lab/molecules/project_registry.rb +251 -0
- data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
- data/lib/ace/lab/molecules/role_verifier.rb +223 -0
- data/lib/ace/lab/organisms/install.rb +213 -0
- data/lib/ace/lab/organisms/prepare.rb +156 -0
- data/lib/ace/lab/organisms/project_add.rb +170 -0
- data/lib/ace/lab/organisms/status_service.rb +70 -0
- data/lib/ace/lab/organisms/verify.rb +768 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +34 -108
- data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
- data/lib/ace/lab/atoms/service_input.rb +0 -92
- data/lib/ace/lab/cli/commands/assignment.rb +0 -92
- data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
- data/lib/ace/lab/cli/commands/run.rb +0 -65
- data/lib/ace/lab/cli/commands/service.rb +0 -240
- data/lib/ace/lab/models/service_receipt.rb +0 -18
- data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
- data/lib/ace/lab/molecules/command_policy.rb +0 -158
- data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
- data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
- data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
- data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
- data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
- data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
- data/lib/ace/lab/molecules/service_executor.rb +0 -99
- data/lib/ace/lab/molecules/service_policy.rb +0 -289
- data/lib/ace/lab/organisms/authority_composition.rb +0 -87
- data/lib/ace/lab/organisms/command_executor.rb +0 -220
- data/lib/ace/lab/organisms/command_run_client.rb +0 -131
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
- data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
- data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
- data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
- data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
- data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
- data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
- data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
- data/lib/ace/lab/organisms/service_request_service.rb +0 -249
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "pty"
|
|
5
|
-
require "etc"
|
|
6
|
-
require "fiddle"
|
|
7
|
-
require "base64"
|
|
8
|
-
require "io/console"
|
|
9
|
-
require "ace/runtime/molecules/owned_child_scope"
|
|
10
|
-
require_relative "command_run_client"
|
|
11
|
-
|
|
12
|
-
module Ace
|
|
13
|
-
module Lab
|
|
14
|
-
module Organisms
|
|
15
|
-
# Short-lived execution child of the installed root labd, not a listener
|
|
16
|
-
# or another journal owner. A single Ruby thread forks the gated executor.
|
|
17
|
-
class CommandExecutor
|
|
18
|
-
def initialize(project:, owner: nil)
|
|
19
|
-
@project = project
|
|
20
|
-
@owner = owner || CommandRunClient.new(project: project).owner
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def run(socket:, request:)
|
|
24
|
-
raise SecurityError, "command executor requires Linux root" unless RUBY_PLATFORM.include?("linux") && Process.uid.zero? && Process.euid.zero?
|
|
25
|
-
strict!(request, %w[execution_id caller tty rows cols])
|
|
26
|
-
id = request.fetch("execution_id")
|
|
27
|
-
raise ArgumentError, "invalid command reference" unless id.is_a?(String) && Molecules::CommandPolicy::ID.match?(id)
|
|
28
|
-
interactive = request.fetch("tty")
|
|
29
|
-
raise ArgumentError, "invalid terminal mode" unless [true, false].include?(interactive)
|
|
30
|
-
rows, cols = geometry(request)
|
|
31
|
-
params = {"execution_id" => id}
|
|
32
|
-
initial = @owner.call("command_status", params).data
|
|
33
|
-
unless initial["phase"] == "admitted" && initial.dig("definition", "requester") == request.fetch("caller")
|
|
34
|
-
raise SecurityError, "original command already claimed or requester differs"
|
|
35
|
-
end
|
|
36
|
-
definition = initial.fetch("definition")
|
|
37
|
-
unless definition.fetch("secret_references").empty?
|
|
38
|
-
raise SecurityError, "secret command execution capability is not installed"
|
|
39
|
-
end
|
|
40
|
-
claimed = @owner.call("command_claim", params.merge("caller" => request.fetch("caller")), mutation_id: "command-claim.#{id}")
|
|
41
|
-
raise SecurityError, "claim replay cannot execute" if claimed.replayed
|
|
42
|
-
launch(socket, id, definition, interactive, rows, cols)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
def launch(socket, id, definition, interactive, rows, cols)
|
|
48
|
-
raise SecurityError, "command fork requires a single-thread helper" unless Thread.list.count(&:alive?) == 1
|
|
49
|
-
scope = Ace::Runtime::Molecules::OwnedChildScope.new
|
|
50
|
-
executor = definition.fetch("executor")
|
|
51
|
-
user = Etc.getpwuid(executor.fetch("uid"))
|
|
52
|
-
environment = {"HOME" => executor.fetch("home"), "USER" => user.name, "LOGNAME" => user.name,
|
|
53
|
-
"PATH" => executor.fetch("path").join(":")}.merge(executor.fetch("environment"))
|
|
54
|
-
if interactive
|
|
55
|
-
parent_input, child_output = PTY.open
|
|
56
|
-
parent_output, child_input = parent_input, child_output
|
|
57
|
-
child_output.winsize = [rows, cols]
|
|
58
|
-
else
|
|
59
|
-
child_input, parent_input = IO.pipe
|
|
60
|
-
parent_output, child_output = IO.pipe
|
|
61
|
-
parent_error, child_error = IO.pipe
|
|
62
|
-
end
|
|
63
|
-
gate_read, gate_write = IO.pipe
|
|
64
|
-
ready_read, ready_write = IO.pipe
|
|
65
|
-
child = fork do
|
|
66
|
-
begin
|
|
67
|
-
socket.close
|
|
68
|
-
[parent_input, parent_output, parent_error, gate_write, ready_read].compact.uniq.each(&:close)
|
|
69
|
-
Process.setsid
|
|
70
|
-
child_input.ioctl(0x540E, 0) if interactive # Linux TIOCSCTTY
|
|
71
|
-
$stdin.reopen(child_input)
|
|
72
|
-
$stdout.reopen(child_output)
|
|
73
|
-
$stderr.reopen(child_error || child_output)
|
|
74
|
-
[child_input, child_output, child_error].compact.uniq.each(&:close)
|
|
75
|
-
Process.groups = executor.fetch("groups")
|
|
76
|
-
Process::GID.change_privilege(executor.fetch("gid"))
|
|
77
|
-
Process::UID.change_privilege(executor.fetch("uid"))
|
|
78
|
-
File.umask(0o077)
|
|
79
|
-
prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
|
|
80
|
-
[Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG], Fiddle::TYPE_INT)
|
|
81
|
-
raise SecurityError, "cannot inhibit privilege gain" unless prctl.call(38, 1, 0, 0, 0).zero?
|
|
82
|
-
Dir.chdir(executor.fetch("cwd"))
|
|
83
|
-
ready_write.write("R")
|
|
84
|
-
ready_write.close
|
|
85
|
-
exit! 125 unless IO.select([gate_read], nil, nil, 35) && gate_read.read(1) == "E"
|
|
86
|
-
gate_read.close
|
|
87
|
-
argv = definition.fetch("argv")
|
|
88
|
-
exec(environment, [File.expand_path(argv.first), argv.first], *argv.drop(1), unsetenv_others: true)
|
|
89
|
-
rescue Exception
|
|
90
|
-
exit! 126
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
[child_input, child_output, child_error, gate_read, ready_write].compact.uniq.each(&:close)
|
|
94
|
-
unless IO.select([ready_read], nil, nil, 10) && ready_read.read(1) == "R"
|
|
95
|
-
raise SecurityError, "child did not reach admission gate"
|
|
96
|
-
end
|
|
97
|
-
ready_read.close
|
|
98
|
-
witness = child_witness(child)
|
|
99
|
-
bound = @owner.call("command_bind", {"execution_id" => id, "child" => witness}, mutation_id: "command-bind.#{id}")
|
|
100
|
-
raise SecurityError, "binding replay cannot execute" if bound.replayed
|
|
101
|
-
gate_write.write("E")
|
|
102
|
-
gate_write.close
|
|
103
|
-
send_frame(socket, {"type" => "started", "execution_id" => id})
|
|
104
|
-
outputs = {parent_output => interactive ? "terminal" : "stdout"}
|
|
105
|
-
outputs[parent_error] = "stderr" if parent_error
|
|
106
|
-
pump(socket, parent_input, outputs, child, interactive)
|
|
107
|
-
scope.cleanup!(leader: child)
|
|
108
|
-
_, result = Process.waitpid2(child)
|
|
109
|
-
reaped = true
|
|
110
|
-
termination = {"exit_status" => result.exitstatus, "signal" => result.termsig}
|
|
111
|
-
@owner.call("command_complete", {"execution_id" => id, "result" => termination}, mutation_id: "command-complete.#{id}")
|
|
112
|
-
send_frame(socket, termination.merge("type" => "result", "execution_id" => id))
|
|
113
|
-
result.exitstatus || 128 + result.termsig
|
|
114
|
-
ensure
|
|
115
|
-
[gate_write, ready_read, parent_input, parent_output, parent_error].compact.uniq.each { |io| io.close unless io.closed? }
|
|
116
|
-
if child && !reaped
|
|
117
|
-
scope.cleanup!(leader: child)
|
|
118
|
-
deadline = monotonic + 5
|
|
119
|
-
loop do
|
|
120
|
-
break if Process.waitpid(child, Process::WNOHANG)
|
|
121
|
-
break if monotonic >= deadline
|
|
122
|
-
IO.select(nil, nil, nil, 0.01)
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def pump(socket, input, outputs, child, interactive)
|
|
128
|
-
pending = "".b
|
|
129
|
-
pending_input = "".b
|
|
130
|
-
input_open = true
|
|
131
|
-
input_finished = false
|
|
132
|
-
terminal_at = nil
|
|
133
|
-
loop do
|
|
134
|
-
terminal_at ||= monotonic if zombie?(child)
|
|
135
|
-
break if terminal_at && (outputs.empty? || monotonic - terminal_at >= 1)
|
|
136
|
-
readers = (pending_input.bytesize < 131_072 ? [socket] : []) + outputs.keys
|
|
137
|
-
ready = IO.select(readers, pending_input.empty? || input.closed? ? [] : [input], nil, 0.1)
|
|
138
|
-
next unless ready
|
|
139
|
-
if ready.first.include?(socket)
|
|
140
|
-
bytes = socket.read_nonblock(65_536, exception: false)
|
|
141
|
-
next if bytes == :wait_readable
|
|
142
|
-
raise IOError, "command client disconnected" unless bytes
|
|
143
|
-
pending << bytes
|
|
144
|
-
raise ArgumentError, "command frame exceeds bound" if pending.bytesize > 131_072
|
|
145
|
-
while (newline = pending.index("\n"))
|
|
146
|
-
frame = JSON.parse(pending.slice!(0, newline + 1))
|
|
147
|
-
case frame.fetch("type")
|
|
148
|
-
when "input"
|
|
149
|
-
strict!(frame, %w[type data])
|
|
150
|
-
raise ArgumentError, "command input after EOF" unless input_open
|
|
151
|
-
pending_input << Base64.strict_decode64(frame.fetch("data"))
|
|
152
|
-
raise ArgumentError, "command buffered input exceeds bound" if pending_input.bytesize > 131_072
|
|
153
|
-
when "resize"
|
|
154
|
-
strict!(frame, %w[type rows cols])
|
|
155
|
-
raise ArgumentError, "resize requires a terminal" unless interactive
|
|
156
|
-
input.winsize = geometry(frame)
|
|
157
|
-
when "eof"
|
|
158
|
-
strict!(frame, %w[type])
|
|
159
|
-
raise ArgumentError, "duplicate input EOF" unless input_open
|
|
160
|
-
pending_input << "\x04" if interactive
|
|
161
|
-
input_open = false
|
|
162
|
-
else raise ArgumentError, "invalid command input"
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
if ready[1].include?(input)
|
|
167
|
-
written = input.write_nonblock(pending_input, exception: false)
|
|
168
|
-
pending_input.slice!(0, written) if written.is_a?(Integer)
|
|
169
|
-
end
|
|
170
|
-
if !input_open && pending_input.empty? && !input_finished
|
|
171
|
-
input.close unless interactive
|
|
172
|
-
input_finished = true
|
|
173
|
-
end
|
|
174
|
-
outputs.keys.each do |output|
|
|
175
|
-
next unless ready.first.include?(output)
|
|
176
|
-
begin
|
|
177
|
-
bytes = output.read_nonblock(65_536, exception: false)
|
|
178
|
-
rescue Errno::EIO
|
|
179
|
-
bytes = nil
|
|
180
|
-
end
|
|
181
|
-
next if bytes == :wait_readable
|
|
182
|
-
if bytes
|
|
183
|
-
send_frame(socket, {"type" => "output", "stream" => outputs.fetch(output), "data" => Base64.strict_encode64(bytes)})
|
|
184
|
-
else
|
|
185
|
-
outputs.delete(output)
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def child_witness(pid)
|
|
192
|
-
observer = Ace::Runtime::Molecules::ProcessIdentity.new(executor:
|
|
193
|
-
Ace::Herdr::Molecules::BoundedObservation.new(deadline: monotonic + 5))
|
|
194
|
-
identity = observer.capture(pid)
|
|
195
|
-
raise SecurityError, "child generation unavailable" unless identity
|
|
196
|
-
status = File.readlines("/proc/#{pid}/status").filter_map { |line| line.include?(":") && line.split(":", 2) }.to_h
|
|
197
|
-
identity.merge("gid" => status.fetch("Gid").split.first.to_i, "groups" => status.fetch("Groups").split.map(&:to_i).sort,
|
|
198
|
-
"cwd" => File.readlink("/proc/#{pid}/cwd"), "process_group" => Process.getpgid(pid),
|
|
199
|
-
"no_new_privs" => Integer(status.fetch("NoNewPrivs").strip, 10),
|
|
200
|
-
"capabilities" => %w[CapInh CapPrm CapEff CapAmb].to_h { |key| [key, status.fetch(key).strip] })
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def zombie?(pid) = File.read("/proc/#{pid}/stat").split(")").last.split.first == "Z"
|
|
204
|
-
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
205
|
-
def geometry(value)
|
|
206
|
-
rows, cols = value.values_at("rows", "cols")
|
|
207
|
-
raise ArgumentError, "invalid command geometry" unless [rows, cols].all? { |v| v.is_a?(Integer) && v.between?(1, 65_535) }
|
|
208
|
-
[rows, cols]
|
|
209
|
-
end
|
|
210
|
-
def strict!(value, keys)
|
|
211
|
-
raise ArgumentError, "invalid command fields" unless value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
212
|
-
end
|
|
213
|
-
def send_frame(socket, frame)
|
|
214
|
-
Ace::Runtime::Molecules::ProtectedSocket.write(socket, frame,
|
|
215
|
-
deadline: Ace::Runtime::Molecules::ProtectedSocket.deadline(5), limit: 131_072)
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
end
|
|
219
|
-
end
|
|
220
|
-
end
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
require "fileutils"
|
|
6
|
-
require "base64"
|
|
7
|
-
require "io/console"
|
|
8
|
-
require "ace/assign/authority/ordinary_client"
|
|
9
|
-
require_relative "../molecules/ordinary_owner_config"
|
|
10
|
-
require_relative "../molecules/command_policy"
|
|
11
|
-
|
|
12
|
-
module Ace
|
|
13
|
-
module Lab
|
|
14
|
-
module Organisms
|
|
15
|
-
class CommandRunClient
|
|
16
|
-
def initialize(project:)
|
|
17
|
-
@project = project
|
|
18
|
-
@config = Molecules::OrdinaryOwnerConfig.load(project: project)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def owner
|
|
22
|
-
Ace::Assign::Authority::OrdinaryClient.new(project_id: @project, service: @config.service,
|
|
23
|
-
verify_selection: @config.method(:verify_owner!))
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def status(reference)
|
|
27
|
-
owner.call("command_status", {"execution_id" => reference}).data
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def run(argv:, executor:, services:, pane: nil)
|
|
31
|
-
id = SecureRandom.hex(16)
|
|
32
|
-
selection = {"argv" => argv, "executor" => executor, "services" => services, "pane" => pane}
|
|
33
|
-
request = {"project" => @project, "execution_id" => id, "selection" => selection,
|
|
34
|
-
"mutation_id" => "command-admit.#{id}"}
|
|
35
|
-
parent = File.expand_path(".ace-local/lab-commands")
|
|
36
|
-
FileUtils.mkdir_p(parent, mode: 0o700)
|
|
37
|
-
directory = File.join(parent, id)
|
|
38
|
-
Dir.mkdir(directory, 0o700)
|
|
39
|
-
File.open(File.join(directory, "request.json"), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
40
|
-
file.write(JSON.generate(request) + "\n")
|
|
41
|
-
file.flush
|
|
42
|
-
file.fsync
|
|
43
|
-
end
|
|
44
|
-
File.open(directory, File::RDONLY) { |parent_io| parent_io.fsync }
|
|
45
|
-
$stderr.puts "Execution #{id}; inspect with ace-lab run-status --project #{@project} --reference #{id}"
|
|
46
|
-
reply = owner.call("command_admit", {"execution_id" => id, "selection" => selection}, mutation_id: request.fetch("mutation_id"))
|
|
47
|
-
raise Ace::Assign::AttemptErrors::Conflict, "original command admission must be reconciled" if reply.replayed
|
|
48
|
-
stream(id)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
private
|
|
52
|
-
|
|
53
|
-
def stream(id)
|
|
54
|
-
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
55
|
-
policy = Molecules::CommandPolicy.load(project: @project)
|
|
56
|
-
path = policy.launcher.fetch("socket_path")
|
|
57
|
-
wire.root_path!(File.dirname(path), directory: true)
|
|
58
|
-
endpoint = wire.socket_identity(path, mode: 0o660)
|
|
59
|
-
raise SecurityError, "command launcher endpoint owner differs" unless endpoint.last.zero?
|
|
60
|
-
expected = JSON.parse(Molecules::GrantResolver.read_verified(policy.launcher.fetch("identity_path")) || "null")
|
|
61
|
-
wire.connect(path, deadline: wire.deadline(5)) do |socket|
|
|
62
|
-
peer = Ace::Assign::Authority::OrdinaryPeer.new.capture(socket)
|
|
63
|
-
unless expected.is_a?(Hash) && expected["schema"] == "labd-command-launcher/v1" && expected["identity"] == peer
|
|
64
|
-
raise SecurityError, "command launcher generation differs"
|
|
65
|
-
end
|
|
66
|
-
interactive = $stdin.tty? && $stdout.tty?
|
|
67
|
-
rows, cols = interactive ? $stdin.winsize : [24, 80]
|
|
68
|
-
wire.write(socket, {"op" => "command_run", "project" => @project, "execution_id" => id,
|
|
69
|
-
"tty" => interactive, "rows" => rows, "cols" => cols}, deadline: wire.deadline(5), limit: 16_384)
|
|
70
|
-
if interactive
|
|
71
|
-
$stdin.raw { pump(socket, id, interactive: true) }
|
|
72
|
-
else
|
|
73
|
-
pump(socket, id, interactive: false)
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def pump(socket, id, interactive:)
|
|
79
|
-
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
80
|
-
ingress = wire::Ingress.new(socket)
|
|
81
|
-
inputs = [socket]
|
|
82
|
-
resize = false
|
|
83
|
-
old_winch = Signal.trap("WINCH") { resize = true } if interactive
|
|
84
|
-
loop do
|
|
85
|
-
if resize
|
|
86
|
-
resize = false
|
|
87
|
-
rows, cols = $stdin.winsize
|
|
88
|
-
wire.write(socket, {"type" => "resize", "rows" => rows, "cols" => cols}, deadline: wire.deadline(5), limit: 131_072)
|
|
89
|
-
end
|
|
90
|
-
ready = IO.select(inputs, nil, nil, 0.25)
|
|
91
|
-
next unless ready
|
|
92
|
-
if ready.first.include?(socket)
|
|
93
|
-
frame = wire.read(ingress, deadline: wire.deadline(5), limit: 131_072)
|
|
94
|
-
case frame["type"]
|
|
95
|
-
when "started"
|
|
96
|
-
raise SecurityError, "invalid command start frame" unless frame.keys.sort == %w[execution_id type]
|
|
97
|
-
raise SecurityError, "command reference differs" unless frame["execution_id"] == id
|
|
98
|
-
inputs << $stdin unless inputs.include?($stdin)
|
|
99
|
-
when "output"
|
|
100
|
-
raise SecurityError, "invalid command output frame" unless frame.keys.sort == %w[data stream type] && %w[stdout stderr terminal].include?(frame["stream"])
|
|
101
|
-
destination = frame["stream"] == "stderr" ? $stderr : $stdout
|
|
102
|
-
destination.write(Base64.strict_decode64(frame.fetch("data")))
|
|
103
|
-
destination.flush
|
|
104
|
-
when "result"
|
|
105
|
-
raise SecurityError, "invalid command result frame" unless frame.keys.sort == %w[execution_id exit_status signal type]
|
|
106
|
-
raise SecurityError, "command reference differs" unless frame["execution_id"] == id
|
|
107
|
-
exited, signal = frame.values_at("exit_status", "signal")
|
|
108
|
-
if exited.is_a?(Integer) && exited.between?(0, 255) && signal.nil?
|
|
109
|
-
return exited
|
|
110
|
-
elsif exited.nil? && signal.is_a?(Integer) && signal.between?(1, 127)
|
|
111
|
-
return 128 + signal
|
|
112
|
-
end
|
|
113
|
-
raise SecurityError, "command termination is invalid"
|
|
114
|
-
else
|
|
115
|
-
raise SecurityError, "command stream is incomplete"
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
next unless ready.first.include?($stdin)
|
|
119
|
-
bytes = $stdin.read_nonblock(8192, exception: false)
|
|
120
|
-
next if bytes == :wait_readable
|
|
121
|
-
frame = bytes ? {"type" => "input", "data" => Base64.strict_encode64(bytes)} : {"type" => "eof"}
|
|
122
|
-
wire.write(socket, frame, deadline: wire.deadline(5), limit: 131_072)
|
|
123
|
-
inputs.delete($stdin) unless bytes
|
|
124
|
-
end
|
|
125
|
-
ensure
|
|
126
|
-
Signal.trap("WINCH", old_winch) if old_winch
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
end
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "securerandom"
|
|
4
|
-
require "ace/assign/authority/retained_input"
|
|
5
|
-
require "ace/assign/authority/ordinary_client"
|
|
6
|
-
require "ace/assign/authority/ordinary_lifecycle"
|
|
7
|
-
require "ace/assign/organisms/prepared_work_builder"
|
|
8
|
-
require_relative "../molecules/ordinary_owner_config"
|
|
9
|
-
|
|
10
|
-
module Ace
|
|
11
|
-
module Lab
|
|
12
|
-
module Organisms
|
|
13
|
-
# File inputs retain the exact mutation and bytes across response loss.
|
|
14
|
-
# Only Assign's installed owner registers, advances and accepts execution.
|
|
15
|
-
class OrdinaryAssignmentClient
|
|
16
|
-
A = Ace::Assign::Authority
|
|
17
|
-
Files = A::RetainedInput
|
|
18
|
-
REQUEST_KEYS = %w[version project_id operation mutation_id params parts].freeze
|
|
19
|
-
|
|
20
|
-
def initialize(project:, config: nil, client_factory: nil)
|
|
21
|
-
@project = project
|
|
22
|
-
@config = config || Molecules::OrdinaryOwnerConfig.load(project: project)
|
|
23
|
-
unless @config.is_a?(Molecules::OrdinaryOwnerConfig) && @config.project == project
|
|
24
|
-
raise ArgumentError, "ordinary assignment project selection differs"
|
|
25
|
-
end
|
|
26
|
-
@client_factory = client_factory || ->(**options) { A::OrdinaryClient.new(**options) }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Preparation produces input only, so it is also available on the host.
|
|
30
|
-
# Reusing an existing output directory never rebuilds or resubmits work.
|
|
31
|
-
def self.prepare(project:, task:, pane:, output:, dependency_reports: [])
|
|
32
|
-
unless project.is_a?(String) && Molecules::OrdinaryOwnerConfig::ID.match?(project) &&
|
|
33
|
-
pane.is_a?(String) && Molecules::OrdinaryOwnerConfig::PANE.match?(pane)
|
|
34
|
-
raise ArgumentError, "preparation requires project and native pane selectors"
|
|
35
|
-
end
|
|
36
|
-
directory = Files.create_output(output)
|
|
37
|
-
prepared = Ace::Assign::Organisms::PreparedWorkBuilder.new(export_root: directory)
|
|
38
|
-
.call(task_ref: task, project_id: project, dependency_reports: dependency_reports)
|
|
39
|
-
definition = JSON.parse(prepared.fetch("definition_bytes"))
|
|
40
|
-
reference = definition.fetch("prepared_work")
|
|
41
|
-
request = {"version" => 1, "project_id" => project, "operation" => "register_assignment",
|
|
42
|
-
"mutation_id" => SecureRandom.hex(16), "params" => {
|
|
43
|
-
"assignment_id" => prepared.fetch("assignment_id"), "pane" => pane, "expected_generation" => 0,
|
|
44
|
-
"definition_digest" => Digest::SHA256.hexdigest(prepared.fetch("definition_bytes")),
|
|
45
|
-
"prepared_head" => prepared.fetch("head"), "prepared_tree" => prepared.fetch("tree"),
|
|
46
|
-
"manifest_sha256" => reference.fetch("manifest_sha256")},
|
|
47
|
-
"parts" => [{"file" => "prepared.bundle", "bytes" => prepared.fetch("bytes"), "sha256" => prepared.fetch("sha256")}]}
|
|
48
|
-
Files.write_file(directory, "definition.json", prepared.fetch("definition_bytes"))
|
|
49
|
-
Files.write_file(directory, "prepared.bundle", prepared.fetch("bundle"))
|
|
50
|
-
Files.write_file(directory, "request.json", JSON.generate(request) + "\n")
|
|
51
|
-
{"project_id" => project, "assignment_id" => prepared.fetch("assignment_id"),
|
|
52
|
-
"scope" => prepared.fetch("scope"), "request" => File.join(directory, "request.json"), "prepared" => true}
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def request(input:, output: nil)
|
|
56
|
-
request = JSON.parse(Files.read_file(input, 16_384), allow_duplicate_key: false, create_additions: false, max_nesting: 24)
|
|
57
|
-
unless request.is_a?(Hash) && request.keys.sort == REQUEST_KEYS.sort && request["version"].is_a?(Integer) && request["version"] == 1 &&
|
|
58
|
-
request["project_id"] == @project && A::OrdinaryLifecycle::OPERATIONS.include?(request["operation"]) &&
|
|
59
|
-
request["params"].is_a?(Hash) && !request["params"].key?("transfer") && request["parts"].is_a?(Array)
|
|
60
|
-
raise ArgumentError, "retained ordinary request selection is invalid"
|
|
61
|
-
end
|
|
62
|
-
operation = request.fetch("operation")
|
|
63
|
-
read = %w[registration_status assignment_snapshot result_status result_snapshot supervisor_status export_candidate review_status merge_evidence].include?(operation)
|
|
64
|
-
mutation = request.fetch("mutation_id")
|
|
65
|
-
unless read ? mutation.nil? : mutation.is_a?(String) && Ace::Assign::Molecules::JournalMutation::ID.match?(mutation)
|
|
66
|
-
raise ArgumentError, "retained mutation ID must be explicit; read operations require null"
|
|
67
|
-
end
|
|
68
|
-
transfer = A::OrdinaryLifecycle::TRANSFER_OPERATIONS[operation]
|
|
69
|
-
download = transfer && transfer.fetch(:direction) == :download
|
|
70
|
-
upload = transfer && transfer.fetch(:direction) == :upload
|
|
71
|
-
unless (!!output == !!download) && (request["parts"].empty? || upload)
|
|
72
|
-
raise ArgumentError, "download requires a new --output directory; only uploads accept parts"
|
|
73
|
-
end
|
|
74
|
-
parts = upload && read_parts(request.fetch("parts"), input, transfer.fetch(:purpose))
|
|
75
|
-
# No local metadata is interpreted as successful execution. Download
|
|
76
|
-
# files are merely a projection of the authenticated canonical reply.
|
|
77
|
-
directory = download && Files.create_output(output)
|
|
78
|
-
client = @client_factory.call(project_id: @project, service: @config.service,
|
|
79
|
-
verify_selection: @config.method(:verify_owner!), scratch_root: directory || nil)
|
|
80
|
-
reply = client.call(operation, request.fetch("params"), mutation_id: request.fetch("mutation_id"),
|
|
81
|
-
upload_parts: parts || nil, download: !!download, purpose: transfer && transfer.fetch(:purpose))
|
|
82
|
-
envelope = {"status" => "ok", "data" => reply.data, "transport" => {"replayed" => reply.replayed}}
|
|
83
|
-
if download
|
|
84
|
-
files = reply.parts.each_with_index.map do |bytes, index|
|
|
85
|
-
name = format("part-%02d.bytes", index)
|
|
86
|
-
Files.write_file(directory, name, bytes)
|
|
87
|
-
{"file" => File.join(directory, name), "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)}
|
|
88
|
-
end
|
|
89
|
-
envelope["files"] = files
|
|
90
|
-
Files.write_file(directory, "response.json", JSON.generate(envelope) + "\n")
|
|
91
|
-
end
|
|
92
|
-
envelope
|
|
93
|
-
rescue JSON::ParserError
|
|
94
|
-
raise ArgumentError, "retained request is not valid bounded JSON"
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
private
|
|
98
|
-
|
|
99
|
-
def read_parts(entries, input, purpose)
|
|
100
|
-
total, count, per_part = A::TransferCodec::LIMITS.fetch(purpose)
|
|
101
|
-
unless entries.size.between?(1, count) && entries.all? { |entry|
|
|
102
|
-
entry.is_a?(Hash) && entry.keys.sort == %w[bytes file sha256] && entry["file"].is_a?(String) &&
|
|
103
|
-
entry["file"].bytesize.between?(1, 4096) && !entry["file"].include?("\0") &&
|
|
104
|
-
entry["bytes"].is_a?(Integer) && entry["bytes"].between?(0, per_part) &&
|
|
105
|
-
entry["sha256"].is_a?(String) && A::TransferCodec::SHA256.match?(entry["sha256"])
|
|
106
|
-
} && entries.sum { |entry| entry.fetch("bytes") } <= total
|
|
107
|
-
raise ArgumentError, "retained upload inventory is invalid"
|
|
108
|
-
end
|
|
109
|
-
entries.map do |entry|
|
|
110
|
-
bytes = Files.read_file(File.expand_path(entry.fetch("file"), File.dirname(File.expand_path(input))), per_part)
|
|
111
|
-
unless bytes.bytesize == entry.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == entry.fetch("sha256")
|
|
112
|
-
raise ArgumentError, "retained upload bytes changed; preserve the original mutation"
|
|
113
|
-
end
|
|
114
|
-
bytes
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
end
|
|
@@ -1,153 +0,0 @@
|
|
|
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/caller_authorizer"
|
|
12
|
-
require_relative "../molecules/command_owner_admission"
|
|
13
|
-
require_relative "../molecules/ordinary_listener"
|
|
14
|
-
require_relative "ordinary_merge_receiver"
|
|
15
|
-
|
|
16
|
-
module Ace
|
|
17
|
-
module Lab
|
|
18
|
-
module Organisms
|
|
19
|
-
# Ordinary delivery has one installed journal writer and exclusion owner.
|
|
20
|
-
# Existing Assign, HITL and merge protocols share that process lifetime;
|
|
21
|
-
# no requester cache or daemon working directory chooses accepted state.
|
|
22
|
-
class OrdinaryAuthorityComposition
|
|
23
|
-
def initialize(receiver_config:, owner_config: nil)
|
|
24
|
-
unless receiver_config.is_a?(Molecules::OrdinaryReceiverConfig)
|
|
25
|
-
raise ArgumentError, "ordinary composition needs installed receiver selection"
|
|
26
|
-
end
|
|
27
|
-
@receiver = receiver_config
|
|
28
|
-
@config = owner_config || Molecules::OrdinaryOwnerConfig.load(project: @receiver.project)
|
|
29
|
-
unless @config.is_a?(Molecules::OrdinaryOwnerConfig)
|
|
30
|
-
raise ArgumentError, "ordinary composition needs installed assignment selection"
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def build
|
|
35
|
-
@config.verify_listener!
|
|
36
|
-
policy_document = Molecules::GrantResolver.trusted_document(@receiver.policy_path)
|
|
37
|
-
verify_join!(policy_document)
|
|
38
|
-
admission = Molecules::OrdinaryOwnerAdmission.new(config: @config)
|
|
39
|
-
reader = nil
|
|
40
|
-
journal = Ace::Assign::Molecules::EvidenceJournal.new(repo_root: @config.journal.fetch("repository"),
|
|
41
|
-
ref: @config.journal.fetch("ref"), checkout_root: @config.journal.fetch("checkout_root"),
|
|
42
|
-
evidence_reader: ->(reference, record, state, pending) { reader.call(reference, record, state, pending) })
|
|
43
|
-
reader = Ace::Assign::Authority::ServiceEvidence.new(journal: journal)
|
|
44
|
-
exclusion = Ace::Assign::Molecules::LifecycleExclusion.new(root: @config.lifecycle_root)
|
|
45
|
-
command_admission = Molecules::CommandOwnerAdmission.new(base: admission, project: @config.project)
|
|
46
|
-
command = Ace::Assign::Authority::CommandExecution.new(project: @config.project, journal: journal,
|
|
47
|
-
admission: command_admission)
|
|
48
|
-
coordinator = nil
|
|
49
|
-
lifecycle = Ace::Assign::Authority::OrdinaryLifecycle.new(project: @config.project,
|
|
50
|
-
admission: admission, journal: journal, exclusion: exclusion, input_root: @config.assignment_root,
|
|
51
|
-
command_evidence: command,
|
|
52
|
-
release_authorizer: ->(binding) { authorize_release_artifact!(binding, coordinator) })
|
|
53
|
-
coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(cache_base: @config.assignment_root,
|
|
54
|
-
repo_root: @config.journal.fetch("repository"), journal: journal, lifecycle_exclusion: exclusion,
|
|
55
|
-
recorded_binding_observer: admission.method(:recorded_binding!), ordinary_merge_evidence: lifecycle)
|
|
56
|
-
router = Ace::Assign::Authority::Router.new(handlers: [lifecycle, command])
|
|
57
|
-
merge = OrdinaryMergeReceiver.new(config: @receiver, coordinator: coordinator,
|
|
58
|
-
policy_document: policy_document, owner_guard: -> { @config.selected_principal!(@receiver.principal_uid) },
|
|
59
|
-
merge_factory: ->(document) {
|
|
60
|
-
Molecules::OrdinaryMergeRead.new(repo_path: @receiver.repo_path, base_ref: document.fetch("base_ref"),
|
|
61
|
-
root: @receiver.repo_path, common_dir: @config.worktrees.fetch("common_dir"))
|
|
62
|
-
})
|
|
63
|
-
listener = Molecules::OrdinaryListener.new(socket_path: @receiver.socket_path,
|
|
64
|
-
peer_uid: @receiver.principal_uid, socket_gid: @receiver.socket_gid, handler: merge,
|
|
65
|
-
logger: ->(message) { warn message })
|
|
66
|
-
Ace::Assign::Authority::OrdinaryServer.new(admission: command_admission, lifecycle: router,
|
|
67
|
-
services: hitl_services(coordinator) + [listener])
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
private
|
|
71
|
-
|
|
72
|
-
# Retention reads the existing exact release decision. It neither
|
|
73
|
-
# consumes an authorization nor dispatches a publication effect.
|
|
74
|
-
def authorize_release_artifact!(binding, coordinator)
|
|
75
|
-
@config.verify_owner!
|
|
76
|
-
document = Molecules::GrantResolver.trusted_document(@receiver.policy_path)
|
|
77
|
-
verify_join!(document)
|
|
78
|
-
uid = binding.fetch("caller_uid")
|
|
79
|
-
unless uid.is_a?(Integer) && uid.positive? && binding.fetch("project_id") == @config.project
|
|
80
|
-
raise SecurityError, "release artifact caller or project differs"
|
|
81
|
-
end
|
|
82
|
-
username = begin
|
|
83
|
-
Etc.getpwuid(uid)&.name
|
|
84
|
-
rescue ArgumentError
|
|
85
|
-
nil
|
|
86
|
-
end
|
|
87
|
-
visibility = Molecules::CallerAuthorizer.new(principals: document.fetch("principals", {}),
|
|
88
|
-
identity: Molecules::CallerAuthorizer.matchable_identities(username, uid))
|
|
89
|
-
raise SecurityError, "release artifact project grant is unavailable" unless visibility.authorized?(@config.project)
|
|
90
|
-
Molecules::ServicePolicy.new(document, coordinator.method(:proposal_authorization))
|
|
91
|
-
.authorize!(binding.fetch("authorization"), binding)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def verify_join!(document)
|
|
95
|
-
configured = Molecules::ServicePolicy.new(document).ordinary_receiver!(@config.project)
|
|
96
|
-
expected = {"receiver_id" => @receiver.receiver_id, "socket_path" => @receiver.socket_path,
|
|
97
|
-
"receiver_uid" => @config.service.fetch("uid"), "principal" => @receiver.principal,
|
|
98
|
-
"journal_repository" => @config.journal.fetch("repository"), "journal_ref" => @config.journal.fetch("ref")}
|
|
99
|
-
unless @receiver.project == @config.project && @receiver.receiver_uid == @config.service.fetch("uid") &&
|
|
100
|
-
configured && configured.slice(*expected.keys) == expected &&
|
|
101
|
-
@config.participants.key?(@receiver.principal_uid.to_s) &&
|
|
102
|
-
![@config.service.fetch("socket_path"), @config.native.fetch("socket_path"), @config.hitl.fetch("socket_path")].include?(@receiver.socket_path)
|
|
103
|
-
raise InvalidConfigurationError, "ordinary receiver and assignment owner selections differ"
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def hitl_services(coordinator)
|
|
108
|
-
selected = @config.hitl
|
|
109
|
-
policy = Ace::Hitl::Providers::Lab.grants_policy(grants_path: selected.fetch("grants_path"))
|
|
110
|
-
unless policy.service_uid == @config.service.fetch("uid")
|
|
111
|
-
raise InvalidConfigurationError, "ordinary HITL service principal differs from assignment owner"
|
|
112
|
-
end
|
|
113
|
-
guard = lambda do
|
|
114
|
-
@config.verify_owner!
|
|
115
|
-
unless policy.service_uid == @config.service.fetch("uid")
|
|
116
|
-
raise Ace::Hitl::Lifecycle::BindingError, "installed HITL service principal changed"
|
|
117
|
-
end
|
|
118
|
-
rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error => error
|
|
119
|
-
raise Ace::Hitl::Lifecycle::BindingError, error.message
|
|
120
|
-
end
|
|
121
|
-
binding = Ace::Hitl::Providers::Lab::AssignmentBinding.new(coordinator: coordinator,
|
|
122
|
-
project: @config.project, owner_guard: guard)
|
|
123
|
-
# Unix requesters and the existing Hermes mTLS transport share the
|
|
124
|
-
# same owner and one in-memory OTP vault. No second daemon opens the
|
|
125
|
-
# journal and no synthetic Unix principal represents a certificate.
|
|
126
|
-
vault = Ace::Hitl::Lifecycle::OtpVault::MemoryVault.new
|
|
127
|
-
services = [Ace::Hitl::Lifecycle::Service.new(root: selected.fetch("root"),
|
|
128
|
-
socket_path: selected.fetch("socket_path"), group: selected.fetch("group"),
|
|
129
|
-
binding: binding, policy: policy, vault: vault)]
|
|
130
|
-
if selected.key?("tls_authority")
|
|
131
|
-
authority = Ace::Hitl::Lifecycle::TlsAuthority.load(selected.fetch("tls_authority"))
|
|
132
|
-
verify_tls_identity!(authority)
|
|
133
|
-
services << Ace::Hitl::Lifecycle::TlsService.new(root: selected.fetch("root"),
|
|
134
|
-
binding: binding, project: @config.project, credential: authority.credential,
|
|
135
|
-
ssl_context: authority.ssl_context!, listen: authority.listen, port: authority.port,
|
|
136
|
-
group: selected.fetch("group"), vault: vault, decision_policy: policy)
|
|
137
|
-
end
|
|
138
|
-
services
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def verify_tls_identity!(authority)
|
|
142
|
-
unless authority.project_id == @config.project &&
|
|
143
|
-
Etc.getpwnam(authority.service_user).uid == @config.service.fetch("uid") &&
|
|
144
|
-
Etc.getgrnam(authority.service_group).gid == @config.service.fetch("gid")
|
|
145
|
-
raise InvalidConfigurationError, "ordinary HITL TLS project or service identity differs"
|
|
146
|
-
end
|
|
147
|
-
rescue ArgumentError
|
|
148
|
-
raise InvalidConfigurationError, "ordinary HITL TLS service account is unavailable"
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
end
|