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,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