docker-api-ng 0.3.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 +7 -0
- data/LICENSE +202 -0
- data/NOTICE +12 -0
- data/README.md +278 -0
- data/docker-api-ng.gemspec +42 -0
- data/docs/building-images.md +98 -0
- data/docs/connecting.md +111 -0
- data/docs/errors.md +89 -0
- data/docs/exec.md +100 -0
- data/docs/extending.md +109 -0
- data/docs/migrating-from-docker-api.md +156 -0
- data/docs/streaming.md +91 -0
- data/lib/docker/api/auth.rb +154 -0
- data/lib/docker/api/body.rb +47 -0
- data/lib/docker/api/client.rb +117 -0
- data/lib/docker/api/collection.rb +60 -0
- data/lib/docker/api/collections/containers.rb +78 -0
- data/lib/docker/api/collections/images.rb +221 -0
- data/lib/docker/api/collections/networks.rb +85 -0
- data/lib/docker/api/collections/system.rb +98 -0
- data/lib/docker/api/collections/volumes.rb +54 -0
- data/lib/docker/api/config.rb +163 -0
- data/lib/docker/api/connection.rb +333 -0
- data/lib/docker/api/context.rb +124 -0
- data/lib/docker/api/errors.rb +159 -0
- data/lib/docker/api/operations.rb +3064 -0
- data/lib/docker/api/path.rb +33 -0
- data/lib/docker/api/platform.rb +68 -0
- data/lib/docker/api/query.rb +76 -0
- data/lib/docker/api/resource.rb +174 -0
- data/lib/docker/api/resources/container.rb +378 -0
- data/lib/docker/api/resources/exec.rb +48 -0
- data/lib/docker/api/resources/image.rb +209 -0
- data/lib/docker/api/resources/network.rb +90 -0
- data/lib/docker/api/resources/volume.rb +51 -0
- data/lib/docker/api/response.rb +110 -0
- data/lib/docker/api/session.rb +70 -0
- data/lib/docker/api/stream.rb +142 -0
- data/lib/docker/api/tar.rb +157 -0
- data/lib/docker/api/transport/base.rb +62 -0
- data/lib/docker/api/transport/fake.rb +154 -0
- data/lib/docker/api/transport/named_pipe.rb +58 -0
- data/lib/docker/api/transport/tcp.rb +49 -0
- data/lib/docker/api/transport/tls.rb +84 -0
- data/lib/docker/api/transport/unix.rb +35 -0
- data/lib/docker/api/transport.rb +113 -0
- data/lib/docker/api/version.rb +21 -0
- data/lib/docker/api.rb +68 -0
- data/sig/docker/api/core.rbs +109 -0
- data/sig/docker/api/operations.rbs +123 -0
- metadata +97 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Docker
|
|
7
|
+
module API
|
|
8
|
+
# A container on the daemon.
|
|
9
|
+
#
|
|
10
|
+
# @example Run a command and read its output
|
|
11
|
+
# container = client.containers.get("web")
|
|
12
|
+
# result = container.exec(["cat", "/etc/hostname"])
|
|
13
|
+
# result.stdout #=> "3f2a9c1b0e4d\n"
|
|
14
|
+
#
|
|
15
|
+
# @example Follow logs
|
|
16
|
+
# container.logs(follow: true) { |stream, chunk| $stdout << chunk }
|
|
17
|
+
class Container < Resource
|
|
18
|
+
# @return [String, nil] the container's name, without the leading slash
|
|
19
|
+
# the daemon puts on it. Answers identically whether this object came
|
|
20
|
+
# from a list or an inspect.
|
|
21
|
+
def name
|
|
22
|
+
# "Name" comes from an inspect, "Names" from a list. Both are tried
|
|
23
|
+
# against the payload in hand before any request is made.
|
|
24
|
+
value = detail("Name", "Names")
|
|
25
|
+
value = Array(value).first if value.is_a?(Array)
|
|
26
|
+
value&.sub(%r{\A/}, "")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [String, nil] "running", "exited", "created", and so on
|
|
30
|
+
def state
|
|
31
|
+
value = detail("State")
|
|
32
|
+
value.is_a?(Hash) ? value["Status"] : value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Boolean] whether the container is running right now
|
|
36
|
+
def running?
|
|
37
|
+
state == "running"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [String, nil] the image the container was created from, by the
|
|
41
|
+
# name it was requested under rather than by digest
|
|
42
|
+
def image
|
|
43
|
+
# An inspect puts the friendly name in Config.Image and a digest in
|
|
44
|
+
# Image; a list puts the friendly name in Image. Preferring
|
|
45
|
+
# Config.Image gets the readable answer from both without a round trip.
|
|
46
|
+
detail("Config.Image", "Image")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Hash] the container's labels
|
|
50
|
+
def labels
|
|
51
|
+
detail("Config.Labels", "Labels") || {}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Published ports, in one shape regardless of where the payload came
|
|
55
|
+
# from. A list response reports an array; an inspect response reports a
|
|
56
|
+
# map keyed by port. Both become the same array of hashes here.
|
|
57
|
+
#
|
|
58
|
+
# @return [Array<Hash>] with :port, :protocol, :host_ip and :host_port
|
|
59
|
+
def ports
|
|
60
|
+
listed = raw["Ports"]
|
|
61
|
+
return normalize_listed_ports(listed) if listed.is_a?(Array)
|
|
62
|
+
|
|
63
|
+
normalize_inspected_ports(detail("NetworkSettings.Ports") || {})
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Hash{String => Hash}] networks this container is attached to
|
|
67
|
+
def networks
|
|
68
|
+
detail("NetworkSettings.Networks") || {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [String, nil] the container's address on its primary network
|
|
72
|
+
def ip_address
|
|
73
|
+
networks.each_value { |net| return net["IPAddress"] unless net["IPAddress"].to_s.empty? }
|
|
74
|
+
detail("NetworkSettings.IPAddress")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Boolean] whether the container was created with a TTY, which
|
|
78
|
+
# decides whether its output stream is multiplexed
|
|
79
|
+
def tty?
|
|
80
|
+
detail("Config.Tty") == true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Re-read this container from the daemon.
|
|
84
|
+
#
|
|
85
|
+
# @return [self]
|
|
86
|
+
def reload
|
|
87
|
+
replace_raw(operations.container_inspect(id: id || name).json)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param detach_keys [String, nil] the key sequence that detaches
|
|
91
|
+
# @return [self]
|
|
92
|
+
def start(detach_keys: nil)
|
|
93
|
+
idempotently { operations.container_start(id: id, detach_keys: detach_keys) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param timeout [Integer, nil] seconds to wait before killing
|
|
97
|
+
# @param signal [String, nil] the signal to send first
|
|
98
|
+
# @return [self]
|
|
99
|
+
def stop(timeout: nil, signal: nil)
|
|
100
|
+
idempotently { operations.container_stop(id: id, t: timeout, signal: signal) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @param timeout [Integer, nil] seconds to wait before killing
|
|
104
|
+
# @param signal [String, nil] the signal to send first
|
|
105
|
+
# @return [self]
|
|
106
|
+
def restart(timeout: nil, signal: nil)
|
|
107
|
+
idempotently { operations.container_restart(id: id, t: timeout, signal: signal) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @param signal [String, nil] the signal to send, SIGKILL by default
|
|
111
|
+
# @return [self]
|
|
112
|
+
def kill(signal: nil)
|
|
113
|
+
operations.container_kill(id: id, signal: signal)
|
|
114
|
+
mark_stale
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [self]
|
|
118
|
+
def pause
|
|
119
|
+
operations.container_pause(id: id)
|
|
120
|
+
mark_stale
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [self]
|
|
124
|
+
def unpause
|
|
125
|
+
operations.container_unpause(id: id)
|
|
126
|
+
mark_stale
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @param name [String] the new name
|
|
130
|
+
# @return [self]
|
|
131
|
+
def rename(name)
|
|
132
|
+
operations.container_rename(id: id, name: name)
|
|
133
|
+
reload
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @param force [Boolean] remove even if running
|
|
137
|
+
# @param volumes [Boolean] remove anonymous volumes too
|
|
138
|
+
# @param link [Boolean] remove the specified link
|
|
139
|
+
# @return [void]
|
|
140
|
+
def remove(force: false, volumes: false, link: false)
|
|
141
|
+
operations.container_delete(id: id, force: force, v: volumes, link: link)
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Block until the container stops.
|
|
146
|
+
#
|
|
147
|
+
# @param condition [String, nil] "not-running", "next-exit" or "removed"
|
|
148
|
+
# @return [Integer] the container's exit code
|
|
149
|
+
def wait(condition: nil)
|
|
150
|
+
operations.container_wait(id: id, condition: condition).json!["StatusCode"]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @param ps_args [String, nil] arguments passed to ps inside the container
|
|
154
|
+
# @return [Hash] with "Titles" and "Processes"
|
|
155
|
+
def top(ps_args: nil)
|
|
156
|
+
operations.container_top(id: id, ps_args: ps_args).json
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [Hash] a single resource-usage sample
|
|
160
|
+
def stats
|
|
161
|
+
operations.container_stats(id: id, stream: false, one_shot: true).json
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Read the container's output.
|
|
165
|
+
#
|
|
166
|
+
# Without a block the whole log is returned as a string. With a block,
|
|
167
|
+
# chunks are yielded as they arrive, demultiplexed into named streams
|
|
168
|
+
# unless the container has a TTY, in which case the daemon sends one
|
|
169
|
+
# undifferentiated stream and every chunk is reported as :stdout.
|
|
170
|
+
#
|
|
171
|
+
# @param follow [Boolean] keep streaming as new output appears
|
|
172
|
+
# @param stdout [Boolean] include stdout
|
|
173
|
+
# @param stderr [Boolean] include stderr
|
|
174
|
+
# @param tail [String, Integer, nil] how many trailing lines to start with
|
|
175
|
+
# @param since [Integer, nil] a UNIX timestamp to start from
|
|
176
|
+
# @param timestamps [Boolean] prefix every line with its timestamp
|
|
177
|
+
# @yieldparam stream [Symbol] :stdout or :stderr
|
|
178
|
+
# @yieldparam chunk [String]
|
|
179
|
+
# @return [String, self] the log, or self when a block was given
|
|
180
|
+
def logs(follow: false, stdout: true, stderr: true, tail: nil,
|
|
181
|
+
since: nil, timestamps: false, &block)
|
|
182
|
+
# Defaulted rather than fixed keys: Demultiplexer maps a frame id it
|
|
183
|
+
# does not recognise to :unknown, and a fixed two-key hash turned that
|
|
184
|
+
# into `undefined method '<<' for nil` -- one corrupt frame crashing a
|
|
185
|
+
# log read, with a bare NoMethodError rather than a Docker::API::Error.
|
|
186
|
+
collected = Hash.new { |streams, name| streams[name] = +"" }
|
|
187
|
+
sink = block || ->(stream, chunk) { collected[stream] << chunk }
|
|
188
|
+
decoder = tty? ? Stream::Raw.new { |chunk| sink.call(:stdout, chunk) } : Stream::Demultiplexer.new(&sink)
|
|
189
|
+
|
|
190
|
+
operations.container_logs(
|
|
191
|
+
id: id, follow: follow, stdout: stdout, stderr: stderr,
|
|
192
|
+
tail: tail&.to_s, since: since, timestamps: timestamps
|
|
193
|
+
) { |chunk| decoder << chunk }
|
|
194
|
+
|
|
195
|
+
block ? self : collected[:stdout] + collected[:stderr]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Run a command inside the container and wait for it to finish.
|
|
199
|
+
#
|
|
200
|
+
# @param command [Array<String>, String] the command and its arguments
|
|
201
|
+
# @param env [Hash] environment variables for the command
|
|
202
|
+
# @param user [String, nil] the user to run as
|
|
203
|
+
# @param working_dir [String, nil] the directory to run in
|
|
204
|
+
# @param tty [Boolean] allocate a TTY, which un-multiplexes the output
|
|
205
|
+
# @param privileged [Boolean] run privileged
|
|
206
|
+
# @yieldparam stream [Symbol] :stdout or :stderr
|
|
207
|
+
# @yieldparam chunk [String]
|
|
208
|
+
# @return [Docker::API::ExecResult]
|
|
209
|
+
#
|
|
210
|
+
# @example
|
|
211
|
+
# result = container.exec(%w{chef-client -z}) { |_stream, chunk| logger << chunk }
|
|
212
|
+
# raise "converge failed" unless result.success?
|
|
213
|
+
def exec(command, env: {}, user: nil, working_dir: nil, tty: false,
|
|
214
|
+
privileged: false, &block)
|
|
215
|
+
exec_id = create_exec(command, env, user, working_dir, tty, privileged)
|
|
216
|
+
stdout = +""
|
|
217
|
+
stderr = +""
|
|
218
|
+
|
|
219
|
+
sink = lambda do |stream, chunk|
|
|
220
|
+
(stream == :stderr ? stderr : stdout) << chunk
|
|
221
|
+
block&.call(stream, chunk)
|
|
222
|
+
end
|
|
223
|
+
decoder = tty ? Stream::Raw.new { |chunk| sink.call(:stdout, chunk) } : Stream::Demultiplexer.new(&sink)
|
|
224
|
+
|
|
225
|
+
operations.exec_start(
|
|
226
|
+
id: exec_id, body: { "Detach" => false, "Tty" => tty }
|
|
227
|
+
) { |chunk| decoder << chunk }
|
|
228
|
+
|
|
229
|
+
ExecResult.new(
|
|
230
|
+
stdout: stdout, stderr: stderr,
|
|
231
|
+
# Not .to_i. The daemon reports "ExitCode": null while an exec is
|
|
232
|
+
# still being reaped, and nil.to_i is 0 -- so a command whose result
|
|
233
|
+
# was not yet known reported success, and #success? agreed. nil
|
|
234
|
+
# travels through instead, and #success? is false for it.
|
|
235
|
+
exit_code: operations.exec_inspect(id: exec_id).json!["ExitCode"]
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Attach to the container's streams, taking over the socket.
|
|
240
|
+
#
|
|
241
|
+
# @param stdin [Boolean] attach the input stream
|
|
242
|
+
# @param stdout [Boolean] attach standard output
|
|
243
|
+
# @param stderr [Boolean] attach standard error
|
|
244
|
+
# @param logs [Boolean] replay existing output first
|
|
245
|
+
# @return [IO] the bidirectional stream
|
|
246
|
+
def attach(stdin: false, stdout: true, stderr: true, logs: false)
|
|
247
|
+
client.connection.hijack(
|
|
248
|
+
:post, "/containers/#{Path.escape(id)}/attach",
|
|
249
|
+
query: { "stream" => true, "stdin" => stdin, "stdout" => stdout,
|
|
250
|
+
"stderr" => stderr, "logs" => logs },
|
|
251
|
+
operation: "container_attach"
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Copy a tar archive into the container.
|
|
256
|
+
#
|
|
257
|
+
# @param archive [String, IO] tar bytes, or an IO to stream from
|
|
258
|
+
# @param path [String] the destination directory inside the container
|
|
259
|
+
# @param overwrite_non_directory [Boolean] allow replacing a file with a
|
|
260
|
+
# directory, or the reverse
|
|
261
|
+
# @param copy_uid_gid [Boolean] keep the archive's ownership
|
|
262
|
+
# @return [self]
|
|
263
|
+
def archive_in(archive, path:, overwrite_non_directory: true, copy_uid_gid: false)
|
|
264
|
+
operations.put_container_archive(
|
|
265
|
+
id: id, path: path,
|
|
266
|
+
# The content type is not a parameter this endpoint declares, so it
|
|
267
|
+
# is not a keyword the generated layer accepts; the connection
|
|
268
|
+
# labels raw bodies as archives, which is what this one is.
|
|
269
|
+
#
|
|
270
|
+
# An IO is handed over as it stands rather than read into a String.
|
|
271
|
+
# Slurping defeated the point of accepting one: a container
|
|
272
|
+
# filesystem is exactly the kind of archive nobody wants resident in
|
|
273
|
+
# memory, and the connection streams a readable body chunked.
|
|
274
|
+
body: archive,
|
|
275
|
+
no_overwrite_dir_non_dir: !overwrite_non_directory,
|
|
276
|
+
copy_uidgid: copy_uid_gid
|
|
277
|
+
)
|
|
278
|
+
self
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Read a path out of the container as a tar archive.
|
|
282
|
+
#
|
|
283
|
+
# @param path [String] the path inside the container
|
|
284
|
+
# @yieldparam chunk [String] tar bytes, when a block is given
|
|
285
|
+
# @return [String, self] the archive, or self when streamed
|
|
286
|
+
def archive_out(path, &block)
|
|
287
|
+
return operations.container_archive(id: id, path: path).body unless block
|
|
288
|
+
|
|
289
|
+
operations.container_archive(id: id, path: path, &block)
|
|
290
|
+
self
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Turn the container's filesystem into an image.
|
|
294
|
+
#
|
|
295
|
+
# @param repo [String, nil] the repository to name it
|
|
296
|
+
# @param tag [String, nil] the tag to give it
|
|
297
|
+
# @param comment [String, nil] a commit message
|
|
298
|
+
# @param author [String, nil] who made it
|
|
299
|
+
# @param pause [Boolean] pause the container while committing
|
|
300
|
+
# @return [Docker::API::Image]
|
|
301
|
+
def commit(repo: nil, tag: nil, comment: nil, author: nil, pause: true)
|
|
302
|
+
response = operations.image_commit(
|
|
303
|
+
container: id, repo: repo, tag: tag, comment: comment,
|
|
304
|
+
author: author, pause: pause
|
|
305
|
+
)
|
|
306
|
+
client.images.get(response.json!["Id"])
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
# Run a lifecycle change that Docker treats as idempotent.
|
|
312
|
+
#
|
|
313
|
+
# The daemon answers 304 for "already started" and "already stopped", and
|
|
314
|
+
# the generated layer faithfully raises NotModified for it -- correct
|
|
315
|
+
# there, where fidelity to the specification is the point. It is the
|
|
316
|
+
# wrong default here: converge loops, retry-until-healthy blocks and test
|
|
317
|
+
# fixtures all call start on a container that may already be running, and
|
|
318
|
+
# asking for a state the container is already in is not a failure.
|
|
319
|
+
#
|
|
320
|
+
# Marked stale either way. Rescuing without it left a caller who did
|
|
321
|
+
# handle the exception holding a payload the daemon had already moved on
|
|
322
|
+
# from.
|
|
323
|
+
#
|
|
324
|
+
# @return [self]
|
|
325
|
+
def idempotently
|
|
326
|
+
yield
|
|
327
|
+
mark_stale
|
|
328
|
+
rescue NotModified
|
|
329
|
+
mark_stale
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# @return [String] the id of the created exec instance
|
|
333
|
+
def create_exec(command, env, user, working_dir, tty, privileged)
|
|
334
|
+
body = {
|
|
335
|
+
"AttachStdout" => true,
|
|
336
|
+
"AttachStderr" => true,
|
|
337
|
+
"Tty" => tty,
|
|
338
|
+
"Cmd" => Array(command),
|
|
339
|
+
"Privileged" => privileged,
|
|
340
|
+
}
|
|
341
|
+
body["Env"] = env.map { |key, value| "#{key}=#{value}" } unless env.nil? || env.empty?
|
|
342
|
+
body["User"] = user if user
|
|
343
|
+
body["WorkingDir"] = working_dir if working_dir
|
|
344
|
+
|
|
345
|
+
operations.container_exec(id: id, body: body).json!["Id"]
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# @param listed [Array<Hash>]
|
|
349
|
+
# @return [Array<Hash>]
|
|
350
|
+
def normalize_listed_ports(listed)
|
|
351
|
+
listed.map do |entry|
|
|
352
|
+
{
|
|
353
|
+
port: entry["PrivatePort"],
|
|
354
|
+
protocol: entry["Type"],
|
|
355
|
+
host_ip: entry["IP"],
|
|
356
|
+
host_port: entry["PublicPort"],
|
|
357
|
+
}
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @param inspected [Hash]
|
|
362
|
+
# @return [Array<Hash>]
|
|
363
|
+
def normalize_inspected_ports(inspected)
|
|
364
|
+
inspected.flat_map do |spec, bindings|
|
|
365
|
+
port, _, protocol = spec.partition("/")
|
|
366
|
+
Array(bindings).map do |binding|
|
|
367
|
+
{
|
|
368
|
+
port: port.to_i,
|
|
369
|
+
protocol: protocol,
|
|
370
|
+
host_ip: binding && binding["HostIp"],
|
|
371
|
+
host_port: binding && binding["HostPort"]&.to_i,
|
|
372
|
+
}
|
|
373
|
+
end.then { |mapped| mapped.empty? ? [{ port: port.to_i, protocol: protocol, host_ip: nil, host_port: nil }] : mapped }
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Docker
|
|
7
|
+
module API
|
|
8
|
+
# What a command run inside a container produced.
|
|
9
|
+
#
|
|
10
|
+
# A non-zero {#exit_code} is returned rather than raised. Whether a failing
|
|
11
|
+
# command is an error depends entirely on why it was run -- a test runner
|
|
12
|
+
# expects failures, a provisioning step does not -- so the decision belongs
|
|
13
|
+
# to the caller. {#success?} and {#check!} are there for the common cases.
|
|
14
|
+
ExecResult = Struct.new(:stdout, :stderr, :exit_code, keyword_init: true) do
|
|
15
|
+
# Nil is not success. The daemon reports a null exit code while an exec
|
|
16
|
+
# is still being reaped, and treating that as zero fails open on exactly
|
|
17
|
+
# the question this struct exists to answer.
|
|
18
|
+
#
|
|
19
|
+
# @return [Boolean] whether the command exited zero
|
|
20
|
+
def success?
|
|
21
|
+
exit_code == 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [self]
|
|
25
|
+
# @raise [Docker::API::Error] if the command exited non-zero, or if the
|
|
26
|
+
# daemon did not report an exit code at all
|
|
27
|
+
def check!
|
|
28
|
+
return self if success?
|
|
29
|
+
|
|
30
|
+
raise Error.new(check_message, operation: "exec")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String] stdout and stderr in the order they were produced
|
|
34
|
+
def output
|
|
35
|
+
"#{stdout}#{stderr}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# @return [String]
|
|
41
|
+
def check_message
|
|
42
|
+
return "the daemon reported no exit code for this command, so whether it succeeded is unknown" if exit_code.nil?
|
|
43
|
+
|
|
44
|
+
"command exited #{exit_code}: #{(stderr.to_s.empty? ? stdout : stderr).to_s.strip}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
module Docker
|
|
7
|
+
module API
|
|
8
|
+
# An image on the daemon.
|
|
9
|
+
class Image < Resource
|
|
10
|
+
# @return [Array<String>] every repository:tag this image answers to
|
|
11
|
+
def tags
|
|
12
|
+
detail("RepoTags") || []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [Array<String>] the image's repository digests
|
|
16
|
+
def digests
|
|
17
|
+
detail("RepoDigests") || []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [String, nil] the first tag, which is what people usually mean
|
|
21
|
+
# when they say "the image name"
|
|
22
|
+
def name
|
|
23
|
+
tags.first
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Integer, nil] size on disk, in bytes
|
|
27
|
+
def size
|
|
28
|
+
detail("Size")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Hash] the image's labels
|
|
32
|
+
def labels
|
|
33
|
+
detail("Config.Labels", "Labels") || {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [String, nil] the platform this image was built for
|
|
37
|
+
def platform
|
|
38
|
+
os = detail("Os")
|
|
39
|
+
architecture = detail("Architecture")
|
|
40
|
+
return nil if os.nil? || architecture.nil?
|
|
41
|
+
|
|
42
|
+
variant = detail("Variant")
|
|
43
|
+
[os, architecture, variant].compact.join("/")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [self]
|
|
47
|
+
def reload
|
|
48
|
+
replace_raw(operations.image_inspect(name: id).json)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Give this image another name.
|
|
52
|
+
#
|
|
53
|
+
# @param reference [String] a full "repo:tag", or just a repo
|
|
54
|
+
# @return [self]
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# image.tag("registry.example.com/team/app:2026.08")
|
|
58
|
+
def tag(reference)
|
|
59
|
+
repo, tag = self.class.split_reference(reference)
|
|
60
|
+
operations.image_tag(name: id, repo: repo, tag: tag)
|
|
61
|
+
reload
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param force [Boolean] remove even if tagged or in use
|
|
65
|
+
# @param noprune [Boolean] keep untagged parents
|
|
66
|
+
# @return [Array<Hash>] what the daemon deleted or untagged
|
|
67
|
+
def remove(force: false, noprune: false)
|
|
68
|
+
operations.image_delete(name: id, force: force, noprune: noprune).json
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Push this image to its registry.
|
|
72
|
+
#
|
|
73
|
+
# Pushes the reference this object stands for, not the whole repository.
|
|
74
|
+
# The daemon pushes every tag under a repository when it is given no tag
|
|
75
|
+
# at all, so leaving it out meant `image.push` on an image tagged both
|
|
76
|
+
# `app:1.0` and `app:latest` pushed both -- an unwelcome surprise when
|
|
77
|
+
# only one of them was meant to be published. `tag:` overrides; to push a
|
|
78
|
+
# whole repository deliberately, ask the daemon for it directly with
|
|
79
|
+
# `client.operations.image_push(name: repo, x_registry_auth: ...)`.
|
|
80
|
+
#
|
|
81
|
+
# @param tag [String, nil] which tag to push, defaulting to this image's own
|
|
82
|
+
# @param auth [String, nil] an X-Registry-Auth value; resolved from the
|
|
83
|
+
# local Docker configuration when omitted
|
|
84
|
+
# @param platform [String, Hash, nil] which variant to push
|
|
85
|
+
# @yieldparam event [Hash] progress events as they arrive
|
|
86
|
+
# @return [self]
|
|
87
|
+
# @raise [Docker::API::Error] if the image carries no repository tag
|
|
88
|
+
def push(tag: nil, auth: nil, platform: nil, &block)
|
|
89
|
+
repo, own_tag = self.class.split_reference(pushable_reference)
|
|
90
|
+
tag ||= own_tag
|
|
91
|
+
credentials = auth || Auth.resolve(self.class.registry_for(repo)) || Auth.encode({})
|
|
92
|
+
|
|
93
|
+
stream = block ? Stream::JSONLines.new(&block) : nil
|
|
94
|
+
operations.image_push(
|
|
95
|
+
name: repo, tag: tag, x_registry_auth: credentials,
|
|
96
|
+
platform: Platform.oci(platform)
|
|
97
|
+
) do |chunk|
|
|
98
|
+
stream << chunk if stream
|
|
99
|
+
end
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @param platform [String, Hash, nil] which variant's history to read
|
|
104
|
+
# @return [Array<Hash>] the image's layer history
|
|
105
|
+
def history(platform: nil)
|
|
106
|
+
operations.image_history(name: id, platform: Platform.oci(platform)).json
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Write the image out as a tar archive.
|
|
110
|
+
#
|
|
111
|
+
# @yieldparam chunk [String] tar bytes, when a block is given
|
|
112
|
+
# @return [String, self]
|
|
113
|
+
def save(&block)
|
|
114
|
+
return operations.image_get(name: id).body unless block
|
|
115
|
+
|
|
116
|
+
operations.image_get(name: id, &block)
|
|
117
|
+
self
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Split "registry.io/team/app:1.0" into its repository and tag.
|
|
121
|
+
#
|
|
122
|
+
# Three colons can appear in a reference and only one of them separates a
|
|
123
|
+
# tag:
|
|
124
|
+
#
|
|
125
|
+
# localhost:5000/app the colon is a registry port
|
|
126
|
+
# alpine@sha256:1a2b... the colon is inside a digest
|
|
127
|
+
# registry.io/team/app:1.0 the colon is the tag separator
|
|
128
|
+
#
|
|
129
|
+
# The digest form has to be taken off first, because "@" binds looser
|
|
130
|
+
# than the colon inside "sha256:..." and a right-hand partition on ":"
|
|
131
|
+
# would otherwise split the digest itself -- turning "alpine@sha256:1a2b"
|
|
132
|
+
# into the repository "alpine@sha256", which cannot exist. The daemon
|
|
133
|
+
# wants the digest whole, as the tag: `?fromImage=alpine&tag=sha256:1a2b`
|
|
134
|
+
# is exactly what `docker pull alpine@sha256:1a2b` sends.
|
|
135
|
+
#
|
|
136
|
+
# @param reference [String]
|
|
137
|
+
# @return [Array(String, String)] the repository and the tag or digest
|
|
138
|
+
#
|
|
139
|
+
# @example
|
|
140
|
+
# split_reference("alpine") #=> ["alpine", "latest"]
|
|
141
|
+
# split_reference("localhost:5000/app") #=> ["localhost:5000/app", "latest"]
|
|
142
|
+
# split_reference("alpine@sha256:1a2b") #=> ["alpine", "sha256:1a2b"]
|
|
143
|
+
def self.split_reference(reference)
|
|
144
|
+
value = reference.to_s
|
|
145
|
+
repo, separator, digest = value.rpartition("@")
|
|
146
|
+
return [repo, digest] unless separator.empty?
|
|
147
|
+
|
|
148
|
+
repo, _, tag = value.rpartition(":")
|
|
149
|
+
return [value, "latest"] if repo.empty? || tag.include?("/")
|
|
150
|
+
|
|
151
|
+
[repo, tag]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Put a repository and a tag or digest back together.
|
|
155
|
+
#
|
|
156
|
+
# The inverse of {.split_reference}, and it has to know which of the two
|
|
157
|
+
# it was handed: a tag joins with ":" and a digest joins with "@". A tag
|
|
158
|
+
# can never contain a colon, so the colon is the tell.
|
|
159
|
+
#
|
|
160
|
+
# @param repo [String]
|
|
161
|
+
# @param tag [String, nil]
|
|
162
|
+
# @return [String]
|
|
163
|
+
#
|
|
164
|
+
# @example
|
|
165
|
+
# join_reference("alpine", "3.20") #=> "alpine:3.20"
|
|
166
|
+
# join_reference("alpine", "sha256:1a2b") #=> "alpine@sha256:1a2b"
|
|
167
|
+
def self.join_reference(repo, tag)
|
|
168
|
+
return repo.to_s if tag.to_s.empty?
|
|
169
|
+
|
|
170
|
+
tag.to_s.include?(":") ? "#{repo}@#{tag}" : "#{repo}:#{tag}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The repository reference this image can be pushed under.
|
|
174
|
+
#
|
|
175
|
+
# An image id is not one. Falling back to it produced a request against
|
|
176
|
+
# the repository "sha256", because splitting "sha256:1a2b..." on the last
|
|
177
|
+
# colon looks exactly like splitting a tag -- a confusing 404 in place of
|
|
178
|
+
# the real problem, which is that nothing has named this image yet.
|
|
179
|
+
# Untagged layers report `<none>:<none>`, which is not a name either.
|
|
180
|
+
#
|
|
181
|
+
# @return [String]
|
|
182
|
+
# @raise [Docker::API::Error]
|
|
183
|
+
# @api private
|
|
184
|
+
def pushable_reference
|
|
185
|
+
reference = tags.reject { |candidate| candidate.start_with?("<none>") }.first
|
|
186
|
+
return reference if reference
|
|
187
|
+
|
|
188
|
+
raise Error.new(
|
|
189
|
+
"image #{id.to_s[0, 19]} has no repository tag, so there is nothing to push it as. " \
|
|
190
|
+
"Give it one first with #tag.",
|
|
191
|
+
operation: "image_push"
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
private :pushable_reference
|
|
195
|
+
|
|
196
|
+
# @param repo [String] a repository, possibly registry-qualified
|
|
197
|
+
# @return [String, nil] the registry hostname, or nil for Docker Hub
|
|
198
|
+
def self.registry_for(repo)
|
|
199
|
+
first = repo.to_s.split("/").first
|
|
200
|
+
return nil if first.nil?
|
|
201
|
+
# A first segment is a registry only if it looks like a host: it has a
|
|
202
|
+
# dot, a port, or is literally localhost. Otherwise it is a Hub org.
|
|
203
|
+
return first if first.include?(".") || first.include?(":") || first == "localhost"
|
|
204
|
+
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|