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,333 @@
|
|
|
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
|
+
# Everything between "a socket exists" and "a Ruby value comes back".
|
|
9
|
+
#
|
|
10
|
+
# A connection owns request building, API version negotiation, error
|
|
11
|
+
# mapping, streaming and hijack. It is immutable once built: there is no
|
|
12
|
+
# setter for the URL or for credentials, which is what lets two connections
|
|
13
|
+
# address two daemons in one process without interfering.
|
|
14
|
+
class Connection
|
|
15
|
+
# Endpoints that must not carry a version prefix, because they are how we
|
|
16
|
+
# discover which version to use.
|
|
17
|
+
UNVERSIONED = ["/_ping"].freeze
|
|
18
|
+
|
|
19
|
+
# HTTP verbs mapped to the Net::HTTP request classes that implement them.
|
|
20
|
+
VERBS = {
|
|
21
|
+
get: Net::HTTP::Get,
|
|
22
|
+
post: Net::HTTP::Post,
|
|
23
|
+
put: Net::HTTP::Put,
|
|
24
|
+
patch: Net::HTTP::Patch,
|
|
25
|
+
delete: Net::HTTP::Delete,
|
|
26
|
+
head: Net::HTTP::Head,
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# @return [Docker::API::Transport::Base] the transport in use
|
|
30
|
+
attr_reader :transport
|
|
31
|
+
|
|
32
|
+
# @return [Logger, nil] where request metadata is logged, if anywhere
|
|
33
|
+
attr_reader :logger
|
|
34
|
+
|
|
35
|
+
# @param transport [Docker::API::Transport::Base] how to reach the daemon
|
|
36
|
+
# @param api_version [String, Symbol] `:negotiate` to ask the daemon,
|
|
37
|
+
# a version string such as `"1.44"` to pin, or `:none` to send
|
|
38
|
+
# unprefixed paths
|
|
39
|
+
# @param logger [Logger, nil] receives one debug line per request
|
|
40
|
+
# @param read_timeout [Numeric] seconds to wait for response data
|
|
41
|
+
# @param open_timeout [Numeric] seconds to wait for a connection
|
|
42
|
+
def initialize(transport:, api_version: :negotiate, logger: nil,
|
|
43
|
+
read_timeout: 60, open_timeout: 10)
|
|
44
|
+
@transport = transport
|
|
45
|
+
@configured_api_version = api_version
|
|
46
|
+
@logger = logger
|
|
47
|
+
@read_timeout = read_timeout
|
|
48
|
+
@open_timeout = open_timeout
|
|
49
|
+
@version_lock = Mutex.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The API version this connection prefixes its requests with.
|
|
53
|
+
#
|
|
54
|
+
# Negotiation happens once, lazily, on the first request that needs it.
|
|
55
|
+
#
|
|
56
|
+
# @return [String, nil] the version, or nil when versioning is disabled
|
|
57
|
+
# @raise [Docker::API::VersionUnsupported] if the daemon is too old
|
|
58
|
+
def api_version
|
|
59
|
+
return @api_version if defined?(@api_version)
|
|
60
|
+
|
|
61
|
+
@version_lock.synchronize do
|
|
62
|
+
@api_version = resolve_api_version unless defined?(@api_version)
|
|
63
|
+
end
|
|
64
|
+
@api_version
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Ask the daemon whether it is alive. Never version-prefixed.
|
|
68
|
+
#
|
|
69
|
+
# @return [Docker::API::Response]
|
|
70
|
+
def ping
|
|
71
|
+
request(:get, "/_ping", operation: "ping")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Send a request and interpret the answer.
|
|
75
|
+
#
|
|
76
|
+
# @param method [Symbol] one of :get, :post, :put, :patch, :delete, :head
|
|
77
|
+
# @param path [String] the path, without a version prefix
|
|
78
|
+
# @param query [Hash] query parameters in Ruby types
|
|
79
|
+
# @param body [Hash, Array, String, IO, nil] the request body. Hashes and
|
|
80
|
+
# arrays are JSON-encoded; strings and IOs are sent as-is.
|
|
81
|
+
# @param headers [Hash] additional request headers
|
|
82
|
+
# @param expects [Array<Integer>] statuses that mean success
|
|
83
|
+
# @param operation [String, Symbol, nil] names this call in errors and logs
|
|
84
|
+
# @param content_type [String, nil] overrides the computed content type
|
|
85
|
+
# @yieldparam chunk [String] successive body chunks, when streaming
|
|
86
|
+
# @return [Docker::API::Response] with an empty body when streamed
|
|
87
|
+
# @raise [Docker::API::Error] for any status outside `expects`
|
|
88
|
+
def request(method, path, query: {}, body: nil, headers: {}, expects: [200],
|
|
89
|
+
operation: nil, content_type: nil, &block)
|
|
90
|
+
full_path = build_path(path, query)
|
|
91
|
+
payload, computed_type = encode_body(body)
|
|
92
|
+
request_headers = build_headers(headers, content_type || computed_type, payload)
|
|
93
|
+
|
|
94
|
+
log(method, full_path, operation)
|
|
95
|
+
|
|
96
|
+
response = perform(method, full_path, payload, request_headers, expects, &block)
|
|
97
|
+
return response if expects.include?(response.status)
|
|
98
|
+
|
|
99
|
+
raise Error.for(status: response.status, operation: operation, response: response)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Take over the socket after an upgrade, for interactive exec and attach.
|
|
103
|
+
#
|
|
104
|
+
# This deliberately does not go through Net::HTTP. An interactive session
|
|
105
|
+
# needs the socket itself, and `Net::BufferedIO` may already have read
|
|
106
|
+
# ahead past the response headers. Rather than prise a buffered socket
|
|
107
|
+
# back out of the stdlib -- the problem that produced docker-api's custom
|
|
108
|
+
# Excon middleware -- this writes the request itself and hands back the
|
|
109
|
+
# socket with nothing consumed but the response head.
|
|
110
|
+
#
|
|
111
|
+
# @param method [Symbol] the HTTP verb
|
|
112
|
+
# @param path [String] the path, without a version prefix
|
|
113
|
+
# @param query [Hash] query parameters
|
|
114
|
+
# @param body [Hash, String, nil] the request body
|
|
115
|
+
# @param headers [Hash] additional headers
|
|
116
|
+
# @param operation [String, Symbol, nil] names this call in errors
|
|
117
|
+
# @return [IO] the bidirectional socket, positioned at the stream body
|
|
118
|
+
# @raise [Docker::API::Error] if the daemon refused to upgrade
|
|
119
|
+
def hijack(method, path, query: {}, body: nil, headers: {}, operation: nil)
|
|
120
|
+
io = transport.connect
|
|
121
|
+
full_path = build_path(path, query)
|
|
122
|
+
payload, computed_type = encode_body(body)
|
|
123
|
+
|
|
124
|
+
write_raw_request(io, method, full_path, payload, headers, computed_type)
|
|
125
|
+
status, response_headers = read_raw_response_head(io)
|
|
126
|
+
|
|
127
|
+
unless [101, 200].include?(status)
|
|
128
|
+
error_body = io.read.to_s
|
|
129
|
+
io.close unless io.closed?
|
|
130
|
+
raise Error.for(
|
|
131
|
+
status: status, operation: operation,
|
|
132
|
+
response: Response.new(status: status, headers: response_headers, body: error_body)
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
io
|
|
137
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, EOFError => e
|
|
138
|
+
io&.close unless io.nil? || io.closed?
|
|
139
|
+
raise ConnectionError.new("the daemon closed the connection during #{operation}: #{e.message}")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [String]
|
|
143
|
+
def to_s
|
|
144
|
+
"#<Docker::API::Connection transport=#{transport} api_version=#{
|
|
145
|
+
defined?(@api_version) ? @api_version : "unnegotiated"}>"
|
|
146
|
+
end
|
|
147
|
+
alias_method :inspect, :to_s
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# @return [String, nil]
|
|
152
|
+
def resolve_api_version
|
|
153
|
+
case @configured_api_version
|
|
154
|
+
when :none then nil
|
|
155
|
+
when :negotiate then negotiate_api_version
|
|
156
|
+
else @configured_api_version.to_s.sub(/\Av/, "")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Ask the daemon what it speaks and meet it in the middle.
|
|
161
|
+
#
|
|
162
|
+
# Pinning to the daemon's version when it is older keeps us inside what
|
|
163
|
+
# it understands; capping at what this gem vendors keeps us inside what
|
|
164
|
+
# the generated layer knows how to ask for.
|
|
165
|
+
#
|
|
166
|
+
# @return [String]
|
|
167
|
+
# @raise [Docker::API::VersionUnsupported]
|
|
168
|
+
def negotiate_api_version
|
|
169
|
+
response = ping
|
|
170
|
+
daemon = response["api-version"]
|
|
171
|
+
|
|
172
|
+
return MAX_API_VERSION if daemon.nil? || daemon.empty?
|
|
173
|
+
|
|
174
|
+
if Gem::Version.new(daemon) < Gem::Version.new(MIN_API_VERSION)
|
|
175
|
+
raise VersionUnsupported.new(
|
|
176
|
+
"the daemon speaks Engine API v#{daemon}, but docker-api-ng requires " \
|
|
177
|
+
"at least v#{MIN_API_VERSION}. Upgrade Docker, or pin an older release of this gem."
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
[Gem::Version.new(daemon), Gem::Version.new(MAX_API_VERSION)].min.to_s
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @param path [String]
|
|
185
|
+
# @param query [Hash]
|
|
186
|
+
# @return [String]
|
|
187
|
+
def build_path(path, query)
|
|
188
|
+
prefix = if UNVERSIONED.include?(path) || api_version.nil?
|
|
189
|
+
""
|
|
190
|
+
else
|
|
191
|
+
"/v#{api_version}"
|
|
192
|
+
end
|
|
193
|
+
"#{prefix}#{path}#{Query.encode(query)}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Encode a body and work out what to call it.
|
|
197
|
+
#
|
|
198
|
+
# Every raw body in this API is an archive: a build context, a container
|
|
199
|
+
# filesystem, an image tarball. Structured bodies are JSON. There is no
|
|
200
|
+
# third case, so a body-bearing request never goes out unlabelled.
|
|
201
|
+
#
|
|
202
|
+
# That matters more than it looks. The daemon rejects the wrong content
|
|
203
|
+
# type on its archive endpoints -- docker-api carries a middleware that
|
|
204
|
+
# retries after parsing "Content-Type: application/json is not
|
|
205
|
+
# supported. Should be application/x-tar" out of an error body -- and an
|
|
206
|
+
# absent type is at the mercy of whatever the HTTP layer decides to
|
|
207
|
+
# guess.
|
|
208
|
+
#
|
|
209
|
+
# @param body [Object]
|
|
210
|
+
# @return [Array(String, IO, nil, String, nil)] the payload and its content type
|
|
211
|
+
def encode_body(body)
|
|
212
|
+
case body
|
|
213
|
+
when nil then [nil, nil]
|
|
214
|
+
when Hash, Array then [JSON.generate(body), "application/json"]
|
|
215
|
+
else [body, "application/x-tar"]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @param headers [Hash]
|
|
220
|
+
# @param content_type [String, nil]
|
|
221
|
+
# @param payload [Object]
|
|
222
|
+
# @return [Hash{String => String}]
|
|
223
|
+
def build_headers(headers, content_type, payload)
|
|
224
|
+
base = {
|
|
225
|
+
"User-Agent" => "docker-api-ng/#{VERSION} (Ruby #{RUBY_VERSION})",
|
|
226
|
+
"Accept" => "application/json",
|
|
227
|
+
}
|
|
228
|
+
base["Content-Type"] = content_type if content_type && payload
|
|
229
|
+
base.merge(headers.transform_keys(&:to_s))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @return [Docker::API::Response]
|
|
233
|
+
def perform(method, full_path, payload, headers, expects, &block)
|
|
234
|
+
klass = VERBS.fetch(method) { raise ArgumentError, "unsupported HTTP method: #{method.inspect}" }
|
|
235
|
+
request = klass.new(full_path, headers)
|
|
236
|
+
attach_payload(request, payload)
|
|
237
|
+
|
|
238
|
+
session = Session.new(transport, read_timeout: @read_timeout, open_timeout: @open_timeout)
|
|
239
|
+
result = nil
|
|
240
|
+
|
|
241
|
+
session.start do |http|
|
|
242
|
+
http.request(request) do |raw|
|
|
243
|
+
status = raw.code.to_i
|
|
244
|
+
result = if block && expects.include?(status)
|
|
245
|
+
raw.read_body { |chunk| block.call(chunk) }
|
|
246
|
+
Response.new(status: status, headers: raw.to_hash, body: nil)
|
|
247
|
+
else
|
|
248
|
+
Response.new(status: status, headers: raw.to_hash, body: raw.read_body)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
result
|
|
254
|
+
# Net::OpenTimeout, Net::ReadTimeout and Net::WriteTimeout all descend
|
|
255
|
+
# from Timeout::Error, so naming them individually would only shadow it.
|
|
256
|
+
rescue Timeout::Error => e
|
|
257
|
+
raise TimeoutError.new("#{transport} did not answer in time: #{e.class}: #{e.message}")
|
|
258
|
+
# EOFError is an IOError, so listing it separately would only shadow it.
|
|
259
|
+
rescue Net::HTTPBadResponse, Net::ProtocolError, IOError,
|
|
260
|
+
SystemCallError, SocketError, OpenSSL::SSL::SSLError => e
|
|
261
|
+
raise ConnectionError.new("#{transport} failed mid-request: #{e.class}: #{e.message}")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Streamed by capability rather than by class.
|
|
265
|
+
#
|
|
266
|
+
# `IO, StringIO` looked exhaustive and is not: Tempfile is a delegator
|
|
267
|
+
# around File, so `Tempfile.new.is_a?(IO)` is false. A build context
|
|
268
|
+
# packed to a temporary file would have fallen through to `body = payload`
|
|
269
|
+
# and been sent as the delegator's to_s -- a string like
|
|
270
|
+
# `#<Tempfile:...>` where the daemon expected a tar. What Net::HTTP
|
|
271
|
+
# actually needs from a body stream is `read`, so that is what is asked
|
|
272
|
+
# for.
|
|
273
|
+
#
|
|
274
|
+
# @return [void]
|
|
275
|
+
def attach_payload(request, payload)
|
|
276
|
+
case payload
|
|
277
|
+
when nil then nil
|
|
278
|
+
when String then request.body = payload
|
|
279
|
+
else
|
|
280
|
+
if payload.respond_to?(:read)
|
|
281
|
+
request.body_stream = payload
|
|
282
|
+
request["Transfer-Encoding"] = "chunked"
|
|
283
|
+
else
|
|
284
|
+
request.body = payload
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# @return [void]
|
|
290
|
+
def write_raw_request(io, method, full_path, payload, headers, content_type)
|
|
291
|
+
lines = ["#{method.to_s.upcase} #{full_path} HTTP/1.1"]
|
|
292
|
+
head = {
|
|
293
|
+
"Host" => transport.host_header,
|
|
294
|
+
"User-Agent" => "docker-api-ng/#{VERSION} (Ruby #{RUBY_VERSION})",
|
|
295
|
+
"Connection" => "Upgrade",
|
|
296
|
+
"Upgrade" => "tcp",
|
|
297
|
+
"Content-Length" => (payload ? payload.bytesize : 0).to_s,
|
|
298
|
+
}
|
|
299
|
+
head["Content-Type"] = content_type if content_type && payload
|
|
300
|
+
head.merge!(headers.transform_keys(&:to_s))
|
|
301
|
+
|
|
302
|
+
head.each { |key, value| lines << "#{key}: #{value}" }
|
|
303
|
+
io.write("#{lines.join("\r\n")}\r\n\r\n")
|
|
304
|
+
io.write(payload) if payload
|
|
305
|
+
io.flush if io.respond_to?(:flush)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# @return [Array(Integer, Hash)]
|
|
309
|
+
def read_raw_response_head(io)
|
|
310
|
+
status_line = io.gets
|
|
311
|
+
raise ConnectionError.new("the daemon closed the connection before answering") if status_line.nil?
|
|
312
|
+
|
|
313
|
+
status = status_line[%r{\AHTTP/\d\.\d\s+(\d+)}, 1].to_i
|
|
314
|
+
headers = {}
|
|
315
|
+
while (line = io.gets)
|
|
316
|
+
line = line.chomp
|
|
317
|
+
break if line.empty?
|
|
318
|
+
|
|
319
|
+
key, _, value = line.partition(":")
|
|
320
|
+
headers[key.strip.downcase] = value.strip
|
|
321
|
+
end
|
|
322
|
+
[status, headers]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# @return [void]
|
|
326
|
+
def log(method, full_path, operation)
|
|
327
|
+
return unless logger
|
|
328
|
+
|
|
329
|
+
logger.debug { "docker-api-ng #{operation || "request"} #{method.to_s.upcase} #{full_path}" }
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require "digest" unless defined?(Digest)
|
|
7
|
+
|
|
8
|
+
module Docker
|
|
9
|
+
module API
|
|
10
|
+
# Docker's context store, which is where the CLI keeps the daemon it is
|
|
11
|
+
# pointed at when `DOCKER_HOST` is not set.
|
|
12
|
+
#
|
|
13
|
+
# `docker context use colima` does not export anything. It writes
|
|
14
|
+
# `currentContext` into ~/.docker/config.json, and every later `docker`
|
|
15
|
+
# command resolves the endpoint out of the store. A client that reads only
|
|
16
|
+
# `DOCKER_HOST` therefore disagrees with the CLI on the same machine, and
|
|
17
|
+
# falls back to /var/run/docker.sock -- which Docker Desktop happens to
|
|
18
|
+
# symlink, and Colima, Rancher Desktop, rootless Docker and Podman
|
|
19
|
+
# generally do not. The failure is a connection refused with a correct
|
|
20
|
+
# looking environment, which is a bad way to spend an afternoon.
|
|
21
|
+
#
|
|
22
|
+
# The store is a directory per context, named for the SHA-256 of the
|
|
23
|
+
# context's own name, holding a meta.json:
|
|
24
|
+
#
|
|
25
|
+
# ~/.docker/contexts/meta/<sha256(name)>/meta.json
|
|
26
|
+
# ~/.docker/contexts/tls/<sha256(name)>/docker/{ca,cert,key}.pem
|
|
27
|
+
#
|
|
28
|
+
# Every failure here is soft. An unreadable store, a malformed meta.json or
|
|
29
|
+
# a context that no longer exists all mean "no context", because falling
|
|
30
|
+
# back to the platform default is what the CLI does and is more useful than
|
|
31
|
+
# refusing to start.
|
|
32
|
+
module Context
|
|
33
|
+
# The context name that means "no context": use DOCKER_HOST, or the
|
|
34
|
+
# platform's default socket. It has no entry in the store.
|
|
35
|
+
DEFAULT = "default"
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# Resolve the endpoint the CLI would use, if any.
|
|
40
|
+
#
|
|
41
|
+
# @param env [Hash] the environment to read
|
|
42
|
+
# @param root [String, nil] the Docker configuration directory
|
|
43
|
+
# @return [Hash, nil] with :url and optionally :tls, or nil when no
|
|
44
|
+
# context applies
|
|
45
|
+
def resolve(env = ENV, root: nil)
|
|
46
|
+
root ||= config_root(env)
|
|
47
|
+
name = current_name(env, root)
|
|
48
|
+
return nil if name.nil? || name.empty? || name == DEFAULT
|
|
49
|
+
|
|
50
|
+
endpoint(name, root)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The active context's name: DOCKER_CONTEXT first, then whatever
|
|
54
|
+
# `docker context use` last wrote into config.json.
|
|
55
|
+
#
|
|
56
|
+
# @param env [Hash]
|
|
57
|
+
# @param root [String]
|
|
58
|
+
# @return [String, nil]
|
|
59
|
+
def current_name(env = ENV, root = config_root(env))
|
|
60
|
+
from_env = env["DOCKER_CONTEXT"]
|
|
61
|
+
return from_env unless from_env.nil? || from_env.empty?
|
|
62
|
+
|
|
63
|
+
config = read_json(File.join(root, "config.json"))
|
|
64
|
+
config.is_a?(Hash) ? config["currentContext"] : nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param name [String] a context name
|
|
68
|
+
# @param root [String] the Docker configuration directory
|
|
69
|
+
# @return [Hash, nil] with :url and optionally :tls
|
|
70
|
+
def endpoint(name, root = config_root)
|
|
71
|
+
digest = Digest::SHA256.hexdigest(name)
|
|
72
|
+
meta = read_json(File.join(root, "contexts", "meta", digest, "meta.json"))
|
|
73
|
+
host = meta.dig("Endpoints", "docker", "Host") if meta.is_a?(Hash)
|
|
74
|
+
return nil if host.nil? || host.empty?
|
|
75
|
+
|
|
76
|
+
resolved = { url: host }
|
|
77
|
+
tls = tls_material(root, digest, skip_verify: meta.dig("Endpoints", "docker", "SkipTLSVerify"))
|
|
78
|
+
resolved[:tls] = tls unless tls.empty?
|
|
79
|
+
resolved
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param env [Hash]
|
|
83
|
+
# @return [String] the Docker configuration directory
|
|
84
|
+
def config_root(env = ENV)
|
|
85
|
+
configured = env["DOCKER_CONFIG"]
|
|
86
|
+
return configured unless configured.nil? || configured.empty?
|
|
87
|
+
|
|
88
|
+
File.join(Dir.home, ".docker")
|
|
89
|
+
rescue ArgumentError
|
|
90
|
+
# Dir.home raises when there is no home to speak of, which happens in
|
|
91
|
+
# stripped containers and some CI images.
|
|
92
|
+
".docker"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A context may carry its own TLS material, which is how `docker context
|
|
96
|
+
# create --docker host=tcp://...,ca=...,cert=...,key=...` stores it.
|
|
97
|
+
#
|
|
98
|
+
# @return [Hash]
|
|
99
|
+
# @api private
|
|
100
|
+
def tls_material(root, digest, skip_verify: false)
|
|
101
|
+
directory = File.join(root, "contexts", "tls", digest, "docker")
|
|
102
|
+
material = { ca_file: "ca.pem", cert_file: "cert.pem", key_file: "key.pem" }
|
|
103
|
+
.filter_map { |key, file|
|
|
104
|
+
path = File.join(directory, file)
|
|
105
|
+
[key, path] if File.readable?(path)
|
|
106
|
+
}.to_h
|
|
107
|
+
|
|
108
|
+
return {} if material.empty?
|
|
109
|
+
|
|
110
|
+
material.merge(verify: !skip_verify)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [Object, nil]
|
|
114
|
+
# @api private
|
|
115
|
+
def read_json(path)
|
|
116
|
+
return nil unless File.readable?(path)
|
|
117
|
+
|
|
118
|
+
JSON.parse(File.read(path))
|
|
119
|
+
rescue JSON::ParserError, SystemCallError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
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 root of every error this gem raises.
|
|
9
|
+
#
|
|
10
|
+
# Nothing beneath the abstraction escapes: no `Errno`, no `OpenSSL`
|
|
11
|
+
# exception, no `Net::` class and no `JSON::ParserError` reaches a caller's
|
|
12
|
+
# rescue clause. The original is always retained as {#cause}, so nothing is
|
|
13
|
+
# lost -- but consumers get to rescue one hierarchy instead of coupling
|
|
14
|
+
# themselves to whichever HTTP library happens to be underneath.
|
|
15
|
+
#
|
|
16
|
+
# @example Rescuing anything this gem can raise
|
|
17
|
+
# begin
|
|
18
|
+
# client.containers.get("missing")
|
|
19
|
+
# rescue Docker::API::Error => e
|
|
20
|
+
# warn "#{e.operation} failed: #{e.message}"
|
|
21
|
+
# end
|
|
22
|
+
class Error < StandardError
|
|
23
|
+
# @return [String, nil] the operation that failed, e.g. "container_start"
|
|
24
|
+
attr_reader :operation
|
|
25
|
+
|
|
26
|
+
# @return [Integer, nil] the HTTP status the daemon returned
|
|
27
|
+
attr_reader :status
|
|
28
|
+
|
|
29
|
+
# @return [Docker::API::Response, nil] the response, when there was one
|
|
30
|
+
attr_reader :response
|
|
31
|
+
|
|
32
|
+
# @param message [String, nil] a message, or nil to compose one
|
|
33
|
+
# @param operation [String, Symbol, nil] the operation that failed
|
|
34
|
+
# @param status [Integer, nil] the HTTP status
|
|
35
|
+
# @param response [Docker::API::Response, nil] the response
|
|
36
|
+
def initialize(message = nil, operation: nil, status: nil, response: nil)
|
|
37
|
+
@operation = operation&.to_s
|
|
38
|
+
@status = status
|
|
39
|
+
@response = response
|
|
40
|
+
super(message || compose_message)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Build the error class appropriate to an HTTP status.
|
|
44
|
+
#
|
|
45
|
+
# @param status [Integer] the HTTP status the daemon returned
|
|
46
|
+
# @param operation [String, Symbol, nil] the operation that failed
|
|
47
|
+
# @param response [Docker::API::Response, nil] the response
|
|
48
|
+
# @return [Docker::API::Error] an instance of the mapped subclass
|
|
49
|
+
def self.for(status:, operation: nil, response: nil)
|
|
50
|
+
klass_for(status).new(nil, operation: operation, status: status, response: response)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param status [Integer] an HTTP status
|
|
54
|
+
# @return [Class] the error class that status maps to
|
|
55
|
+
# @api private
|
|
56
|
+
def self.klass_for(status)
|
|
57
|
+
STATUS_MAP.fetch(status) do
|
|
58
|
+
case status
|
|
59
|
+
when 400..499 then ClientError
|
|
60
|
+
when 500..599 then ServerError
|
|
61
|
+
else Error
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
private_class_method :klass_for
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Compose the most informative message available: what was attempted,
|
|
70
|
+
# what the daemon said about it, and the status it said it with.
|
|
71
|
+
#
|
|
72
|
+
# @return [String]
|
|
73
|
+
def compose_message
|
|
74
|
+
parts = []
|
|
75
|
+
parts << (operation ? "#{operation} failed" : "request failed")
|
|
76
|
+
parts << "(HTTP #{status})" if status
|
|
77
|
+
detail = daemon_message
|
|
78
|
+
parts << ": #{detail}" if detail && !detail.empty?
|
|
79
|
+
parts.join(" ").sub(" :", ":")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The daemon reports its own reason in a JSON `message` field. Fall back
|
|
83
|
+
# to the raw body when the response is not JSON, because an unparsed body
|
|
84
|
+
# is still more use to a human than nothing at all.
|
|
85
|
+
#
|
|
86
|
+
# @return [String, nil]
|
|
87
|
+
def daemon_message
|
|
88
|
+
return nil if response.nil?
|
|
89
|
+
|
|
90
|
+
body = response.body
|
|
91
|
+
return nil if body.nil? || body.empty?
|
|
92
|
+
|
|
93
|
+
parsed = begin
|
|
94
|
+
JSON.parse(body)
|
|
95
|
+
rescue JSON::ParserError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return body.strip unless parsed.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
parsed["message"] || parsed["Message"] || body.strip
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The daemon could not be reached at all: refused, reset, unresolvable, or
|
|
106
|
+
# a TLS handshake that failed.
|
|
107
|
+
class ConnectionError < Error; end
|
|
108
|
+
|
|
109
|
+
# The daemon accepted the connection but did not answer in time.
|
|
110
|
+
class TimeoutError < Error; end
|
|
111
|
+
|
|
112
|
+
# The request was wrong. 4xx.
|
|
113
|
+
class ClientError < Error; end
|
|
114
|
+
|
|
115
|
+
# 400 -- the daemon could not parse or accept the request.
|
|
116
|
+
class BadRequest < ClientError; end
|
|
117
|
+
|
|
118
|
+
# 401 -- authentication is required or was rejected.
|
|
119
|
+
class Unauthorized < ClientError; end
|
|
120
|
+
|
|
121
|
+
# 403 -- the daemon understood but refuses.
|
|
122
|
+
class Forbidden < ClientError; end
|
|
123
|
+
|
|
124
|
+
# 404 -- no such container, image, network, volume or endpoint.
|
|
125
|
+
class NotFound < ClientError; end
|
|
126
|
+
|
|
127
|
+
# 304 -- the requested change was already true. Docker returns this for,
|
|
128
|
+
# among others, starting an already-running container, which is why it is
|
|
129
|
+
# grouped with the client errors rather than treated as a redirect.
|
|
130
|
+
class NotModified < ClientError; end
|
|
131
|
+
|
|
132
|
+
# 409 -- the request conflicts with the daemon's current state, such as a
|
|
133
|
+
# container name already in use.
|
|
134
|
+
class Conflict < ClientError; end
|
|
135
|
+
|
|
136
|
+
# 5xx -- the daemon failed while handling a request it accepted.
|
|
137
|
+
class ServerError < Error; end
|
|
138
|
+
|
|
139
|
+
# The daemon speaks an API version this gem cannot work with.
|
|
140
|
+
class VersionUnsupported < Error; end
|
|
141
|
+
|
|
142
|
+
# A response stream was malformed, truncated, or not the format the
|
|
143
|
+
# endpoint promised.
|
|
144
|
+
class StreamError < Error; end
|
|
145
|
+
|
|
146
|
+
# Statuses with a specific meaning worth its own class. Anything else falls
|
|
147
|
+
# back to ClientError or ServerError by range.
|
|
148
|
+
#
|
|
149
|
+
# @api private
|
|
150
|
+
STATUS_MAP = {
|
|
151
|
+
304 => NotModified,
|
|
152
|
+
400 => BadRequest,
|
|
153
|
+
401 => Unauthorized,
|
|
154
|
+
403 => Forbidden,
|
|
155
|
+
404 => NotFound,
|
|
156
|
+
409 => Conflict,
|
|
157
|
+
}.freeze
|
|
158
|
+
end
|
|
159
|
+
end
|