kosa8 0.1.0 → 0.1.2
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/README.md +2 -0
- data/lib/kosa8/checkpoints.rb +18 -3
- data/lib/kosa8/client.rb +51 -3
- data/lib/kosa8/errors.rb +5 -2
- data/lib/kosa8/transport.rb +40 -6
- data/lib/kosa8/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fbd21499fe850f6b1c9055f9451c06500e9b779e60d880426afa850fc8a69ce5
|
|
4
|
+
data.tar.gz: 786b60910b4a591d9fec38b5b0b5d2623fd805934ff8508337ceec1f7e3159b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f5e5ea9ecbde4e0fdd2085b89d00ac7145485a4f6f2866bbde4986672a9e24215e3af27b9fa89aed98adc010698359d84ad435343d737f120c7faa37c7657af8
|
|
7
|
+
data.tar.gz: 946d830b7d1ecfba2962857cded5f61c8bcb9d621fc8d0af7f3985f755272811934175bdff469aabe1a442c740a457a8ea6cecc32eba2b5c0d993344a818c799
|
data/README.md
CHANGED
|
@@ -41,6 +41,8 @@ Daemon: `health`, `version`, `pull`.
|
|
|
41
41
|
Sandboxes: `list_sandboxes`, `get_sandbox`, `create_sandbox`, `start_sandbox`, `stop_sandbox`, `delete_sandbox`,
|
|
42
42
|
`pull_into_sandbox`, `sandbox_images`, `sandbox_containers`, `sandbox_egress`.
|
|
43
43
|
Processes: `run`, `start_container`, `exec`, `exec_frames`, `start_interactive`, `write_stdin`, `attach`.
|
|
44
|
+
|
|
45
|
+
Streaming calls (`run`, `exec`, `exec_frames`, `attach`) wait as long as the command runs unless you pass `timeout:`; a timeout stops the client waiting, not the command. `write_stdin` sends the container's whole stdin in one call (the daemon closes stdin at the end of the body), so pass an IO or an Enumerable to stream input; a second call raises.
|
|
44
46
|
Snapshots: `snapshot`, `list_snapshots`, `restore_snapshot`, `delete_snapshot`, `fork`, `explore`.
|
|
45
47
|
Checkpoints: `checkpoint`, `list_checkpoints`, `rewind` (store: `~/.kosa8/checkpoints.json`, flock-shared with the CLI).
|
|
46
48
|
`client.sandbox(id)` binds them to one sandbox.
|
data/lib/kosa8/checkpoints.rb
CHANGED
|
@@ -42,10 +42,12 @@ module Kosa8
|
|
|
42
42
|
# atomically), locked with the same flock on checkpoints.json.lock, so
|
|
43
43
|
# `kosa8 rewind` sees checkpoints taken here and the other way round.
|
|
44
44
|
class FileCheckpointStore
|
|
45
|
-
attr_reader :path
|
|
45
|
+
attr_reader :path, :lock_timeout
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
# lock_timeout: seconds to wait for checkpoints.json.lock (10, as @kosa8/sdk).
|
|
48
|
+
def initialize(path = nil, lock_timeout: 10)
|
|
48
49
|
@path = path || File.join(Dir.home, ".kosa8", "checkpoints.json")
|
|
50
|
+
@lock_timeout = lock_timeout
|
|
49
51
|
@mu = Mutex.new
|
|
50
52
|
end
|
|
51
53
|
|
|
@@ -60,7 +62,7 @@ module Kosa8
|
|
|
60
62
|
@mu.synchronize do
|
|
61
63
|
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
|
|
62
64
|
File.open("#{@path}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
|
|
63
|
-
lock
|
|
65
|
+
acquire(lock)
|
|
64
66
|
d = load
|
|
65
67
|
r = yield d
|
|
66
68
|
tmp = "#{@path}.tmp"
|
|
@@ -71,6 +73,19 @@ module Kosa8
|
|
|
71
73
|
end
|
|
72
74
|
end
|
|
73
75
|
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def acquire(lock)
|
|
80
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @lock_timeout
|
|
81
|
+
until lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
82
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
83
|
+
raise CheckpointError, "timed out after #{@lock_timeout}s waiting for #{@path}.lock; another kosa8 " \
|
|
84
|
+
"process is holding it"
|
|
85
|
+
end
|
|
86
|
+
sleep 0.05
|
|
87
|
+
end
|
|
88
|
+
end
|
|
74
89
|
end
|
|
75
90
|
|
|
76
91
|
# The stack logic of pkg/checkpoint over any store. Records are hashes with
|
data/lib/kosa8/client.rb
CHANGED
|
@@ -28,6 +28,8 @@ module Kosa8
|
|
|
28
28
|
@transport = Transport.new(socket_path: socket_path, host: host, token: token, insecure: insecure, actor: actor,
|
|
29
29
|
agent_token: agent_token, headers: headers, timeout: timeout, env: env)
|
|
30
30
|
@checkpoints = CheckpointBook.new(checkpoint_store || FileCheckpointStore.new)
|
|
31
|
+
@stdin_sent = {}
|
|
32
|
+
@stdin_mu = Mutex.new
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# --- daemon ---------------------------------------------------------------
|
|
@@ -163,7 +165,22 @@ module Kosa8
|
|
|
163
165
|
end
|
|
164
166
|
|
|
165
167
|
# POST …/containers/{cid}/stdin: a String, an IO, or an Enumerable of chunks (sent chunked).
|
|
168
|
+
#
|
|
169
|
+
# The request body is the container's whole stdin: the daemon closes stdin
|
|
170
|
+
# when the body ends, so this can be called once per container. To feed
|
|
171
|
+
# input over time, pass an IO or an Enumerable that yields as it goes.
|
|
172
|
+
# A second call raises instead of reporting success for input the process
|
|
173
|
+
# would never see.
|
|
166
174
|
def write_stdin(sandbox, container, data, approval: nil)
|
|
175
|
+
key = [sandbox.to_s, container.to_s]
|
|
176
|
+
@stdin_mu.synchronize do
|
|
177
|
+
if @stdin_sent[key]
|
|
178
|
+
raise Error, "stdin of container #{container} in #{sandbox} was already sent and is closed: the daemon " \
|
|
179
|
+
"treats one write_stdin body as the whole of stdin. Pass an IO or an Enumerable to stream input"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
@stdin_sent[key] = true
|
|
183
|
+
end
|
|
167
184
|
@transport.json("POST", sb(sandbox, "containers", Kosa8.enc(container), "stdin"), raw_body: data, approval: approval)
|
|
168
185
|
end
|
|
169
186
|
|
|
@@ -265,8 +282,22 @@ module Kosa8
|
|
|
265
282
|
# Save a restore point: a snapshot <sandbox>-ckpt-<n> on the sandbox's stack,
|
|
266
283
|
# the same store `kosa8 checkpoint` uses; the oldest is evicted past 10.
|
|
267
284
|
def checkpoint(sandbox, label: "", approval: nil)
|
|
285
|
+
snap = nil
|
|
286
|
+
# Two checkpoints taken at once (here or by the CLI) can pick the same
|
|
287
|
+
# next id; the loser's snapshot is refused with 409. The winner records
|
|
288
|
+
# its id only once its snapshot is done, so step past the taken id
|
|
289
|
+
# rather than re-reading the store.
|
|
268
290
|
cid, evict = @checkpoints.next_id(sandbox)
|
|
269
|
-
|
|
291
|
+
10.times do |attempt|
|
|
292
|
+
begin
|
|
293
|
+
snap = snapshot(sandbox, id: cid, approval: approval)
|
|
294
|
+
break
|
|
295
|
+
rescue ConflictError => e
|
|
296
|
+
raise if attempt == 9 || !(e.daemon_message || e.message).to_s.include?("already exists")
|
|
297
|
+
|
|
298
|
+
cid = "#{sandbox}-ckpt-#{CheckpointBook.seq(cid, sandbox) + 1}"
|
|
299
|
+
end
|
|
300
|
+
end
|
|
270
301
|
evict.each do |old| # evict only once the replacement exists
|
|
271
302
|
delete_snapshot(old, approval: approval)
|
|
272
303
|
rescue Error
|
|
@@ -292,7 +323,8 @@ module Kosa8
|
|
|
292
323
|
begin
|
|
293
324
|
restored = restore_snapshot(target["id"], name: sandbox, approval: approval)
|
|
294
325
|
rescue Error => e
|
|
295
|
-
raise CheckpointError, "restore failed and #{sandbox}
|
|
326
|
+
raise CheckpointError, "restore failed and #{sandbox} no longer exists (it was deleted to free its name " \
|
|
327
|
+
"for the restore): #{e.message} — the checkpoint is intact; recover with " \
|
|
296
328
|
"restore_snapshot(#{target['id'].inspect}, name: #{sandbox.inspect})"
|
|
297
329
|
end
|
|
298
330
|
dropped = []
|
|
@@ -397,7 +429,7 @@ module Kosa8
|
|
|
397
429
|
rescue Error => e
|
|
398
430
|
b.error = e.message
|
|
399
431
|
b.output = (e.stdout + e.stderr).chomp if e.is_a?(StreamError)
|
|
400
|
-
break if e.is_a?(TimeoutError) || b.error !~ TRANSIENT
|
|
432
|
+
break if e.is_a?(TimeoutError) || b.error !~ TRANSIENT || attempt == 3
|
|
401
433
|
|
|
402
434
|
sleep((attempt + 1) * 0.5)
|
|
403
435
|
end
|
|
@@ -464,6 +496,22 @@ module Kosa8
|
|
|
464
496
|
@client.explore(@id, variants, **kw)
|
|
465
497
|
end
|
|
466
498
|
|
|
499
|
+
def exec_frames(container, args, **kw, &blk)
|
|
500
|
+
@client.exec_frames(@id, container, args, **kw, &blk)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def start_interactive(image, args = [], **kw)
|
|
504
|
+
@client.start_interactive(@id, image, args, **kw)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def write_stdin(container, data, **kw)
|
|
508
|
+
@client.write_stdin(@id, container, data, **kw)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def attach(container, **kw)
|
|
512
|
+
@client.attach(@id, container, **kw)
|
|
513
|
+
end
|
|
514
|
+
|
|
467
515
|
{ start: :start_sandbox, stop: :stop_sandbox, delete: :delete_sandbox, snapshot: :snapshot,
|
|
468
516
|
checkpoint: :checkpoint, rewind: :rewind }.each do |name, target|
|
|
469
517
|
define_method(name) { |**kw| @client.public_send(target, @id, **kw) }
|
data/lib/kosa8/errors.rb
CHANGED
|
@@ -4,14 +4,14 @@ module Kosa8
|
|
|
4
4
|
# Every error this gem raises. The daemon's own words are kept in
|
|
5
5
|
# +daemon_message+ and +remediation+, as @kosa8/sdk does.
|
|
6
6
|
class Error < StandardError
|
|
7
|
-
attr_reader :status, :daemon_message, :remediation, :
|
|
7
|
+
attr_reader :status, :daemon_message, :remediation, :http_method, :path, :body
|
|
8
8
|
|
|
9
9
|
def initialize(message = nil, status: nil, daemon_message: nil, remediation: nil, method: nil, path: nil, body: nil)
|
|
10
10
|
super(message)
|
|
11
11
|
@status = status
|
|
12
12
|
@daemon_message = daemon_message
|
|
13
13
|
@remediation = remediation
|
|
14
|
-
@
|
|
14
|
+
@http_method = method
|
|
15
15
|
@path = path
|
|
16
16
|
@body = body
|
|
17
17
|
end
|
|
@@ -50,6 +50,9 @@ module Kosa8
|
|
|
50
50
|
class CheckpointError < Error; end
|
|
51
51
|
class TimeoutError < Error; end
|
|
52
52
|
|
|
53
|
+
# Raised when a frame stream carries something that is not NDJSON.
|
|
54
|
+
class ProtocolError < Error; end
|
|
55
|
+
|
|
53
56
|
BY_STATUS = {
|
|
54
57
|
400 => BadRequestError, 401 => AuthError, 403 => PolicyError,
|
|
55
58
|
404 => NotFoundError, 409 => ConflictError, 501 => UnsupportedError
|
data/lib/kosa8/transport.rb
CHANGED
|
@@ -105,6 +105,17 @@ module Kosa8
|
|
|
105
105
|
end
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
+
# Carries an exception raised by a caller's callback past raw's network
|
|
109
|
+
# rescue; raw re-raises the original.
|
|
110
|
+
class CallbackFailure < Exception # rubocop:disable Lint/InheritException
|
|
111
|
+
attr_reader :cause_error
|
|
112
|
+
|
|
113
|
+
def initialize(err)
|
|
114
|
+
super(err.message)
|
|
115
|
+
@cause_error = err
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
108
119
|
# Sends requests to the daemon; the client builds on it.
|
|
109
120
|
class Transport
|
|
110
121
|
attr_reader :endpoint
|
|
@@ -121,7 +132,7 @@ module Kosa8
|
|
|
121
132
|
end
|
|
122
133
|
|
|
123
134
|
# One request; yields the Net::HTTPResponse (body unread) and returns the block's value.
|
|
124
|
-
def raw(method, path, query: nil, body: nil, raw_body: nil, approval: nil, timeout: nil)
|
|
135
|
+
def raw(method, path, query: nil, body: nil, raw_body: nil, approval: nil, timeout: nil, stream: false)
|
|
125
136
|
full = (@endpoint.kind == :tcp ? @endpoint.base_path.to_s : "") + path
|
|
126
137
|
q = (query || {}).each_with_object([]) do |(k, v), a|
|
|
127
138
|
next if v.nil? || v == false
|
|
@@ -153,9 +164,15 @@ module Kosa8
|
|
|
153
164
|
if t
|
|
154
165
|
http.open_timeout = [t, 30].min
|
|
155
166
|
http.read_timeout = t
|
|
167
|
+
elsif stream
|
|
168
|
+
# A run, exec or attach can be silent for as long as the command
|
|
169
|
+
# likes; Net::HTTP's 60 s default would cut it off. Wait for it.
|
|
170
|
+
http.read_timeout = nil
|
|
156
171
|
end
|
|
157
172
|
begin
|
|
158
173
|
http.start { |h| h.request(req) { |resp| return yield(resp) } }
|
|
174
|
+
rescue CallbackFailure => e
|
|
175
|
+
raise e.cause_error
|
|
159
176
|
rescue Net::ReadTimeout, Net::OpenTimeout => e
|
|
160
177
|
raise TimeoutError.new("#{method} #{path} timed out talking to #{@endpoint.target}: #{e.message}",
|
|
161
178
|
method: method, path: path)
|
|
@@ -179,21 +196,38 @@ module Kosa8
|
|
|
179
196
|
|
|
180
197
|
# A request whose answer is NDJSON frames; yields each, data decoded from base64.
|
|
181
198
|
def frames(method, path, **kw)
|
|
182
|
-
raw(method, path, **kw) do |resp|
|
|
199
|
+
raw(method, path, stream: true, **kw) do |resp|
|
|
183
200
|
if resp.code.to_i >= 400
|
|
184
201
|
raise Kosa8.error_from_response(resp.code.to_i, decode(resp.read_body.to_s), method: method, path: path)
|
|
185
202
|
end
|
|
186
203
|
|
|
187
204
|
buf = +""
|
|
205
|
+
emit = lambda do |line|
|
|
206
|
+
next if line.empty?
|
|
207
|
+
|
|
208
|
+
frame = begin
|
|
209
|
+
Kosa8.decode_frame(JSON.parse(line))
|
|
210
|
+
rescue JSON::ParserError, ArgumentError => e
|
|
211
|
+
raise ProtocolError.new("#{method} #{path}: the daemon sent a frame that is not valid NDJSON " \
|
|
212
|
+
"(#{e.message}): #{line[0, 200].inspect}", method: method, path: path)
|
|
213
|
+
end
|
|
214
|
+
# Errors from the caller's own callbacks must come out as they are,
|
|
215
|
+
# not relabelled as a lost connection by the rescue in raw.
|
|
216
|
+
begin
|
|
217
|
+
yield frame
|
|
218
|
+
rescue Kosa8::Error
|
|
219
|
+
raise
|
|
220
|
+
rescue StandardError => e
|
|
221
|
+
raise CallbackFailure.new(e)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
188
224
|
resp.read_body do |chunk|
|
|
189
225
|
buf << chunk
|
|
190
226
|
while (i = buf.index("\n"))
|
|
191
|
-
|
|
192
|
-
yield Kosa8.decode_frame(JSON.parse(line)) unless line.empty?
|
|
227
|
+
emit.call(buf.slice!(0..i).strip)
|
|
193
228
|
end
|
|
194
229
|
end
|
|
195
|
-
|
|
196
|
-
yield Kosa8.decode_frame(JSON.parse(line)) unless line.empty?
|
|
230
|
+
emit.call(buf.strip)
|
|
197
231
|
nil
|
|
198
232
|
end
|
|
199
233
|
end
|
data/lib/kosa8/version.rb
CHANGED