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