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,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require "English"
|
|
7
|
+
|
|
8
|
+
module Docker
|
|
9
|
+
module API
|
|
10
|
+
# Registry credentials, resolved per call.
|
|
11
|
+
#
|
|
12
|
+
# There is no credential store on this module and no way to set one
|
|
13
|
+
# globally. Credentials are looked up for the registry a particular request
|
|
14
|
+
# is talking to and travel on that request as `X-Registry-Auth`, so pulling
|
|
15
|
+
# from two registries in one process needs no coordination.
|
|
16
|
+
module Auth
|
|
17
|
+
# Docker Hub's canonical auth key, which is not the hostname you pull from.
|
|
18
|
+
DOCKER_HUB_KEY = "https://index.docker.io/v1/"
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Find credentials for a registry and encode them for the daemon.
|
|
23
|
+
#
|
|
24
|
+
# Every failure mode here is soft. A missing config file, an unreadable
|
|
25
|
+
# one, or a credential helper that is not installed all mean "no
|
|
26
|
+
# credentials", because anonymous pulls of public images must keep
|
|
27
|
+
# working on a machine that has never run `docker login`.
|
|
28
|
+
#
|
|
29
|
+
# @param registry [String, nil] a registry hostname, or nil for Docker Hub
|
|
30
|
+
# @param config_path [String, nil] path to config.json, for tests
|
|
31
|
+
# @return [String, nil] a base64 `X-Registry-Auth` value, or nil
|
|
32
|
+
def resolve(registry = nil, config_path: nil)
|
|
33
|
+
key = registry_key(registry)
|
|
34
|
+
config = read_config(config_path)
|
|
35
|
+
return nil if config.nil?
|
|
36
|
+
|
|
37
|
+
credentials = from_helper(config, key) || from_auths(config, key)
|
|
38
|
+
return nil if credentials.nil?
|
|
39
|
+
|
|
40
|
+
encode(credentials)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Encode a credential hash the way the daemon expects it: base64url of a
|
|
44
|
+
# JSON document, in a header.
|
|
45
|
+
#
|
|
46
|
+
# Encoded with pack rather than with the base64 library, deliberately.
|
|
47
|
+
# `base64` stopped being a default gem in Ruby 3.4, so requiring it makes
|
|
48
|
+
# this a gem with a runtime dependency it does not declare -- which fails
|
|
49
|
+
# at `require` time for anyone whose bundle does not happen to carry it
|
|
50
|
+
# for another reason. `pack("m0")` is the same transform with no
|
|
51
|
+
# newlines, and tr maps the standard alphabet onto the URL-safe one the
|
|
52
|
+
# daemon expects.
|
|
53
|
+
#
|
|
54
|
+
# @param credentials [Hash] with :username, :password, :serveraddress
|
|
55
|
+
# @return [String]
|
|
56
|
+
def encode(credentials)
|
|
57
|
+
[JSON.generate(credentials)].pack("m0").tr("+/", "-_")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Docker Hub is stored under a URL key rather than its hostname, and an
|
|
61
|
+
# empty registry means Hub. Everything else is keyed by hostname.
|
|
62
|
+
#
|
|
63
|
+
# @param registry [String, nil]
|
|
64
|
+
# @return [String]
|
|
65
|
+
def registry_key(registry)
|
|
66
|
+
return DOCKER_HUB_KEY if registry.nil? || registry.empty?
|
|
67
|
+
return DOCKER_HUB_KEY if ["docker.io", "index.docker.io", "registry-1.docker.io"].include?(registry)
|
|
68
|
+
|
|
69
|
+
registry
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param path [String, nil]
|
|
73
|
+
# @return [Hash, nil]
|
|
74
|
+
def read_config(path)
|
|
75
|
+
path ||= File.join(Dir.home, ".docker", "config.json")
|
|
76
|
+
return nil unless File.readable?(path)
|
|
77
|
+
|
|
78
|
+
JSON.parse(File.read(path))
|
|
79
|
+
rescue JSON::ParserError, SystemCallError, ArgumentError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @param config [Hash]
|
|
84
|
+
# @param key [String]
|
|
85
|
+
# @return [Hash, nil]
|
|
86
|
+
def from_auths(config, key)
|
|
87
|
+
entry = config.dig("auths", key)
|
|
88
|
+
return nil if entry.nil?
|
|
89
|
+
|
|
90
|
+
if entry["auth"] && !entry["auth"].empty?
|
|
91
|
+
# unpack1("m") is Base64.decode64: lenient, ignoring newlines and
|
|
92
|
+
# any character outside the alphabet, which is what these values
|
|
93
|
+
# arrive as when a config.json has been hand-edited or line-wrapped.
|
|
94
|
+
username, _, password = entry["auth"].unpack1("m").partition(":")
|
|
95
|
+
return { username: username, password: password, serveraddress: key }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
return nil if entry["username"].nil?
|
|
99
|
+
|
|
100
|
+
{ username: entry["username"], password: entry["password"], serveraddress: key }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A helper named for this specific registry wins over the catch-all
|
|
104
|
+
# store, which is how `credHelpers` is specified to behave.
|
|
105
|
+
#
|
|
106
|
+
# @param config [Hash]
|
|
107
|
+
# @param key [String]
|
|
108
|
+
# @return [Hash, nil]
|
|
109
|
+
def from_helper(config, key)
|
|
110
|
+
helper = config.dig("credHelpers", key) ||
|
|
111
|
+
config.dig("credHelpers", URI(key).host.to_s) ||
|
|
112
|
+
config["credsStore"]
|
|
113
|
+
return nil if helper.nil? || helper.empty?
|
|
114
|
+
|
|
115
|
+
run_helper(helper, key)
|
|
116
|
+
rescue URI::InvalidURIError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Invoke `docker-credential-<helper> get`, which reads the registry on
|
|
121
|
+
# stdin and writes JSON on stdout.
|
|
122
|
+
#
|
|
123
|
+
# The command is passed as an argument array, never a shell string, so a
|
|
124
|
+
# helper name from a config file cannot become a shell injection.
|
|
125
|
+
#
|
|
126
|
+
# @param helper [String] the helper's short name
|
|
127
|
+
# @param key [String] the registry to ask about
|
|
128
|
+
# @return [Hash, nil]
|
|
129
|
+
def run_helper(helper, key)
|
|
130
|
+
output = IO.popen(["docker-credential-#{helper}", "get"], "r+") do |io|
|
|
131
|
+
io.write(key)
|
|
132
|
+
io.close_write
|
|
133
|
+
io.read
|
|
134
|
+
end
|
|
135
|
+
return nil unless $CHILD_STATUS.nil? || $CHILD_STATUS.success?
|
|
136
|
+
|
|
137
|
+
parsed = JSON.parse(output.to_s)
|
|
138
|
+
secret = parsed["Secret"]
|
|
139
|
+
return nil if secret.nil? || secret.empty?
|
|
140
|
+
|
|
141
|
+
# A username of <token> is the helper's way of saying the secret is an
|
|
142
|
+
# identity token rather than a password.
|
|
143
|
+
if parsed["Username"] == "<token>"
|
|
144
|
+
{ identitytoken: secret, serveraddress: parsed["ServerURL"] || key }
|
|
145
|
+
else
|
|
146
|
+
{ username: parsed["Username"], password: secret,
|
|
147
|
+
serveraddress: parsed["ServerURL"] || key }
|
|
148
|
+
end
|
|
149
|
+
rescue Errno::ENOENT, Errno::EACCES, JSON::ParserError, IOError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
# Builds request bodies without making callers shout in PascalCase.
|
|
9
|
+
module Body
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Convert top-level snake_case keys to the PascalCase the daemon uses.
|
|
13
|
+
#
|
|
14
|
+
# Only the top level is converted, deliberately. Nested structures are
|
|
15
|
+
# passed through exactly as given, because their keys are frequently data
|
|
16
|
+
# rather than field names -- `Labels`, `ExposedPorts`, `PortBindings` and
|
|
17
|
+
# `Sysctls` are all maps whose keys belong to the user. A recursive
|
|
18
|
+
# converter would mangle `{ "com.example/team" => "infra" }` into
|
|
19
|
+
# something the daemon has never heard of.
|
|
20
|
+
#
|
|
21
|
+
# Keys that already look like the daemon's own are left alone, so a body
|
|
22
|
+
# copied verbatim out of Docker's documentation keeps working.
|
|
23
|
+
#
|
|
24
|
+
# @param attributes [Hash] a body in either convention
|
|
25
|
+
# @return [Hash] a body in the daemon's convention
|
|
26
|
+
#
|
|
27
|
+
# @example
|
|
28
|
+
# Body.build(image: "alpine", host_config: { "Binds" => ["/a:/b"] })
|
|
29
|
+
# #=> { "Image" => "alpine", "HostConfig" => { "Binds" => ["/a:/b"] } }
|
|
30
|
+
def build(attributes)
|
|
31
|
+
(attributes || {}).each_with_object({}) do |(key, value), out|
|
|
32
|
+
out[camelize(key)] = value unless value.nil?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param key [String, Symbol]
|
|
37
|
+
# @return [String] the daemon's spelling of a field name
|
|
38
|
+
def camelize(key)
|
|
39
|
+
name = key.to_s
|
|
40
|
+
# Already PascalCase, or a literal the caller means verbatim.
|
|
41
|
+
return name if name.match?(/\A[A-Z]/)
|
|
42
|
+
|
|
43
|
+
name.split("_").map { |part| part.sub(/\A(.)/) { Regexp.last_match(1).upcase } }.join
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
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 connection to one Docker daemon, and the way in to everything else.
|
|
9
|
+
#
|
|
10
|
+
# A client owns its configuration and its connection. There is no global
|
|
11
|
+
# state behind it: two clients addressing two daemons share nothing, so a
|
|
12
|
+
# process can talk to a local daemon and a remote builder at the same time
|
|
13
|
+
# without either one noticing.
|
|
14
|
+
#
|
|
15
|
+
# @example The local daemon
|
|
16
|
+
# client = Docker::API::Client.new
|
|
17
|
+
# client.system.info["ServerVersion"]
|
|
18
|
+
#
|
|
19
|
+
# @example A remote daemon over TLS
|
|
20
|
+
# client = Docker::API::Client.new(
|
|
21
|
+
# url: "tcp://build.internal:2376",
|
|
22
|
+
# tls: { ca_file: "ca.pem", cert_file: "cert.pem", key_file: "key.pem" }
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# @example Reaching an endpoint with no ergonomic wrapper
|
|
26
|
+
# client.operations.container_prune(filters: { "until" => ["24h"] })
|
|
27
|
+
class Client
|
|
28
|
+
# @return [Docker::API::Config] this client's frozen configuration
|
|
29
|
+
attr_reader :config
|
|
30
|
+
|
|
31
|
+
# @return [Docker::API::Connection] the connection in use
|
|
32
|
+
attr_reader :connection
|
|
33
|
+
|
|
34
|
+
# @param url [String, nil] the daemon URL. Falls back to `DOCKER_HOST`,
|
|
35
|
+
# then to the platform's default socket.
|
|
36
|
+
# @param tls [Hash, nil] TLS material: `:ca_file`, `:cert_file`,
|
|
37
|
+
# `:key_file`, `:verify`. Falls back to `DOCKER_CERT_PATH`.
|
|
38
|
+
# @param api_version [String, Symbol] `:negotiate` to ask the daemon what
|
|
39
|
+
# it speaks, a version such as `"1.44"` to pin, or `:none` to send
|
|
40
|
+
# unprefixed paths
|
|
41
|
+
# @param logger [Logger, nil] receives one debug line per request
|
|
42
|
+
# @param read_timeout [Numeric] seconds to wait for response data
|
|
43
|
+
# @param open_timeout [Numeric] seconds to wait for a connection
|
|
44
|
+
# @param transport [Docker::API::Transport::Base, nil] a transport to use
|
|
45
|
+
# instead of building one from the URL. Mainly for tests, where
|
|
46
|
+
# {Docker::API::Transport::Fake} stands in for a daemon.
|
|
47
|
+
# @param env [Hash] the environment to resolve defaults from
|
|
48
|
+
def initialize(url: nil, tls: nil, api_version: :negotiate, logger: nil,
|
|
49
|
+
read_timeout: 60, open_timeout: 10, transport: nil, env: ENV)
|
|
50
|
+
@config = Config.from_env(
|
|
51
|
+
env,
|
|
52
|
+
url: url, api_version: api_version,
|
|
53
|
+
read_timeout: read_timeout, open_timeout: open_timeout,
|
|
54
|
+
**(tls.nil? ? {} : { tls: tls })
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@connection = Connection.new(
|
|
58
|
+
transport: transport || Transport.for(
|
|
59
|
+
url: config.url, tls: config.tls, open_timeout: config.open_timeout
|
|
60
|
+
),
|
|
61
|
+
api_version: config.api_version,
|
|
62
|
+
logger: logger,
|
|
63
|
+
read_timeout: config.read_timeout,
|
|
64
|
+
open_timeout: config.open_timeout
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Every Engine API operation, one method each.
|
|
69
|
+
#
|
|
70
|
+
# This is public API rather than an escape hatch. The ergonomic
|
|
71
|
+
# collections below cover what most code needs; anything they have not
|
|
72
|
+
# grown sugar for is reachable here, fully documented and typed, with no
|
|
73
|
+
# loss of capability.
|
|
74
|
+
#
|
|
75
|
+
# @return [Docker::API::Operations]
|
|
76
|
+
def operations
|
|
77
|
+
@operations ||= Operations.new(connection)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Docker::API::Containers]
|
|
81
|
+
def containers
|
|
82
|
+
@containers ||= Containers.new(self)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Docker::API::Images]
|
|
86
|
+
def images
|
|
87
|
+
@images ||= Images.new(self)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Docker::API::Networks]
|
|
91
|
+
def networks
|
|
92
|
+
@networks ||= Networks.new(self)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Docker::API::Volumes]
|
|
96
|
+
def volumes
|
|
97
|
+
@volumes ||= Volumes.new(self)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Docker::API::System]
|
|
101
|
+
def system
|
|
102
|
+
@system ||= System.new(self)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [String, nil] the negotiated Engine API version
|
|
106
|
+
def api_version
|
|
107
|
+
connection.api_version
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [String]
|
|
111
|
+
def to_s
|
|
112
|
+
"#<Docker::API::Client url=#{config.url}>"
|
|
113
|
+
end
|
|
114
|
+
alias_method :inspect, :to_s
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
# Shared behaviour for the collections hanging off a {Docker::API::Client}.
|
|
9
|
+
#
|
|
10
|
+
# Collections are thin by design: each method is one call into the
|
|
11
|
+
# generated operations layer plus whatever wrapping makes the result
|
|
12
|
+
# pleasant. Keeping them thin is what stops the ergonomic layer from
|
|
13
|
+
# drifting away from the API it is supposed to be sugar for.
|
|
14
|
+
#
|
|
15
|
+
# @abstract
|
|
16
|
+
class Collection
|
|
17
|
+
# @return [Docker::API::Client] the client this collection belongs to
|
|
18
|
+
attr_reader :client
|
|
19
|
+
|
|
20
|
+
# @param client [Docker::API::Client]
|
|
21
|
+
def initialize(client)
|
|
22
|
+
@client = client
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Fetch one object, or nil if the daemon has never heard of it.
|
|
26
|
+
#
|
|
27
|
+
# The distinction from `get` is deliberate. Asking for something by a
|
|
28
|
+
# name a user typed is a question; asking for something you just created
|
|
29
|
+
# is an assertion. `find` answers the question, `get` makes the
|
|
30
|
+
# assertion and raises when it turns out to be false.
|
|
31
|
+
#
|
|
32
|
+
# @param id [String] a name or id
|
|
33
|
+
# @return [Docker::API::Resource, nil]
|
|
34
|
+
def find(id)
|
|
35
|
+
get(id)
|
|
36
|
+
rescue NotFound
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param id [String] a name or id
|
|
41
|
+
# @return [Boolean] whether the object exists
|
|
42
|
+
def exist?(id)
|
|
43
|
+
!find(id).nil?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [String]
|
|
47
|
+
def to_s
|
|
48
|
+
"#<#{self.class.name}>"
|
|
49
|
+
end
|
|
50
|
+
alias_method :inspect, :to_s
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# @return [Docker::API::Operations]
|
|
55
|
+
def operations
|
|
56
|
+
client.operations
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
# The containers on a daemon.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# client.containers.all(all: true)
|
|
12
|
+
# client.containers.create(image: "alpine:3.20", name: "scratch", cmd: %w{sleep 30})
|
|
13
|
+
class Containers < Collection
|
|
14
|
+
# List containers.
|
|
15
|
+
#
|
|
16
|
+
# @param all [Boolean] include containers that are not running
|
|
17
|
+
# @param limit [Integer, nil] return only the most recent N
|
|
18
|
+
# @param size [Boolean] include the filesystem size of each
|
|
19
|
+
# @param filters [Hash, nil] daemon-side filters, e.g. `{ "status" => ["running"] }`
|
|
20
|
+
# @return [Array<Docker::API::Container>] partial resources; accessors
|
|
21
|
+
# that need detail the list did not carry will fetch it
|
|
22
|
+
def all(all: false, limit: nil, size: false, filters: nil)
|
|
23
|
+
operations.container_list(all: all, limit: limit, size: size, filters: filters)
|
|
24
|
+
.json!.map { |payload| Container.new(client: client, raw: payload, partial: true) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Fetch one container.
|
|
28
|
+
#
|
|
29
|
+
# @param id [String] a name or id
|
|
30
|
+
# @param size [Boolean] include filesystem size information
|
|
31
|
+
# @return [Docker::API::Container]
|
|
32
|
+
# @raise [Docker::API::NotFound] if there is no such container
|
|
33
|
+
def get(id, size: false)
|
|
34
|
+
Container.new(
|
|
35
|
+
client: client,
|
|
36
|
+
raw: operations.container_inspect(id: id, size: size).json,
|
|
37
|
+
partial: false
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Create a container.
|
|
42
|
+
#
|
|
43
|
+
# Body attributes may be given in snake_case and are converted to the
|
|
44
|
+
# daemon's spelling; keys already in the daemon's convention are passed
|
|
45
|
+
# through untouched, so a configuration copied out of Docker's own
|
|
46
|
+
# documentation works as-is. Only top-level keys are converted -- nested
|
|
47
|
+
# maps such as `Labels` and `PortBindings` have keys that are data.
|
|
48
|
+
#
|
|
49
|
+
# @param name [String, nil] a name for the container
|
|
50
|
+
# @param platform [String, nil] "os[/arch[/variant]]" to create for
|
|
51
|
+
# @param attributes [Hash] the container configuration
|
|
52
|
+
# @return [Docker::API::Container]
|
|
53
|
+
#
|
|
54
|
+
# @example
|
|
55
|
+
# client.containers.create(
|
|
56
|
+
# image: "alpine:3.20",
|
|
57
|
+
# name: "worker",
|
|
58
|
+
# cmd: %w{sleep 3600},
|
|
59
|
+
# env: ["LOG_LEVEL=debug"],
|
|
60
|
+
# host_config: { "Binds" => ["/data:/data:ro"] }
|
|
61
|
+
# )
|
|
62
|
+
def create(name: nil, platform: nil, **attributes)
|
|
63
|
+
response = operations.container_create(
|
|
64
|
+
body: Body.build(attributes), name: name, platform: platform
|
|
65
|
+
)
|
|
66
|
+
get(response.json!["Id"])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Remove stopped containers.
|
|
70
|
+
#
|
|
71
|
+
# @param filters [Hash, nil] which containers to consider
|
|
72
|
+
# @return [Hash] what was deleted and how much space it freed
|
|
73
|
+
def prune(filters: nil)
|
|
74
|
+
operations.container_prune(filters: filters).json
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
# The images on a daemon.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# client.images.pull("alpine:3.20", platform: "linux/arm64")
|
|
12
|
+
# client.images.build(context: "./app", tag: "app:dev")
|
|
13
|
+
class Images < Collection
|
|
14
|
+
# List images.
|
|
15
|
+
#
|
|
16
|
+
# @param all [Boolean] include intermediate layers
|
|
17
|
+
# @param filters [Hash, nil] daemon-side filters
|
|
18
|
+
# @param digests [Boolean] include repository digests
|
|
19
|
+
# @return [Array<Docker::API::Image>] partial resources
|
|
20
|
+
def all(all: false, filters: nil, digests: false)
|
|
21
|
+
operations.image_list(all: all, filters: filters, digests: digests)
|
|
22
|
+
.json!.map { |payload| Image.new(client: client, raw: payload, partial: true) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Fetch one image.
|
|
26
|
+
#
|
|
27
|
+
# @param name [String] a reference, id or digest
|
|
28
|
+
# @param platform [String, Hash, nil] which platform's image to describe,
|
|
29
|
+
# as "linux/arm64" or an OCI platform hash
|
|
30
|
+
# @return [Docker::API::Image]
|
|
31
|
+
# @raise [Docker::API::NotFound] if there is no such image
|
|
32
|
+
def get(name, platform: nil)
|
|
33
|
+
Image.new(
|
|
34
|
+
client: client,
|
|
35
|
+
# This endpoint wants the OCI object form, unlike pull and build,
|
|
36
|
+
# which want the string. See Docker::API::Platform.
|
|
37
|
+
raw: operations.image_inspect(name: name, platform: Platform.oci(platform)).json,
|
|
38
|
+
partial: false
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether an image is present locally.
|
|
43
|
+
#
|
|
44
|
+
# @param name [String] a reference, id or digest
|
|
45
|
+
# @param platform [String, nil] which platform to ask about
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def exist?(name, platform: nil)
|
|
48
|
+
!get(name, platform: platform).nil?
|
|
49
|
+
rescue NotFound
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Fetch an image, pulling it first if the daemon does not have it.
|
|
54
|
+
#
|
|
55
|
+
# @param reference [String] the image to ensure is present
|
|
56
|
+
# @param platform [String, Hash, nil] the platform to select or pull
|
|
57
|
+
# @param auth [String, nil] an X-Registry-Auth value
|
|
58
|
+
# @yieldparam event [Hash] pull progress, when a pull is needed
|
|
59
|
+
# @return [Docker::API::Image]
|
|
60
|
+
def ensure(reference, platform: nil, auth: nil, &block)
|
|
61
|
+
get(reference, platform: platform)
|
|
62
|
+
rescue NotFound
|
|
63
|
+
pull(reference, platform: platform, auth: auth, &block)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Pull an image from a registry.
|
|
67
|
+
#
|
|
68
|
+
# Credentials are resolved for the registry in the reference, so pulling
|
|
69
|
+
# from two private registries in one process needs no setup between
|
|
70
|
+
# calls. Pass `auth:` to override.
|
|
71
|
+
#
|
|
72
|
+
# @param reference [String] "alpine:3.20", "registry.io/team/app:1.0"
|
|
73
|
+
# @param platform [String, Hash, nil] the platform to pull, as
|
|
74
|
+
# "linux/arm64" or an OCI platform hash
|
|
75
|
+
# @param auth [String, nil] an X-Registry-Auth value
|
|
76
|
+
# @yieldparam event [Hash] progress events as they arrive
|
|
77
|
+
# @return [Docker::API::Image]
|
|
78
|
+
#
|
|
79
|
+
# @example Watching progress
|
|
80
|
+
# client.images.pull("alpine:3.20") { |event| puts event["status"] }
|
|
81
|
+
def pull(reference, platform: nil, auth: nil, &block)
|
|
82
|
+
repo, tag = Image.split_reference(reference)
|
|
83
|
+
credentials = auth || Auth.resolve(Image.registry_for(repo))
|
|
84
|
+
|
|
85
|
+
stream = block ? Stream::JSONLines.new(&block) : nil
|
|
86
|
+
# Pull wants the string form; inspect below wants the object form.
|
|
87
|
+
operations.image_create(
|
|
88
|
+
from_image: repo, tag: tag, platform: Platform.string(platform),
|
|
89
|
+
x_registry_auth: credentials
|
|
90
|
+
) { |chunk| stream << chunk if stream }
|
|
91
|
+
|
|
92
|
+
# Reassembled rather than reusing the caller's string, because the tag
|
|
93
|
+
# may have been defaulted to "latest" along the way -- and because a
|
|
94
|
+
# digest has to go back together with "@" rather than ":".
|
|
95
|
+
get(Image.join_reference(repo, tag), platform: platform)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Build an image.
|
|
99
|
+
#
|
|
100
|
+
# The context may be a directory, which is tarred honouring its
|
|
101
|
+
# .dockerignore, or a Dockerfile given as a string for the common case of
|
|
102
|
+
# a short generated build with no accompanying files.
|
|
103
|
+
#
|
|
104
|
+
# @param context [String, IO, nil] a directory path, or tar bytes
|
|
105
|
+
# @param dockerfile [String, nil] Dockerfile contents, when there is no
|
|
106
|
+
# directory, or the name of the Dockerfile within the context
|
|
107
|
+
# @param tag [String, nil] the reference to give the result
|
|
108
|
+
# @param platform [String, Hash, nil] the platform to build for
|
|
109
|
+
# @param nocache [Boolean] ignore the layer cache
|
|
110
|
+
# @param rm [Boolean] remove intermediate containers
|
|
111
|
+
# @param pull [Boolean] always re-pull the base image
|
|
112
|
+
# @param buildargs [Hash, nil] --build-arg values
|
|
113
|
+
# @param labels [Hash, nil] labels for the resulting image
|
|
114
|
+
# @param target [String, nil] which stage of a multi-stage build to stop at
|
|
115
|
+
# @yieldparam event [Hash] build output as it arrives
|
|
116
|
+
# @return [Docker::API::Image]
|
|
117
|
+
# @raise [Docker::API::Error] if the daemon reports a build failure
|
|
118
|
+
#
|
|
119
|
+
# @example Building from a directory
|
|
120
|
+
# client.images.build(context: "./app", tag: "app:dev") do |event|
|
|
121
|
+
# print event["stream"]
|
|
122
|
+
# end
|
|
123
|
+
#
|
|
124
|
+
# @example Building from a Dockerfile in memory
|
|
125
|
+
# client.images.build(dockerfile: "FROM alpine\nRUN apk add curl\n", tag: "curl:dev")
|
|
126
|
+
def build(context: nil, dockerfile: nil, tag: nil, platform: nil, nocache: false,
|
|
127
|
+
rm: true, pull: false, buildargs: nil, labels: nil, target: nil, &block)
|
|
128
|
+
archive, dockerfile_name = build_context(context, dockerfile)
|
|
129
|
+
image_id = nil
|
|
130
|
+
failure = nil
|
|
131
|
+
|
|
132
|
+
stream = Stream::JSONLines.new do |event|
|
|
133
|
+
image_id ||= event.dig("aux", "ID")
|
|
134
|
+
failure ||= event["error"]
|
|
135
|
+
block&.call(event)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
operations.image_build(
|
|
139
|
+
body: archive, content_type: "application/x-tar",
|
|
140
|
+
t: tag, platform: Platform.string(platform), nocache: nocache, rm: rm, pull: pull,
|
|
141
|
+
dockerfile: dockerfile_name, target: target,
|
|
142
|
+
buildargs: buildargs, labels: labels
|
|
143
|
+
) { |chunk| stream << chunk }
|
|
144
|
+
|
|
145
|
+
# The daemon reports build failures inside a 200 response rather than
|
|
146
|
+
# as a status code, so a build that failed looks like a success to
|
|
147
|
+
# anything that only checks HTTP.
|
|
148
|
+
raise Error.new("image build failed: #{failure}", operation: "image_build") if failure
|
|
149
|
+
|
|
150
|
+
get(built_reference(tag, image_id))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Remove unused images.
|
|
154
|
+
#
|
|
155
|
+
# @param filters [Hash, nil] which images to consider
|
|
156
|
+
# @return [Hash] what was deleted and how much space it freed
|
|
157
|
+
def prune(filters: nil)
|
|
158
|
+
operations.image_prune(filters: filters).json
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Search Docker Hub.
|
|
162
|
+
#
|
|
163
|
+
# @param term [String] what to search for
|
|
164
|
+
# @param limit [Integer, nil] how many results to return
|
|
165
|
+
# @param filters [Hash, nil] search filters
|
|
166
|
+
# @return [Array<Hash>]
|
|
167
|
+
def search(term, limit: nil, filters: nil)
|
|
168
|
+
operations.image_search(term: term, limit: limit, filters: filters).json
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# What to inspect once a build has finished.
|
|
174
|
+
#
|
|
175
|
+
# A tag is the reliable answer. The image id only turns up if the daemon
|
|
176
|
+
# emitted an `aux.ID` event, which the classic builder does and BuildKit
|
|
177
|
+
# does not always -- so an untagged build could reach here with neither,
|
|
178
|
+
# and `get(nil)` asked the daemon for `GET /v1.55/images//json`. That
|
|
179
|
+
# comes back as a 404 about a missing image, which points at everything
|
|
180
|
+
# except the actual problem.
|
|
181
|
+
#
|
|
182
|
+
# @param tag [String, nil]
|
|
183
|
+
# @param image_id [String, nil]
|
|
184
|
+
# @return [String]
|
|
185
|
+
# @raise [Docker::API::Error]
|
|
186
|
+
def built_reference(tag, image_id)
|
|
187
|
+
reference = tag || image_id
|
|
188
|
+
return reference if reference
|
|
189
|
+
|
|
190
|
+
raise Error.new(
|
|
191
|
+
"the build succeeded but produced nothing to look up: no tag: was given and the " \
|
|
192
|
+
"daemon reported no image id. Pass tag: to name the result, which BuildKit needs " \
|
|
193
|
+
"in order to report one.",
|
|
194
|
+
operation: "image_build"
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @return [Array(IO, String, nil)] the archive and the Dockerfile name
|
|
199
|
+
def build_context(context, dockerfile)
|
|
200
|
+
return [Tar.pack_dockerfile(dockerfile), "Dockerfile"] if context.nil? && dockerfile
|
|
201
|
+
|
|
202
|
+
raise ArgumentError, "build needs a context: or a dockerfile:" if context.nil?
|
|
203
|
+
return [context, dockerfile] unless context.is_a?(String) && File.directory?(context)
|
|
204
|
+
|
|
205
|
+
# dockerfile: means two different things depending on whether there is
|
|
206
|
+
# a context, and getting them the wrong way round is silent: the whole
|
|
207
|
+
# Dockerfile source goes out as the `dockerfile=` query parameter, and
|
|
208
|
+
# the daemon simply reports that it cannot find that file. A newline is
|
|
209
|
+
# the tell -- a filename inside a build context does not contain one.
|
|
210
|
+
if dockerfile.is_a?(String) && dockerfile.include?("\n")
|
|
211
|
+
raise ArgumentError,
|
|
212
|
+
"dockerfile: names a file inside context: -- it looks like you passed the file's " \
|
|
213
|
+
"contents. Drop context: to build from a Dockerfile given inline, or pass the name " \
|
|
214
|
+
"of a Dockerfile within #{context.inspect}."
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
[Tar.pack_directory(context), dockerfile]
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|