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,90 @@
|
|
|
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 network on the daemon.
|
|
9
|
+
class Network < Resource
|
|
10
|
+
# @return [String, nil] the network's name
|
|
11
|
+
def name
|
|
12
|
+
detail("Name")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [String, nil] "bridge", "overlay", "host", and so on
|
|
16
|
+
def driver
|
|
17
|
+
detail("Driver")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Boolean] whether the network has IPv6 enabled
|
|
21
|
+
def ipv6?
|
|
22
|
+
detail("EnableIPv6") == true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Hash] containers attached to this network, keyed by id
|
|
26
|
+
def containers
|
|
27
|
+
detail("Containers") || {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Array<Hash>] the network's IPAM configuration
|
|
31
|
+
def subnets
|
|
32
|
+
detail("IPAM.Config") || []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [self]
|
|
36
|
+
def reload
|
|
37
|
+
replace_raw(operations.network_inspect(id: id || name).json)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Attach a container to this network.
|
|
41
|
+
#
|
|
42
|
+
# @param container [String, Docker::API::Container] the container
|
|
43
|
+
# @param aliases [Array<String>] additional names it answers to here
|
|
44
|
+
# @param ipv4_address [String, nil] a fixed address to assign
|
|
45
|
+
# @param ipv6_address [String, nil] a fixed address to assign
|
|
46
|
+
# @return [self]
|
|
47
|
+
#
|
|
48
|
+
# @example
|
|
49
|
+
# network.connect(container, aliases: %w{web web.local})
|
|
50
|
+
def connect(container, aliases: [], ipv4_address: nil, ipv6_address: nil)
|
|
51
|
+
endpoint = {}
|
|
52
|
+
endpoint["Aliases"] = aliases unless aliases.nil? || aliases.empty?
|
|
53
|
+
endpoint["IPAMConfig"] = {
|
|
54
|
+
"IPv4Address" => ipv4_address, "IPv6Address" => ipv6_address
|
|
55
|
+
}.compact
|
|
56
|
+
endpoint.delete("IPAMConfig") if endpoint["IPAMConfig"].empty?
|
|
57
|
+
|
|
58
|
+
body = { "Container" => container_id(container) }
|
|
59
|
+
body["EndpointConfig"] = endpoint unless endpoint.empty?
|
|
60
|
+
|
|
61
|
+
operations.network_connect(id: id, body: body)
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param container [String, Docker::API::Container] the container
|
|
66
|
+
# @param force [Boolean] disconnect even if the container is running
|
|
67
|
+
# @return [self]
|
|
68
|
+
def disconnect(container, force: false)
|
|
69
|
+
operations.network_disconnect(
|
|
70
|
+
id: id, body: { "Container" => container_id(container), "Force" => force }
|
|
71
|
+
)
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [void]
|
|
76
|
+
def remove
|
|
77
|
+
operations.network_delete(id: id)
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# @param container [String, Docker::API::Container]
|
|
84
|
+
# @return [String]
|
|
85
|
+
def container_id(container)
|
|
86
|
+
container.respond_to?(:id) ? container.id : container.to_s
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
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 volume on the daemon.
|
|
9
|
+
#
|
|
10
|
+
# Volumes are identified by name rather than by a generated id, so {#id}
|
|
11
|
+
# and {#name} are the same value.
|
|
12
|
+
class Volume < Resource
|
|
13
|
+
# @return [String, nil] the volume's name
|
|
14
|
+
def name
|
|
15
|
+
raw["Name"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [String, nil] the volume's name, which is also its identifier
|
|
19
|
+
def id
|
|
20
|
+
name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] the driver backing this volume
|
|
24
|
+
def driver
|
|
25
|
+
detail("Driver")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [String, nil] where the daemon keeps the volume's contents
|
|
29
|
+
def mountpoint
|
|
30
|
+
detail("Mountpoint")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Hash] the volume's labels
|
|
34
|
+
def labels
|
|
35
|
+
detail("Labels") || {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [self]
|
|
39
|
+
def reload
|
|
40
|
+
replace_raw(operations.volume_inspect(name: name).json)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param force [Boolean] remove even if the driver objects
|
|
44
|
+
# @return [void]
|
|
45
|
+
def remove(force: false)
|
|
46
|
+
operations.volume_delete(name: name, force: force)
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
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 the daemon said, before anything has interpreted it.
|
|
9
|
+
#
|
|
10
|
+
# The status is kept alongside the body because Docker uses it to carry
|
|
11
|
+
# meaning: 204 for a successful delete, 304 for "already in that state".
|
|
12
|
+
# A layer that returned only a parsed body would throw that away.
|
|
13
|
+
class Response
|
|
14
|
+
# @return [Integer] the HTTP status
|
|
15
|
+
attr_reader :status
|
|
16
|
+
|
|
17
|
+
# @return [Hash{String => String}] response headers, downcased keys
|
|
18
|
+
attr_reader :headers
|
|
19
|
+
|
|
20
|
+
# @return [String] the raw body. Empty when the body was streamed to a
|
|
21
|
+
# block instead of buffered.
|
|
22
|
+
attr_reader :body
|
|
23
|
+
|
|
24
|
+
# @param status [Integer] the HTTP status
|
|
25
|
+
# @param headers [Hash] response headers in any casing
|
|
26
|
+
# @param body [String, nil] the raw body
|
|
27
|
+
def initialize(status:, headers: {}, body: nil)
|
|
28
|
+
@status = Integer(status)
|
|
29
|
+
@headers = normalize(headers)
|
|
30
|
+
@body = body.to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The body parsed as JSON.
|
|
34
|
+
#
|
|
35
|
+
# @return [Hash, Array, nil] the parsed body, or nil when there was none
|
|
36
|
+
# @raise [Docker::API::StreamError] if the body is not valid JSON
|
|
37
|
+
def json
|
|
38
|
+
return @json if defined?(@json)
|
|
39
|
+
|
|
40
|
+
@json = if body.empty?
|
|
41
|
+
nil
|
|
42
|
+
else
|
|
43
|
+
begin
|
|
44
|
+
JSON.parse(body)
|
|
45
|
+
rescue JSON::ParserError => e
|
|
46
|
+
raise StreamError.new(
|
|
47
|
+
"expected JSON from the daemon but could not parse it: #{e.message}",
|
|
48
|
+
status: status, response: self
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The body parsed as JSON, when a document is required rather than
|
|
55
|
+
# merely hoped for.
|
|
56
|
+
#
|
|
57
|
+
# {#json} answers nil for an empty body, which is right: 204 and 304 are
|
|
58
|
+
# ordinary Docker answers and carry nothing. But most callers immediately
|
|
59
|
+
# index the result -- `.json["Id"]`, `.json["StatusCode"]` -- and against
|
|
60
|
+
# an empty body that is `NoMethodError: undefined method '[]' for nil`, a
|
|
61
|
+
# bare Ruby error escaping the hierarchy this gem promises is the only
|
|
62
|
+
# thing a caller has to rescue.
|
|
63
|
+
#
|
|
64
|
+
# Reaching this means a daemon, proxy or API-compatible shim answered a
|
|
65
|
+
# documented-success status with no body, so name that rather than
|
|
66
|
+
# letting a nil propagate to whichever accessor touches it first.
|
|
67
|
+
#
|
|
68
|
+
# @return [Hash, Array] the parsed body
|
|
69
|
+
# @raise [Docker::API::StreamError] if the body was empty or unparseable
|
|
70
|
+
def json!
|
|
71
|
+
parsed = json
|
|
72
|
+
return parsed unless parsed.nil?
|
|
73
|
+
|
|
74
|
+
raise StreamError.new(
|
|
75
|
+
"the daemon answered HTTP #{status} with an empty body, but this call needs a JSON document",
|
|
76
|
+
status: status, response: self
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Boolean] whether the status is in the 2xx range
|
|
81
|
+
def success?
|
|
82
|
+
(200..299).cover?(status)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [String, nil] the value of a header, case-insensitively
|
|
86
|
+
def [](name)
|
|
87
|
+
headers[name.to_s.downcase]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [String]
|
|
91
|
+
def to_s
|
|
92
|
+
"#<Docker::API::Response status=#{status} bytes=#{body.bytesize}>"
|
|
93
|
+
end
|
|
94
|
+
alias_method :inspect, :to_s
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Header casing is not guaranteed by anything, so it is normalised once
|
|
99
|
+
# here rather than being guessed at every call site.
|
|
100
|
+
#
|
|
101
|
+
# @param headers [Hash]
|
|
102
|
+
# @return [Hash{String => String}]
|
|
103
|
+
def normalize(headers)
|
|
104
|
+
headers.each_with_object({}) do |(key, value), out|
|
|
105
|
+
out[key.to_s.downcase] = value.is_a?(Array) ? value.first : value
|
|
106
|
+
end.freeze
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
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 Net::HTTP that connects through a {Docker::API::Transport} rather than
|
|
9
|
+
# by dialling a hostname itself.
|
|
10
|
+
#
|
|
11
|
+
# Overriding `#connect` is the whole trick, and it is what makes a
|
|
12
|
+
# dependency-free client practical: the stdlib keeps doing the tedious,
|
|
13
|
+
# well-tested parts -- chunked transfer decoding, keep-alive, header
|
|
14
|
+
# parsing, 100-continue -- while we retain ownership of the socket. That
|
|
15
|
+
# ownership is what unix sockets, TLS and Windows named pipes all need, and
|
|
16
|
+
# it is why none of them requires an HTTP library that knows about Docker.
|
|
17
|
+
#
|
|
18
|
+
# @api private
|
|
19
|
+
class Session < Net::HTTP
|
|
20
|
+
class << self
|
|
21
|
+
# Net::HTTP.new is not a plain allocator: it resolves proxy settings
|
|
22
|
+
# and then re-dispatches to Class#new with its own positional
|
|
23
|
+
# arguments, which do not match ours. A session connects through a
|
|
24
|
+
# transport and never proxies, so that work is not merely unnecessary,
|
|
25
|
+
# it actively gets in the way.
|
|
26
|
+
#
|
|
27
|
+
# @param transport [Docker::API::Transport::Base] the transport to dial through
|
|
28
|
+
# @param read_timeout [Numeric] seconds to wait for response data
|
|
29
|
+
# @param open_timeout [Numeric] seconds to wait for the connection
|
|
30
|
+
# @return [Docker::API::Session]
|
|
31
|
+
def new(transport, read_timeout: 60, open_timeout: 10)
|
|
32
|
+
allocate.tap do |session|
|
|
33
|
+
session.send(:initialize, transport,
|
|
34
|
+
read_timeout: read_timeout, open_timeout: open_timeout)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param transport [Docker::API::Transport::Base] the transport to dial through
|
|
40
|
+
# @param read_timeout [Numeric] seconds to wait for response data
|
|
41
|
+
# @param open_timeout [Numeric] seconds to wait for the connection
|
|
42
|
+
def initialize(transport, read_timeout: 60, open_timeout: 10)
|
|
43
|
+
super(transport.host_header, 80)
|
|
44
|
+
@transport = transport
|
|
45
|
+
self.read_timeout = read_timeout
|
|
46
|
+
self.open_timeout = open_timeout
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Use the transport's socket instead of opening one.
|
|
52
|
+
#
|
|
53
|
+
# When TLS is in play the transport has already completed the handshake,
|
|
54
|
+
# so `use_ssl` stays false and Net::HTTP never tries to negotiate a
|
|
55
|
+
# second session over the top of the first.
|
|
56
|
+
#
|
|
57
|
+
# @return [void]
|
|
58
|
+
def connect
|
|
59
|
+
@socket = Net::BufferedIO.new(
|
|
60
|
+
@transport.connect,
|
|
61
|
+
read_timeout: @read_timeout,
|
|
62
|
+
write_timeout: @write_timeout,
|
|
63
|
+
continue_timeout: @continue_timeout,
|
|
64
|
+
debug_output: @debug_output
|
|
65
|
+
)
|
|
66
|
+
on_connect
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
# Decoders for the three wire formats the Engine API streams in.
|
|
9
|
+
#
|
|
10
|
+
# All three are stateful accumulators, and that is the important part. HTTP
|
|
11
|
+
# hands us chunks at byte boundaries that have nothing to do with Docker's
|
|
12
|
+
# framing: a frame header can be split across two chunks and a JSON object
|
|
13
|
+
# can be split mid-string. A decoder that assumes a chunk is a message
|
|
14
|
+
# passes its tests and then interleaves garbage in production, which is
|
|
15
|
+
# precisely the bug class that makes `exec` output untrustworthy.
|
|
16
|
+
module Stream
|
|
17
|
+
# Docker's multiplexed frame header is eight bytes: a stream id, three
|
|
18
|
+
# bytes of padding, then a big-endian payload length.
|
|
19
|
+
HEADER_SIZE = 8
|
|
20
|
+
|
|
21
|
+
# Stream ids as they appear in the first header byte.
|
|
22
|
+
STREAM_NAMES = { 0 => :stdin, 1 => :stdout, 2 => :stderr }.freeze
|
|
23
|
+
|
|
24
|
+
# Splits Docker's multiplexed stdout/stderr framing back into named
|
|
25
|
+
# streams.
|
|
26
|
+
#
|
|
27
|
+
# Used whenever a container was created without a TTY. With a TTY the
|
|
28
|
+
# daemon sends unframed bytes instead, which is what {Raw} is for.
|
|
29
|
+
#
|
|
30
|
+
# @example
|
|
31
|
+
# demux = Stream::Demultiplexer.new { |name, chunk| $stdout << chunk if name == :stdout }
|
|
32
|
+
# demux << first_chunk << second_chunk
|
|
33
|
+
class Demultiplexer
|
|
34
|
+
# @yieldparam name [Symbol] one of :stdin, :stdout, :stderr
|
|
35
|
+
# @yieldparam chunk [String] the payload, in UTF-8
|
|
36
|
+
def initialize(&block)
|
|
37
|
+
raise ArgumentError, "Demultiplexer needs a block to yield frames to" unless block
|
|
38
|
+
|
|
39
|
+
@block = block
|
|
40
|
+
@buffer = +"".b
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Feed bytes in. Complete frames are yielded; a partial frame is held
|
|
44
|
+
# until the rest of it arrives.
|
|
45
|
+
#
|
|
46
|
+
# @param chunk [String] any number of bytes, at any boundary
|
|
47
|
+
# @return [self]
|
|
48
|
+
def <<(chunk)
|
|
49
|
+
@buffer << chunk.to_s.b
|
|
50
|
+
emit_frames
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Boolean] whether bytes are being held back for a partial frame
|
|
55
|
+
def pending?
|
|
56
|
+
!@buffer.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# @return [void]
|
|
62
|
+
def emit_frames
|
|
63
|
+
loop do
|
|
64
|
+
break if @buffer.bytesize < HEADER_SIZE
|
|
65
|
+
|
|
66
|
+
length = @buffer.byteslice(4, 4).unpack1("N")
|
|
67
|
+
break if @buffer.bytesize < HEADER_SIZE + length
|
|
68
|
+
|
|
69
|
+
stream = STREAM_NAMES.fetch(@buffer.getbyte(0), :unknown)
|
|
70
|
+
payload = @buffer.byteslice(HEADER_SIZE, length)
|
|
71
|
+
@buffer = @buffer.byteslice(HEADER_SIZE + length..) || +"".b
|
|
72
|
+
|
|
73
|
+
@block.call(stream, payload.force_encoding(Encoding::UTF_8))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Parses the newline-delimited JSON that pull, push, build and /events
|
|
79
|
+
# stream their progress in.
|
|
80
|
+
#
|
|
81
|
+
# @example
|
|
82
|
+
# lines = Stream::JSONLines.new { |event| puts event["status"] }
|
|
83
|
+
# lines << chunk
|
|
84
|
+
class JSONLines
|
|
85
|
+
# @yieldparam object [Hash] one decoded JSON document
|
|
86
|
+
def initialize(&block)
|
|
87
|
+
raise ArgumentError, "JSONLines needs a block to yield objects to" unless block
|
|
88
|
+
|
|
89
|
+
@block = block
|
|
90
|
+
@buffer = +""
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @param chunk [String] any number of bytes, at any boundary
|
|
94
|
+
# @return [self]
|
|
95
|
+
# @raise [Docker::API::StreamError] if a complete line is not valid JSON
|
|
96
|
+
def <<(chunk)
|
|
97
|
+
@buffer << chunk.to_s
|
|
98
|
+
emit_lines
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# @return [void]
|
|
105
|
+
def emit_lines
|
|
106
|
+
while (index = @buffer.index("\n"))
|
|
107
|
+
line = @buffer.slice!(0, index + 1).strip
|
|
108
|
+
next if line.empty?
|
|
109
|
+
|
|
110
|
+
@block.call(parse(line))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @param line [String]
|
|
115
|
+
# @return [Hash]
|
|
116
|
+
def parse(line)
|
|
117
|
+
JSON.parse(line)
|
|
118
|
+
rescue JSON::ParserError => e
|
|
119
|
+
raise StreamError.new("the daemon sent a line that is not valid JSON: #{e.message}")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Passes bytes straight through, for TTY-enabled streams and for raw
|
|
124
|
+
# archive downloads where there is no framing to undo.
|
|
125
|
+
class Raw
|
|
126
|
+
# @yieldparam chunk [String]
|
|
127
|
+
def initialize(&block)
|
|
128
|
+
raise ArgumentError, "Raw needs a block to yield chunks to" unless block
|
|
129
|
+
|
|
130
|
+
@block = block
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @param chunk [String]
|
|
134
|
+
# @return [self]
|
|
135
|
+
def <<(chunk)
|
|
136
|
+
@block.call(chunk)
|
|
137
|
+
self
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Tim Smith
|
|
4
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
|
|
6
|
+
require "rubygems/package" unless defined?(Gem::Package)
|
|
7
|
+
require "tempfile" unless defined?(Tempfile)
|
|
8
|
+
|
|
9
|
+
module Docker
|
|
10
|
+
module API
|
|
11
|
+
# Packs a build context into the tar archive the daemon expects.
|
|
12
|
+
#
|
|
13
|
+
# RubyGems ships `Gem::Package::TarWriter` with every Ruby, so building a
|
|
14
|
+
# context needs no dependency and no shelling out to `tar` -- which also
|
|
15
|
+
# means the behaviour is identical on Windows, where there may be no `tar`
|
|
16
|
+
# to shell out to.
|
|
17
|
+
module Tar
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Pack a directory into an uncompressed tar archive.
|
|
21
|
+
#
|
|
22
|
+
# Written to a temporary file rather than a String. A build context is
|
|
23
|
+
# whatever the caller points at -- a Rails application with its assets, a
|
|
24
|
+
# monorepo subtree, a directory holding a model checkpoint -- and holding
|
|
25
|
+
# the whole archive in memory to send it costs its full size again on top
|
|
26
|
+
# of what the daemon is about to receive. The connection layer streams an
|
|
27
|
+
# IO body chunked, so the archive never has to be resident at all.
|
|
28
|
+
#
|
|
29
|
+
# @param directory [String] the build context root
|
|
30
|
+
# @param ignore [Array<String>, nil] patterns to exclude. Read from the
|
|
31
|
+
# context's .dockerignore when not given.
|
|
32
|
+
# @return [File] the archive, rewound and ready to send
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# Tar.pack_directory("./app")
|
|
36
|
+
def pack_directory(directory, ignore: nil)
|
|
37
|
+
root = File.expand_path(directory)
|
|
38
|
+
raise ArgumentError, "build context #{directory} is not a directory" unless File.directory?(root)
|
|
39
|
+
|
|
40
|
+
patterns = ignore || read_dockerignore(root)
|
|
41
|
+
buffer = Tempfile.new(["docker-api-ng-context", ".tar"])
|
|
42
|
+
buffer.binmode
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
Gem::Package::TarWriter.new(buffer) do |tar|
|
|
46
|
+
each_entry(root, patterns) do |absolute, relative|
|
|
47
|
+
add_entry(tar, absolute, relative)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
rescue StandardError
|
|
51
|
+
buffer.close!
|
|
52
|
+
raise
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
buffer.rewind
|
|
56
|
+
buffer
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Pack a single in-memory Dockerfile into a context of its own.
|
|
60
|
+
#
|
|
61
|
+
# Useful for the common case of a short generated Dockerfile with no
|
|
62
|
+
# accompanying files, where writing a temporary directory first would be
|
|
63
|
+
# ceremony for its own sake.
|
|
64
|
+
#
|
|
65
|
+
# @param contents [String] the Dockerfile
|
|
66
|
+
# @param files [Hash{String => String}] additional files, path to content
|
|
67
|
+
# @return [StringIO] the archive, rewound and ready to send
|
|
68
|
+
def pack_dockerfile(contents, files: {})
|
|
69
|
+
buffer = StringIO.new(+"".b)
|
|
70
|
+
|
|
71
|
+
Gem::Package::TarWriter.new(buffer) do |tar|
|
|
72
|
+
write_file(tar, "Dockerfile", contents, 0o644)
|
|
73
|
+
files.each { |path, body| write_file(tar, path, body, 0o644) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
buffer.rewind
|
|
77
|
+
buffer
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param root [String]
|
|
81
|
+
# @return [Array<String>] patterns from .dockerignore, or an empty list
|
|
82
|
+
def read_dockerignore(root)
|
|
83
|
+
path = File.join(root, ".dockerignore")
|
|
84
|
+
return [] unless File.readable?(path)
|
|
85
|
+
|
|
86
|
+
File.readlines(path, chomp: true)
|
|
87
|
+
.map(&:strip)
|
|
88
|
+
.reject { |line| line.empty? || line.start_with?("#") }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Docker's ignore rules allow a later "!" pattern to re-include something
|
|
92
|
+
# an earlier pattern excluded, so the last matching rule wins rather than
|
|
93
|
+
# the first.
|
|
94
|
+
#
|
|
95
|
+
# @param relative [String] a context-relative path
|
|
96
|
+
# @param patterns [Array<String>]
|
|
97
|
+
# @return [Boolean] whether the path should be left out
|
|
98
|
+
def ignored?(relative, patterns)
|
|
99
|
+
decision = false
|
|
100
|
+
|
|
101
|
+
patterns.each do |pattern|
|
|
102
|
+
negated = pattern.start_with?("!")
|
|
103
|
+
candidate = negated ? pattern[1..] : pattern
|
|
104
|
+
decision = !negated if matches?(relative, candidate)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
decision
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @param relative [String]
|
|
111
|
+
# @param pattern [String]
|
|
112
|
+
# @return [Boolean]
|
|
113
|
+
def matches?(relative, pattern)
|
|
114
|
+
pattern = pattern.chomp("/")
|
|
115
|
+
return true if File.fnmatch?(pattern, relative, File::FNM_PATHNAME)
|
|
116
|
+
|
|
117
|
+
# A directory pattern excludes everything beneath it.
|
|
118
|
+
File.fnmatch?("#{pattern}/**", relative, File::FNM_PATHNAME) ||
|
|
119
|
+
relative.start_with?("#{pattern}/")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [void]
|
|
123
|
+
# @api private
|
|
124
|
+
def each_entry(root, patterns)
|
|
125
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: root).sort.each do |relative|
|
|
126
|
+
next if [".", ".."].include?(File.basename(relative))
|
|
127
|
+
next if ignored?(relative, patterns)
|
|
128
|
+
|
|
129
|
+
yield File.join(root, relative), relative
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [void]
|
|
134
|
+
# @api private
|
|
135
|
+
def add_entry(tar, absolute, relative)
|
|
136
|
+
stat = File.lstat(absolute)
|
|
137
|
+
|
|
138
|
+
if stat.directory?
|
|
139
|
+
tar.mkdir(relative, stat.mode)
|
|
140
|
+
elsif stat.symlink?
|
|
141
|
+
tar.add_symlink(relative, File.readlink(absolute), stat.mode)
|
|
142
|
+
elsif stat.file?
|
|
143
|
+
tar.add_file_simple(relative, stat.mode, stat.size) do |entry|
|
|
144
|
+
File.open(absolute, "rb") { |file| IO.copy_stream(file, entry) }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [void]
|
|
150
|
+
# @api private
|
|
151
|
+
def write_file(tar, path, contents, mode)
|
|
152
|
+
bytes = contents.to_s.b
|
|
153
|
+
tar.add_file_simple(path, mode, bytes.bytesize) { |entry| entry.write(bytes) }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
module Transport
|
|
9
|
+
# The contract every transport honours: make a socket, say what Host
|
|
10
|
+
# header requests over it should carry, and describe yourself for logs.
|
|
11
|
+
#
|
|
12
|
+
# Transports know how to dial, and nothing else. Keeping them this narrow
|
|
13
|
+
# is what makes the layer above testable -- {Docker::API::Transport::Fake}
|
|
14
|
+
# is a drop-in because there is so little to stand in for.
|
|
15
|
+
#
|
|
16
|
+
# @abstract Subclass and implement {#connect}.
|
|
17
|
+
class Base
|
|
18
|
+
# Open a connection.
|
|
19
|
+
#
|
|
20
|
+
# @return [IO] a connected, readable and writable socket
|
|
21
|
+
# @raise [Docker::API::ConnectionError] if the endpoint cannot be reached
|
|
22
|
+
def connect
|
|
23
|
+
raise NotImplementedError, "#{self.class} must implement #connect"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The daemon does not route on Host, but HTTP/1.1 requires the header
|
|
27
|
+
# and some proxies in front of a daemon do care.
|
|
28
|
+
#
|
|
29
|
+
# @return [String]
|
|
30
|
+
def host_header
|
|
31
|
+
"localhost"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [String] a description safe to put in a log line
|
|
35
|
+
def to_s
|
|
36
|
+
"#<#{self.class.name}>"
|
|
37
|
+
end
|
|
38
|
+
alias_method :inspect, :to_s
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Run a block that dials a socket, converting every way the operating
|
|
43
|
+
# system and OpenSSL report failure into one gem error.
|
|
44
|
+
#
|
|
45
|
+
# This is the boundary that stops `Errno::ECONNREFUSED` from appearing
|
|
46
|
+
# in a caller's rescue clause and coupling them to our plumbing.
|
|
47
|
+
#
|
|
48
|
+
# @param endpoint [String] what we were trying to reach, for the message
|
|
49
|
+
# @yieldreturn [IO]
|
|
50
|
+
# @return [IO]
|
|
51
|
+
# @raise [Docker::API::ConnectionError]
|
|
52
|
+
def dial(endpoint)
|
|
53
|
+
yield
|
|
54
|
+
rescue SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError, Timeout::Error => e
|
|
55
|
+
raise ConnectionError.new(
|
|
56
|
+
"could not connect to the Docker daemon at #{endpoint}: #{e.class}: #{e.message}"
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|