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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +202 -0
  3. data/NOTICE +12 -0
  4. data/README.md +278 -0
  5. data/docker-api-ng.gemspec +42 -0
  6. data/docs/building-images.md +98 -0
  7. data/docs/connecting.md +111 -0
  8. data/docs/errors.md +89 -0
  9. data/docs/exec.md +100 -0
  10. data/docs/extending.md +109 -0
  11. data/docs/migrating-from-docker-api.md +156 -0
  12. data/docs/streaming.md +91 -0
  13. data/lib/docker/api/auth.rb +154 -0
  14. data/lib/docker/api/body.rb +47 -0
  15. data/lib/docker/api/client.rb +117 -0
  16. data/lib/docker/api/collection.rb +60 -0
  17. data/lib/docker/api/collections/containers.rb +78 -0
  18. data/lib/docker/api/collections/images.rb +221 -0
  19. data/lib/docker/api/collections/networks.rb +85 -0
  20. data/lib/docker/api/collections/system.rb +98 -0
  21. data/lib/docker/api/collections/volumes.rb +54 -0
  22. data/lib/docker/api/config.rb +163 -0
  23. data/lib/docker/api/connection.rb +333 -0
  24. data/lib/docker/api/context.rb +124 -0
  25. data/lib/docker/api/errors.rb +159 -0
  26. data/lib/docker/api/operations.rb +3064 -0
  27. data/lib/docker/api/path.rb +33 -0
  28. data/lib/docker/api/platform.rb +68 -0
  29. data/lib/docker/api/query.rb +76 -0
  30. data/lib/docker/api/resource.rb +174 -0
  31. data/lib/docker/api/resources/container.rb +378 -0
  32. data/lib/docker/api/resources/exec.rb +48 -0
  33. data/lib/docker/api/resources/image.rb +209 -0
  34. data/lib/docker/api/resources/network.rb +90 -0
  35. data/lib/docker/api/resources/volume.rb +51 -0
  36. data/lib/docker/api/response.rb +110 -0
  37. data/lib/docker/api/session.rb +70 -0
  38. data/lib/docker/api/stream.rb +142 -0
  39. data/lib/docker/api/tar.rb +157 -0
  40. data/lib/docker/api/transport/base.rb +62 -0
  41. data/lib/docker/api/transport/fake.rb +154 -0
  42. data/lib/docker/api/transport/named_pipe.rb +58 -0
  43. data/lib/docker/api/transport/tcp.rb +49 -0
  44. data/lib/docker/api/transport/tls.rb +84 -0
  45. data/lib/docker/api/transport/unix.rb +35 -0
  46. data/lib/docker/api/transport.rb +113 -0
  47. data/lib/docker/api/version.rb +21 -0
  48. data/lib/docker/api.rb +68 -0
  49. data/sig/docker/api/core.rbs +109 -0
  50. data/sig/docker/api/operations.rbs +123 -0
  51. 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