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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '059d3ca9753fb52d517a80038164038e51621d2ae4a2393fcb8f79481e9c8c95'
4
- data.tar.gz: 10c6b5dd4914af9371fe21b17230df7ac38c85275fbb16c1fa60e1f10487bbc3
3
+ metadata.gz: fbd21499fe850f6b1c9055f9451c06500e9b779e60d880426afa850fc8a69ce5
4
+ data.tar.gz: 786b60910b4a591d9fec38b5b0b5d2623fd805934ff8508337ceec1f7e3159b4
5
5
  SHA512:
6
- metadata.gz: 8774ce6bba2044d40ec5f051d84be6b057944469bda072eb52bacb8104d025f8a6cda96f5da6ca244c0eeee1207730b42f096118a93a96ad3d9454a8a740a2b0
7
- data.tar.gz: 2207cecb3af2ca7dadfe4aa1252f29a0886b5e583534da78fe04682e5882b04235a9ff6a52fa16b546a9a1f3ad300c006a2b5413b92fe4350ca324448ebe7fed
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.
@@ -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
- def initialize(path = nil)
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.flock(File::LOCK_EX)
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
- snap = snapshot(sandbox, id: cid, approval: approval)
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} is now stopped: #{e.message} — recover with " \
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, :method, :path, :body
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
- @method = method
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
@@ -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
- line = buf.slice!(0..i).strip
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
- line = buf.strip
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kosa8
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kosa8
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - kosa8