stack-service-base 0.0.92 → 0.0.93
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/lib/stack-service-base/project_template/gitlab-c/.gitlab-ci.yml +1 -0
- data/lib/stack-service-base/project_template/home/AGENTS.md +2 -1
- data/lib/stack-service-base/safe_exec.rb +359 -0
- data/lib/stack-service-base/version.rb +1 -1
- data/lib/stack-service-base.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d44d5ba201a8fd56ce89f29cd4918bae9b121fc011e3f480ead2b1d7b884f017
|
|
4
|
+
data.tar.gz: 14328155c925dfc5e8f1c71eb75b12ae7d2ae157ad160839dc130d522b8ac2a7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca55bce6c2d68ac6ac5632cc423c56e7d6353d831a2bf5020089963ba7f094bc3cba411033b190d668bf2aa36dcea205f5ba62f385b91e5421d6b515edbf1d5c
|
|
7
|
+
data.tar.gz: 8d9486558f823941e2a89e60133788ea6d0f4d60d99638abd3991a6fb0a0b818cd2e947a1db2364124edf7d6e277821bb76acca9b48811d5ee76db4c509afc2e
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# SafeExec provides two related safety wrappers for Unix-like systems:
|
|
2
|
+
# 1. Run a Ruby block in an isolated fork with a hard timeout.
|
|
3
|
+
# 2. Run an external command with captured output, streaming callbacks,
|
|
4
|
+
# process-group cleanup, and optional raising "bang" variants.
|
|
5
|
+
#
|
|
6
|
+
# Design notes:
|
|
7
|
+
# - Timeouts are enforced with a monotonic clock.
|
|
8
|
+
# - Child work is placed into its own process group so timeout handling can
|
|
9
|
+
# terminate the whole subtree, not just the direct child.
|
|
10
|
+
# - `call`/`call_result` return values must be Marshal-serializable because
|
|
11
|
+
# data is sent from the child process to the parent over a pipe.
|
|
12
|
+
# - `capture` yields streamed output as `|stream, chunk|`, where `stream` is
|
|
13
|
+
# `:stdout` or `:stderr`.
|
|
14
|
+
module SafeExec
|
|
15
|
+
TERM_GRACE_SECONDS = 5
|
|
16
|
+
|
|
17
|
+
# Result of block execution via `call_result`.
|
|
18
|
+
#
|
|
19
|
+
# Fields:
|
|
20
|
+
# - ok: true when the child block completed without raising
|
|
21
|
+
# - result: block return value, if execution succeeded
|
|
22
|
+
# - exception: reconstructed exception object, if any
|
|
23
|
+
# - timed_out: true when the child exceeded the deadline
|
|
24
|
+
StepResult = Struct.new(:ok, :result, :exception, :timed_out, keyword_init: true) do
|
|
25
|
+
def success? = ok && !timed_out && exception.nil?
|
|
26
|
+
def timed_out? = !!timed_out
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Result of external command execution via `capture`.
|
|
30
|
+
#
|
|
31
|
+
# Fields:
|
|
32
|
+
# - stdout: collected standard output
|
|
33
|
+
# - stderr: collected standard error
|
|
34
|
+
# - status: Process::Status for completed commands
|
|
35
|
+
# - timed_out: true when the process group was terminated on deadline
|
|
36
|
+
# - exception: wrapper-level error, typically spawn/setup failure
|
|
37
|
+
CommandResult = Struct.new(:stdout, :stderr, :status, :timed_out, :exception, keyword_init: true) do
|
|
38
|
+
def success? = !timed_out && exception.nil? && status&.success?
|
|
39
|
+
def timed_out? = !!timed_out
|
|
40
|
+
def exitstatus = status&.exitstatus
|
|
41
|
+
def out = stdout
|
|
42
|
+
def err = stderr
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class Error < StandardError
|
|
46
|
+
attr_reader :result
|
|
47
|
+
|
|
48
|
+
def initialize(message = nil, result: nil)
|
|
49
|
+
super(message)
|
|
50
|
+
@result = result
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class TimeoutError < Error; end
|
|
55
|
+
class SpawnError < Error; end
|
|
56
|
+
class ExitError < Error; end
|
|
57
|
+
class SerializationError < Error; end
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
def ensure_open3_loaded
|
|
62
|
+
return if defined?(Open3)
|
|
63
|
+
|
|
64
|
+
require 'open3'
|
|
65
|
+
end
|
|
66
|
+
private_class_method :ensure_open3_loaded if respond_to?(:private_class_method)
|
|
67
|
+
|
|
68
|
+
def ensure_timeout_loaded
|
|
69
|
+
return if defined?(Timeout::Error)
|
|
70
|
+
|
|
71
|
+
require 'timeout'
|
|
72
|
+
end
|
|
73
|
+
private_class_method :ensure_timeout_loaded if respond_to?(:private_class_method)
|
|
74
|
+
|
|
75
|
+
# Execute a Ruby block in a forked subprocess.
|
|
76
|
+
#
|
|
77
|
+
# Returns the block result on success.
|
|
78
|
+
# Raises the reconstructed child exception or TimeoutError on failure.
|
|
79
|
+
def call(timeout:, &block)
|
|
80
|
+
ensure_timeout_loaded
|
|
81
|
+
result = call_result(timeout:, &block)
|
|
82
|
+
raise result.exception if result.exception
|
|
83
|
+
|
|
84
|
+
result.result
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Bang alias for `call`.
|
|
88
|
+
def call!(timeout:, &block) = call(timeout:, &block)
|
|
89
|
+
|
|
90
|
+
# Execute a Ruby block in a forked subprocess and return a structured result.
|
|
91
|
+
#
|
|
92
|
+
# This is the non-raising API for isolated block execution.
|
|
93
|
+
# The block return value must be Marshal-serializable.
|
|
94
|
+
def call_result(timeout:, &block)
|
|
95
|
+
ensure_timeout_loaded
|
|
96
|
+
reader, writer = IO.pipe
|
|
97
|
+
pid = fork do
|
|
98
|
+
reader.close
|
|
99
|
+
Process.setpgrp
|
|
100
|
+
|
|
101
|
+
result = block.call
|
|
102
|
+
dump_payload(writer, step_payload(result:))
|
|
103
|
+
rescue => e
|
|
104
|
+
dump_payload(writer, step_payload(exception: e))
|
|
105
|
+
ensure
|
|
106
|
+
writer.close unless writer.closed?
|
|
107
|
+
exit! 0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
writer.close
|
|
111
|
+
timed_out = wait_or_terminate(pid, timeout)
|
|
112
|
+
if timed_out
|
|
113
|
+
return StepResult.new(
|
|
114
|
+
ok: false,
|
|
115
|
+
timed_out: true,
|
|
116
|
+
exception: TimeoutError.new("Timed out after #{timeout}s")
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
payload = Marshal.load(reader)
|
|
121
|
+
StepResult.new(
|
|
122
|
+
ok: payload[:ok],
|
|
123
|
+
result: payload[:result],
|
|
124
|
+
exception: build_exception(payload),
|
|
125
|
+
timed_out: false
|
|
126
|
+
)
|
|
127
|
+
rescue EOFError => e
|
|
128
|
+
StepResult.new(ok: false, exception: e, timed_out: false)
|
|
129
|
+
ensure
|
|
130
|
+
reader.close if reader && !reader.closed?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Run an external command with timeout control and full output capture.
|
|
134
|
+
#
|
|
135
|
+
# When a block is given, chunks are yielded as they arrive:
|
|
136
|
+
# capture("bash", "-lc", "echo hi; echo warn >&2") { |stream, chunk| ... }
|
|
137
|
+
#
|
|
138
|
+
# Yields:
|
|
139
|
+
# - stream: :stdout or :stderr
|
|
140
|
+
# - chunk: String
|
|
141
|
+
#
|
|
142
|
+
# Returns CommandResult and does not raise for non-zero exit status.
|
|
143
|
+
def capture(*cmd, timeout:, &block)
|
|
144
|
+
ensure_open3_loaded
|
|
145
|
+
stdout = +''
|
|
146
|
+
stderr = +''
|
|
147
|
+
status = nil
|
|
148
|
+
|
|
149
|
+
Open3.popen3(*cmd, pgroup: true) do |stdin, child_stdout, child_stderr, wait_thr|
|
|
150
|
+
stdin.close
|
|
151
|
+
pid = wait_thr.pid
|
|
152
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
153
|
+
|
|
154
|
+
until wait_thr.join(0)
|
|
155
|
+
drain_pair(child_stdout, stdout, :stdout, &block)
|
|
156
|
+
drain_pair(child_stderr, stderr, :stderr, &block)
|
|
157
|
+
|
|
158
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
159
|
+
terminate_process_group(pid)
|
|
160
|
+
drain_all(child_stdout, stdout, :stdout, &block)
|
|
161
|
+
drain_all(child_stderr, stderr, :stderr, &block)
|
|
162
|
+
return CommandResult.new(stdout:, stderr:, timed_out: true)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
IO.select([child_stdout, child_stderr], nil, nil, 0.1)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
drain_all(child_stdout, stdout, :stdout, &block)
|
|
169
|
+
drain_all(child_stderr, stderr, :stderr, &block)
|
|
170
|
+
status = wait_thr.value
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
CommandResult.new(stdout:, stderr:, status:, timed_out: false)
|
|
174
|
+
rescue => e
|
|
175
|
+
CommandResult.new(stdout:, stderr:, exception: SpawnError.new(e.message, result: nil), timed_out: false)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Raising variant of `capture`.
|
|
179
|
+
#
|
|
180
|
+
# Raises:
|
|
181
|
+
# - TimeoutError on timeout
|
|
182
|
+
# - SpawnError on wrapper-level execution/setup failure
|
|
183
|
+
# - ExitError on non-zero exit status
|
|
184
|
+
#
|
|
185
|
+
# Returns CommandResult on success.
|
|
186
|
+
def capture!(*cmd, timeout:, &block)
|
|
187
|
+
ensure_timeout_loaded
|
|
188
|
+
result = capture(*cmd, timeout:, &block)
|
|
189
|
+
raise TimeoutError.new("Timed out after #{timeout}s", result:) if result.timed_out?
|
|
190
|
+
raise result.exception if result.exception
|
|
191
|
+
raise ExitError.new(exit_error_message(cmd, result), result:) unless result.status&.success?
|
|
192
|
+
|
|
193
|
+
result
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def run(*cmd, timeout:, &block) = capture(*cmd, timeout:, &block)
|
|
197
|
+
def run!(*cmd, timeout:, &block) = capture!(*cmd, timeout:, &block)
|
|
198
|
+
|
|
199
|
+
# Marshal the child payload back to the parent process.
|
|
200
|
+
# If the block result cannot be serialized, convert it into a structured error
|
|
201
|
+
# so the parent still receives a useful failure.
|
|
202
|
+
def dump_payload(writer, payload)
|
|
203
|
+
Marshal.dump(payload, writer)
|
|
204
|
+
rescue TypeError => e
|
|
205
|
+
serializable = step_payload(
|
|
206
|
+
exception: SerializationError.new("Result is not serializable: #{e.message}")
|
|
207
|
+
)
|
|
208
|
+
Marshal.dump(serializable, writer) rescue nil
|
|
209
|
+
end
|
|
210
|
+
private_class_method :dump_payload if respond_to?(:private_class_method)
|
|
211
|
+
|
|
212
|
+
def step_payload(result: nil, exception: nil)
|
|
213
|
+
{
|
|
214
|
+
ok: exception.nil?,
|
|
215
|
+
result: result,
|
|
216
|
+
exception_class: exception&.class&.name,
|
|
217
|
+
exception_message: exception&.message,
|
|
218
|
+
exception_backtrace: exception&.backtrace
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
private_class_method :step_payload if respond_to?(:private_class_method)
|
|
222
|
+
|
|
223
|
+
# Rebuild a best-effort exception object in the parent process from a payload
|
|
224
|
+
# received over Marshal.
|
|
225
|
+
def build_exception(payload)
|
|
226
|
+
return nil unless payload[:exception_message]
|
|
227
|
+
|
|
228
|
+
klass_name = payload[:exception_class].to_s
|
|
229
|
+
klass = klass_name.empty? ? RuntimeError : Object.const_get(klass_name)
|
|
230
|
+
exception = klass.new(payload[:exception_message])
|
|
231
|
+
exception.set_backtrace(Array(payload[:exception_backtrace])) if exception.respond_to?(:set_backtrace)
|
|
232
|
+
exception
|
|
233
|
+
rescue NameError
|
|
234
|
+
exception = RuntimeError.new(payload[:exception_message])
|
|
235
|
+
exception.set_backtrace(Array(payload[:exception_backtrace])) if exception.respond_to?(:set_backtrace)
|
|
236
|
+
exception
|
|
237
|
+
end
|
|
238
|
+
private_class_method :build_exception if respond_to?(:private_class_method)
|
|
239
|
+
|
|
240
|
+
# Drain as much currently available data as possible from one pipe without
|
|
241
|
+
# blocking, append it to the target buffer, and optionally stream it to the
|
|
242
|
+
# caller-supplied block.
|
|
243
|
+
def drain_pair(io, buffer, stream)
|
|
244
|
+
loop do
|
|
245
|
+
chunk = io.read_nonblock(4096, exception: false)
|
|
246
|
+
case chunk
|
|
247
|
+
when String
|
|
248
|
+
buffer << chunk
|
|
249
|
+
yield stream, chunk if block_given?
|
|
250
|
+
when :wait_readable, nil
|
|
251
|
+
return
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
rescue IOError, Errno::EIO
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
private_class_method :drain_pair if respond_to?(:private_class_method)
|
|
258
|
+
|
|
259
|
+
# Drain the remainder of a finished stream and optionally yield it.
|
|
260
|
+
def drain_all(io, buffer, stream)
|
|
261
|
+
chunk = io.read
|
|
262
|
+
return unless chunk
|
|
263
|
+
|
|
264
|
+
buffer << chunk
|
|
265
|
+
yield stream, chunk if block_given?
|
|
266
|
+
rescue IOError, Errno::EIO
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
private_class_method :drain_all if respond_to?(:private_class_method)
|
|
270
|
+
|
|
271
|
+
# Wait for a child process until the deadline, then terminate the whole
|
|
272
|
+
# process group if it is still running.
|
|
273
|
+
def wait_or_terminate(pid, timeout)
|
|
274
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
275
|
+
|
|
276
|
+
loop do
|
|
277
|
+
waited_pid, = Process.waitpid2(pid, Process::WNOHANG)
|
|
278
|
+
return false if waited_pid
|
|
279
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
280
|
+
sleep 0.2
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
terminate_process_group(pid)
|
|
284
|
+
true
|
|
285
|
+
end
|
|
286
|
+
private_class_method :wait_or_terminate if respond_to?(:private_class_method)
|
|
287
|
+
|
|
288
|
+
# Graceful shutdown policy:
|
|
289
|
+
# - send TERM to the child process group
|
|
290
|
+
# - wait up to TERM_GRACE_SECONDS
|
|
291
|
+
# - send KILL if anything is still alive
|
|
292
|
+
def terminate_process_group(pid)
|
|
293
|
+
Process.kill('TERM', -pid)
|
|
294
|
+
rescue Errno::ESRCH
|
|
295
|
+
return
|
|
296
|
+
ensure
|
|
297
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + TERM_GRACE_SECONDS
|
|
298
|
+
loop do
|
|
299
|
+
waited_pid, = Process.waitpid2(pid, Process::WNOHANG)
|
|
300
|
+
return if waited_pid
|
|
301
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
302
|
+
sleep 0.2
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
begin
|
|
306
|
+
Process.kill('KILL', -pid)
|
|
307
|
+
rescue Errno::ESRCH
|
|
308
|
+
nil
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
begin
|
|
312
|
+
Process.waitpid(pid)
|
|
313
|
+
rescue Errno::ECHILD
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
private_class_method :terminate_process_group if respond_to?(:private_class_method)
|
|
318
|
+
|
|
319
|
+
# Build an informative error message for `capture!` failures.
|
|
320
|
+
def exit_error_message(cmd, result)
|
|
321
|
+
parts = []
|
|
322
|
+
parts << "Command failed: #{cmd.join(' ')}"
|
|
323
|
+
parts << "exit status #{result.exitstatus}" if result.exitstatus
|
|
324
|
+
parts.join(' with ')
|
|
325
|
+
end
|
|
326
|
+
private_class_method :exit_error_message if respond_to?(:private_class_method)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
if $PROGRAM_NAME == __FILE__
|
|
330
|
+
puts '[self-test] capture with streaming'
|
|
331
|
+
result = SafeExec.capture('bash', '-lc', 'echo out; echo err >&2', timeout: 2) do |stream, chunk|
|
|
332
|
+
puts " #{stream}: #{chunk.inspect}"
|
|
333
|
+
end
|
|
334
|
+
puts " success?: #{result.success?}"
|
|
335
|
+
puts " stdout: #{result.stdout.inspect}"
|
|
336
|
+
puts " stderr: #{result.stderr.inspect}"
|
|
337
|
+
|
|
338
|
+
puts '[self-test] capture! exit error'
|
|
339
|
+
begin
|
|
340
|
+
SafeExec.capture!('bash', '-lc', 'echo boom >&2; exit 7', timeout: 2)
|
|
341
|
+
rescue SafeExec::ExitError => e
|
|
342
|
+
puts " exit_error: #{e.message}"
|
|
343
|
+
puts " stderr: #{e.result.stderr.inspect}"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
puts '[self-test] call data result'
|
|
347
|
+
data = SafeExec.call(timeout: 2) { { ok: true, items: [1, 2, 3] } }
|
|
348
|
+
puts " data: #{data.inspect}"
|
|
349
|
+
|
|
350
|
+
puts '[self-test] call timeout'
|
|
351
|
+
begin
|
|
352
|
+
SafeExec.call(timeout: 1) do
|
|
353
|
+
sleep 2
|
|
354
|
+
true
|
|
355
|
+
end
|
|
356
|
+
rescue SafeExec::TimeoutError, Timeout::Error => e
|
|
357
|
+
puts " timeout: #{e.class}: #{e.message}"
|
|
358
|
+
end
|
|
359
|
+
end
|
data/lib/stack-service-base.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: stack-service-base
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.93
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Artyom B
|
|
@@ -453,6 +453,7 @@ files:
|
|
|
453
453
|
- lib/stack-service-base/prometheus_parser.rb
|
|
454
454
|
- lib/stack-service-base/public/ssbase/main.js
|
|
455
455
|
- lib/stack-service-base/rack_helpers.rb
|
|
456
|
+
- lib/stack-service-base/safe_exec.rb
|
|
456
457
|
- lib/stack-service-base/sinatra_ext.rb
|
|
457
458
|
- lib/stack-service-base/socket_trace.rb
|
|
458
459
|
- lib/stack-service-base/stack_template/gitlab-c/.gitattributes
|