From bda435ee408db4421ecbb3b9c49f85878172c2ad Mon Sep 17 00:00:00 2001 From: Kirill Mokevnin Date: Sun, 27 Sep 2026 16:03:24 -0400 Subject: [PATCH] feat!: stream image build, pull and push progress images.build, images.pull and images.push returned nil and drained the response, so their logs were lost and a failed build went unnoticed: Docker reports it with status 200 and an error in the last line of the stream. They now read the stream to the end, yield each typed message (BuildInfo, CreateImageInfo, PushImageInfo) to an optional block and raise Errors::StreamError when the daemon reports a failure. Reading to the end is kept on purpose: a lazily returned stream left unread could cut a pull short. BuildKit sends its progress as base64 protobuf StatusResponse messages in BuildInfo#aux; Helpers::BuildkitStatus decodes the fields a log needs and PlainPrinter renders them as --progress=plain does. Union#coerce also kept the error of a failed variant attempt after a later variant matched, so reading BuildInfo#aux as a String raised. BREAKING CHANGE: images.build, images.pull and images.push raise Errors::StreamError on a failure reported inside the stream. Co-Authored-By: Claude Opus 5.5 --- lib/docker_engine_ruby.rb | 8 + lib/docker_engine_ruby/errors.rb | 14 ++ .../helpers/buildkit_status.rb | 136 +++++++++++++++++ .../internal/jsonl_stream.rb | 34 +++++ .../internal/transport/base_client.rb | 9 +- lib/docker_engine_ruby/internal/type/union.rb | 7 +- lib/docker_engine_ruby/models.rb | 12 ++ lib/docker_engine_ruby/models/build_info.rb | 81 ++++++++++ .../models/create_image_info.rb | 53 +++++++ lib/docker_engine_ruby/models/error_detail.rb | 25 ++++ lib/docker_engine_ruby/models/image_id.rb | 16 ++ .../models/progress_detail.rb | 23 +++ .../models/push_image_info.rb | 86 +++++++++++ lib/docker_engine_ruby/resources/images.rb | 53 +++++-- rbi/docker_engine_ruby/errors.rbi | 14 ++ .../internal/jsonl_stream.rbi | 39 +++++ rbi/docker_engine_ruby/models.rbi | 12 ++ rbi/docker_engine_ruby/models/build_info.rbi | 122 +++++++++++++++ .../models/create_image_info.rbi | 90 +++++++++++ .../models/error_detail.rbi | 37 +++++ rbi/docker_engine_ruby/models/image_id.rbi | 26 ++++ .../models/progress_detail.rbi | 35 +++++ .../models/push_image_info.rbi | 140 ++++++++++++++++++ rbi/docker_engine_ruby/resources/images.rbi | 25 +++- sig/docker_engine_ruby/errors.rbs | 9 ++ .../internal/jsonl_stream.rbs | 19 +++ sig/docker_engine_ruby/models.rbs | 12 ++ sig/docker_engine_ruby/models/build_info.rbs | 85 +++++++++++ .../models/create_image_info.rbs | 61 ++++++++ .../models/error_detail.rbs | 19 +++ sig/docker_engine_ruby/models/image_id.rbs | 15 ++ .../models/progress_detail.rbs | 19 +++ .../models/push_image_info.rbs | 90 +++++++++++ sig/docker_engine_ruby/resources/images.rbs | 12 +- .../helpers/buildkit_status_test.rb | 21 +++ .../helpers/buildkit_trace_fixture.rb | 26 ++++ .../models/progress_stream_test.rb | 91 ++++++++++++ 37 files changed, 1555 insertions(+), 21 deletions(-) create mode 100644 lib/docker_engine_ruby/helpers/buildkit_status.rb create mode 100644 lib/docker_engine_ruby/internal/jsonl_stream.rb create mode 100644 lib/docker_engine_ruby/models/build_info.rb create mode 100644 lib/docker_engine_ruby/models/create_image_info.rb create mode 100644 lib/docker_engine_ruby/models/error_detail.rb create mode 100644 lib/docker_engine_ruby/models/image_id.rb create mode 100644 lib/docker_engine_ruby/models/progress_detail.rb create mode 100644 lib/docker_engine_ruby/models/push_image_info.rb create mode 100644 rbi/docker_engine_ruby/internal/jsonl_stream.rbi create mode 100644 rbi/docker_engine_ruby/models/build_info.rbi create mode 100644 rbi/docker_engine_ruby/models/create_image_info.rbi create mode 100644 rbi/docker_engine_ruby/models/error_detail.rbi create mode 100644 rbi/docker_engine_ruby/models/image_id.rbi create mode 100644 rbi/docker_engine_ruby/models/progress_detail.rbi create mode 100644 rbi/docker_engine_ruby/models/push_image_info.rbi create mode 100644 sig/docker_engine_ruby/internal/jsonl_stream.rbs create mode 100644 sig/docker_engine_ruby/models/build_info.rbs create mode 100644 sig/docker_engine_ruby/models/create_image_info.rbs create mode 100644 sig/docker_engine_ruby/models/error_detail.rbs create mode 100644 sig/docker_engine_ruby/models/image_id.rbs create mode 100644 sig/docker_engine_ruby/models/progress_detail.rbs create mode 100644 sig/docker_engine_ruby/models/push_image_info.rbs create mode 100644 test/docker_engine_ruby/helpers/buildkit_status_test.rb create mode 100644 test/docker_engine_ruby/helpers/buildkit_trace_fixture.rb create mode 100644 test/docker_engine_ruby/models/progress_stream_test.rb diff --git a/lib/docker_engine_ruby.rb b/lib/docker_engine_ruby.rb index f7f1910..f43def6 100644 --- a/lib/docker_engine_ruby.rb +++ b/lib/docker_engine_ruby.rb @@ -44,6 +44,7 @@ require_relative "docker_engine_ruby/internal/type/hash_of" require_relative "docker_engine_ruby/internal/type/base_model" require_relative "docker_engine_ruby/internal/type/base_page" +require_relative "docker_engine_ruby/internal/jsonl_stream" require_relative "docker_engine_ruby/internal/type/request_parameters" require_relative "docker_engine_ruby/internal" require_relative "docker_engine_ruby/request_options" @@ -227,6 +228,13 @@ require_relative "docker_engine_ruby/models/volume_prune_response" require_relative "docker_engine_ruby/models/volume_update_params" require_relative "docker_engine_ruby/models/wait_response" +require_relative "docker_engine_ruby/models/error_detail" +require_relative "docker_engine_ruby/models/progress_detail" +require_relative "docker_engine_ruby/models/image_id" +require_relative "docker_engine_ruby/models/build_info" +require_relative "docker_engine_ruby/models/create_image_info" +require_relative "docker_engine_ruby/models/push_image_info" +require_relative "docker_engine_ruby/helpers/buildkit_status" require_relative "docker_engine_ruby/models" require_relative "docker_engine_ruby/resources/auth" require_relative "docker_engine_ruby/resources/configs" diff --git a/lib/docker_engine_ruby/errors.rb b/lib/docker_engine_ruby/errors.rb index 06af5da..93da3f0 100644 --- a/lib/docker_engine_ruby/errors.rb +++ b/lib/docker_engine_ruby/errors.rb @@ -33,6 +33,20 @@ def initialize(on:, method:, target:, value:, cause: nil) end end + # Docker reports a failed build, pull or push inside a 200 response, as the last + # message of the progress stream. + class StreamError < DockerEngineRuby::Errors::Error + # @return [DockerEngineRuby::Models::ErrorDetail, nil] + attr_reader :detail + + # @param message [String] + # @param detail [DockerEngineRuby::Models::ErrorDetail, nil] + def initialize(message, detail: nil) + @detail = detail + super(message) + end + end + class APIError < DockerEngineRuby::Errors::Error # @return [URI::Generic] attr_accessor :url diff --git a/lib/docker_engine_ruby/helpers/buildkit_status.rb b/lib/docker_engine_ruby/helpers/buildkit_status.rb new file mode 100644 index 0000000..987ad32 --- /dev/null +++ b/lib/docker_engine_ruby/helpers/buildkit_status.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "base64" + +module DockerEngineRuby + module Helpers + # Progress of a BuildKit build (`version: :"2"`). The daemon sends it as `BuildInfo` messages + # with `id: "moby.buildkit.trace"` and a base64 protobuf `StatusResponse` in `aux` + # (moby/buildkit `api/services/control/control.proto`). Only the fields a progress log + # needs are decoded; the rest are skipped. + module BuildkitStatus + TRACE_ID = "moby.buildkit.trace" + + Vertex = Struct.new(:digest, :name, :cached, :started, :completed, :error, keyword_init: true) + Log = Struct.new(:vertex, :stream, :msg, keyword_init: true) + Status = Struct.new(:vertexes, :logs, keyword_init: true) + + class << self + # @param info [DockerEngineRuby::Models::BuildInfo] + # @return [Boolean] + def trace?(info) = info.id == TRACE_ID && info.aux.is_a?(String) + + # @param aux [String] base64-encoded `StatusResponse` + # @return [DockerEngineRuby::Helpers::BuildkitStatus::Status] + def decode(aux) + status = Status.new(vertexes: [], logs: []) + each_field(Base64.decode64(aux)) do |number, value| + case number + in 1 then status.vertexes << decode_vertex(value) + in 3 then status.logs << decode_log(value) + else nil + end + end + status + end + + private + + def decode_vertex(bytes) + vertex = Vertex.new(cached: false, started: false, completed: false) + each_field(bytes) do |number, value| + case number + in 1 then vertex.digest = utf8(value) + in 3 then vertex.name = utf8(value) + in 4 then vertex.cached = value != 0 + in 5 then vertex.started = true + in 6 then vertex.completed = true + in 7 then vertex.error = utf8(value) + else nil + end + end + vertex + end + + def decode_log(bytes) + log = Log.new(stream: 0, msg: +"") + each_field(bytes) do |number, value| + case number + in 1 then log.vertex = utf8(value) + in 3 then log.stream = value + in 4 then log.msg = utf8(value) + else nil + end + end + log + end + + def utf8(bytes) = bytes.dup.force_encoding(Encoding::UTF_8).scrub + + # Protobuf wire format: yields [field number, varint Integer | length-delimited String]. + def each_field(bytes) + reader = StringIO.new(bytes.b) + until reader.eof? + key = read_varint(reader) + number = key >> 3 + case key & 7 + in 0 then yield(number, read_varint(reader)) + in 1 then reader.read(8) + in 2 then yield(number, reader.read(read_varint(reader)).to_s) + in 5 then reader.read(4) + end + end + end + + def read_varint(reader) + result = 0 + shift = 0 + loop do + byte = reader.readbyte + result |= (byte & 0x7f) << shift + return result if byte < 0x80 + + shift += 7 + end + end + end + + # Renders statuses as `docker build --progress=plain` does: vertexes are numbered in the + # order they appear, and each state change or log chunk becomes a line. + class PlainPrinter + # @param out [#call] receives each rendered line + def initialize(&out) + @out = out + @numbers = {} + @printed = {} + end + + # @param status [DockerEngineRuby::Helpers::BuildkitStatus::Status] + def print(status) + status.vertexes.each { print_vertex(_1) } + status.logs.each do |log| + prefix = "##{number(log.vertex)} " + log.msg.each_line { @out.call(prefix + _1.chomp) } + end + end + + private + + def number(digest) = @numbers[digest] ||= @numbers.size + 1 + + def print_vertex(vertex) + n = number(vertex.digest) + state = if vertex.error then "ERROR: #{vertex.error}" + elsif vertex.cached then "CACHED" + elsif vertex.completed then "DONE" + elsif vertex.started then vertex.name + end + return if state.nil? || @printed[[n, state]] + + @printed[[n, state]] = true + @out.call(state == vertex.name ? "##{n} #{vertex.name}" : "##{n} #{state}") + end + end + end + end +end diff --git a/lib/docker_engine_ruby/internal/jsonl_stream.rb b/lib/docker_engine_ruby/internal/jsonl_stream.rb new file mode 100644 index 0000000..d5c4126 --- /dev/null +++ b/lib/docker_engine_ruby/internal/jsonl_stream.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module DockerEngineRuby + module Internal + # Progress streams of Docker (`/build`, `/images/create`, `/images/{name}/push`) are one JSON + # object per line, sent as `application/json`. Each line is coerced to `model` as it arrives. + # + # @generic Elem + class JSONLStream + include Enumerable + + # @param model [DockerEngineRuby::Internal::Type::Converter, Class] + # @param stream [Enumerable] raw response lines + def initialize(model:, stream:, **) + @model = model + @lines = stream + end + + # @yieldparam message [generic] + def each + return enum_for(__method__) unless block_given? + + @lines.each do |line| + next if line.strip.empty? + + yield(DockerEngineRuby::Internal::Type::Converter.coerce( + @model, + JSON.parse(line, symbolize_names: true) + )) + end + end + end + end +end diff --git a/lib/docker_engine_ruby/internal/transport/base_client.rb b/lib/docker_engine_ruby/internal/transport/base_client.rb index d683727..500b7a7 100644 --- a/lib/docker_engine_ruby/internal/transport/base_client.rb +++ b/lib/docker_engine_ruby/internal/transport/base_client.rb @@ -537,7 +537,14 @@ def request(req) return nil end - decoded = DockerEngineRuby::Internal::Util.decode_content(headers, stream: stream) + # A stream class takes the raw lines: Docker sends its progress streams as `application/json`, + # which `decode_content` would join and parse as a single document. + decoded = + if req.key?(:stream) + DockerEngineRuby::Internal::Util.decode_lines(stream) + else + DockerEngineRuby::Internal::Util.decode_content(headers, stream: stream) + end case req in {stream: Class => st} st.new( diff --git a/lib/docker_engine_ruby/internal/type/union.rb b/lib/docker_engine_ruby/internal/type/union.rb index db499f0..a3b528d 100644 --- a/lib/docker_engine_ruby/internal/type/union.rb +++ b/lib/docker_engine_ruby/internal/type/union.rb @@ -150,6 +150,8 @@ def coerce(value, state:) target = variant_fn.call exact = state[:exactness] = {yes: 0, no: 0, maybe: 0} state[:branched] += 1 + # A failed attempt must not leave its error behind for the variant that fits. + state[:error] = nil coerced = DockerEngineRuby::Internal::Type::Converter.coerce(target, value, state: state) yes, no, maybe = exact.values @@ -158,7 +160,7 @@ def coerce(value, state:) state[:exactness] = exactness return coerced elsif maybe.positive? - alternatives << [[-yes, -maybe, no], exact, coerced] + alternatives << [[-yes, -maybe, no], exact, coerced, state[:error]] end end @@ -167,8 +169,9 @@ def coerce(value, state:) exactness[:no] += 1 state[:error] = ArgumentError.new("no matching variant for #{value.inspect}") value - in [[_, exact, coerced], *] + in [[_, exact, coerced, error], *] exact.each { exactness[_1] += _2 } + state[:error] = error coerced end .tap { state[:exactness] = exactness } diff --git a/lib/docker_engine_ruby/models.rb b/lib/docker_engine_ruby/models.rb index 1562fae..bd37ca1 100644 --- a/lib/docker_engine_ruby/models.rb +++ b/lib/docker_engine_ruby/models.rb @@ -331,4 +331,16 @@ module DockerEngineRuby VolumeUpdateParams = DockerEngineRuby::Models::VolumeUpdateParams WaitResponse = DockerEngineRuby::Models::WaitResponse + + BuildInfo = DockerEngineRuby::Models::BuildInfo + + CreateImageInfo = DockerEngineRuby::Models::CreateImageInfo + + ErrorDetail = DockerEngineRuby::Models::ErrorDetail + + ImageID = DockerEngineRuby::Models::ImageID + + ProgressDetail = DockerEngineRuby::Models::ProgressDetail + + PushImageInfo = DockerEngineRuby::Models::PushImageInfo end diff --git a/lib/docker_engine_ruby/models/build_info.rb b/lib/docker_engine_ruby/models/build_info.rb new file mode 100644 index 0000000..8b96042 --- /dev/null +++ b/lib/docker_engine_ruby/models/build_info.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module DockerEngineRuby + module Models + # Сообщение из потока `/build`. + class BuildInfo < DockerEngineRuby::Internal::Type::BaseModel + # @!attribute id + # + # @return [String, nil] + optional :id, String + + # @!attribute stream + # Строка вывода сборки. + # + # @return [String, nil] + optional :stream, String + + # @!attribute error + # Устаревшее поле: то же, что `error_detail.message`. + # + # @return [String, nil] + optional :error, String + + # @!attribute error_detail + # + # @return [DockerEngineRuby::Models::ErrorDetail, nil] + optional :error_detail, -> { DockerEngineRuby::ErrorDetail }, api_name: :errorDetail + + # @!attribute status + # + # @return [String, nil] + optional :status, String + + # @!attribute progress + # Устаревшее поле: прогресс строкой. + # + # @return [String, nil] + optional :progress, String + + # @!attribute progress_detail + # + # @return [DockerEngineRuby::Models::ProgressDetail, nil] + optional :progress_detail, -> { DockerEngineRuby::ProgressDetail }, api_name: :progressDetail + + # @!attribute aux + # Классический сборщик кладёт сюда ID образа, BuildKit — base64 protobuf `StatusResponse` при `id` = `moby.buildkit.trace`. + # + # @return [DockerEngineRuby::Models::ImageID, String, nil] + optional :aux, union: -> { DockerEngineRuby::BuildInfo::Aux } + + # @!method initialize(id: nil, stream: nil, error: nil, error_detail: nil, status: nil, progress: nil, progress_detail: nil, aux: nil) + # @param id [String] + # + # @param stream [String] Строка вывода сборки. + # + # @param error [String] Устаревшее поле: то же, что `error_detail.message`. + # + # @param error_detail [DockerEngineRuby::Models::ErrorDetail] + # + # @param status [String] + # + # @param progress [String] Устаревшее поле: прогресс строкой. + # + # @param progress_detail [DockerEngineRuby::Models::ProgressDetail] + # + # @param aux [DockerEngineRuby::Models::ImageID, String] Классический сборщик кладёт сюда ID образа, BuildKit — base64 protobuf `StatusResponse` при `id` = `moby.buildkit.trace`. + + # @see DockerEngineRuby::Models::BuildInfo#aux + module Aux + extend DockerEngineRuby::Internal::Type::Union + + variant -> { DockerEngineRuby::ImageID } + + variant String + + # @!method self.variants + # @return [Array(DockerEngineRuby::Models::ImageID, String)] + end + end + end +end diff --git a/lib/docker_engine_ruby/models/create_image_info.rb b/lib/docker_engine_ruby/models/create_image_info.rb new file mode 100644 index 0000000..d12297e --- /dev/null +++ b/lib/docker_engine_ruby/models/create_image_info.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module DockerEngineRuby + module Models + # Сообщение из потока `/images/create` (pull). + class CreateImageInfo < DockerEngineRuby::Internal::Type::BaseModel + # @!attribute id + # + # @return [String, nil] + optional :id, String + + # @!attribute error + # Устаревшее поле: то же, что `error_detail.message`. + # + # @return [String, nil] + optional :error, String + + # @!attribute error_detail + # + # @return [DockerEngineRuby::Models::ErrorDetail, nil] + optional :error_detail, -> { DockerEngineRuby::ErrorDetail }, api_name: :errorDetail + + # @!attribute status + # + # @return [String, nil] + optional :status, String + + # @!attribute progress + # Устаревшее поле: прогресс строкой. + # + # @return [String, nil] + optional :progress, String + + # @!attribute progress_detail + # + # @return [DockerEngineRuby::Models::ProgressDetail, nil] + optional :progress_detail, -> { DockerEngineRuby::ProgressDetail }, api_name: :progressDetail + + # @!method initialize(id: nil, error: nil, error_detail: nil, status: nil, progress: nil, progress_detail: nil) + # @param id [String] + # + # @param error [String] Устаревшее поле: то же, что `error_detail.message`. + # + # @param error_detail [DockerEngineRuby::Models::ErrorDetail] + # + # @param status [String] + # + # @param progress [String] Устаревшее поле: прогресс строкой. + # + # @param progress_detail [DockerEngineRuby::Models::ProgressDetail] + end + end +end diff --git a/lib/docker_engine_ruby/models/error_detail.rb b/lib/docker_engine_ruby/models/error_detail.rb new file mode 100644 index 0000000..685050d --- /dev/null +++ b/lib/docker_engine_ruby/models/error_detail.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module DockerEngineRuby + module Models + # Ошибка из потока прогресса Docker. + class ErrorDetail < DockerEngineRuby::Internal::Type::BaseModel + # @!attribute code + # Код ошибки. + # + # @return [Integer, nil] + optional :code, Integer + + # @!attribute message + # Сообщение об ошибке. + # + # @return [String, nil] + optional :message, String + + # @!method initialize(code: nil, message: nil) + # @param code [Integer] Код ошибки. + # + # @param message [String] Сообщение об ошибке. + end + end +end diff --git a/lib/docker_engine_ruby/models/image_id.rb b/lib/docker_engine_ruby/models/image_id.rb new file mode 100644 index 0000000..0835312 --- /dev/null +++ b/lib/docker_engine_ruby/models/image_id.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module DockerEngineRuby + module Models + # Идентификатор или дайджест образа. + class ImageID < DockerEngineRuby::Internal::Type::BaseModel + # @!attribute id + # + # @return [String, nil] + optional :id, String, api_name: :ID + + # @!method initialize(id: nil) + # @param id [String] + end + end +end diff --git a/lib/docker_engine_ruby/models/progress_detail.rb b/lib/docker_engine_ruby/models/progress_detail.rb new file mode 100644 index 0000000..576a41f --- /dev/null +++ b/lib/docker_engine_ruby/models/progress_detail.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module DockerEngineRuby + module Models + # Прогресс шага: сколько сделано из скольки. + class ProgressDetail < DockerEngineRuby::Internal::Type::BaseModel + # @!attribute current + # + # @return [Integer, nil] + optional :current, Integer + + # @!attribute total + # + # @return [Integer, nil] + optional :total, Integer + + # @!method initialize(current: nil, total: nil) + # @param current [Integer] + # + # @param total [Integer] + end + end +end diff --git a/lib/docker_engine_ruby/models/push_image_info.rb b/lib/docker_engine_ruby/models/push_image_info.rb new file mode 100644 index 0000000..1fb710f --- /dev/null +++ b/lib/docker_engine_ruby/models/push_image_info.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module DockerEngineRuby + module Models + # Сообщение из потока `/images/{name}/push`. + class PushImageInfo < DockerEngineRuby::Internal::Type::BaseModel + # @!attribute id + # + # @return [String, nil] + optional :id, String + + # @!attribute error + # Устаревшее поле: то же, что `error_detail.message`. + # + # @return [String, nil] + optional :error, String + + # @!attribute error_detail + # + # @return [DockerEngineRuby::Models::ErrorDetail, nil] + optional :error_detail, -> { DockerEngineRuby::ErrorDetail }, api_name: :errorDetail + + # @!attribute status + # + # @return [String, nil] + optional :status, String + + # @!attribute progress + # Устаревшее поле: прогресс строкой. + # + # @return [String, nil] + optional :progress, String + + # @!attribute progress_detail + # + # @return [DockerEngineRuby::Models::ProgressDetail, nil] + optional :progress_detail, -> { DockerEngineRuby::ProgressDetail }, api_name: :progressDetail + + # @!attribute aux + # Итог публикации: тег, дайджест и размер. + # + # @return [DockerEngineRuby::Models::PushImageInfo::Aux, nil] + optional :aux, -> { DockerEngineRuby::PushImageInfo::Aux } + + # @!method initialize(id: nil, error: nil, error_detail: nil, status: nil, progress: nil, progress_detail: nil, aux: nil) + # @param id [String] + # + # @param error [String] Устаревшее поле: то же, что `error_detail.message`. + # + # @param error_detail [DockerEngineRuby::Models::ErrorDetail] + # + # @param status [String] + # + # @param progress [String] Устаревшее поле: прогресс строкой. + # + # @param progress_detail [DockerEngineRuby::Models::ProgressDetail] + # + # @param aux [DockerEngineRuby::Models::PushImageInfo::Aux] Итог публикации: тег, дайджест и размер. + + # @see DockerEngineRuby::Models::PushImageInfo#aux + class Aux < DockerEngineRuby::Internal::Type::BaseModel + # @!attribute tag + # + # @return [String, nil] + optional :tag, String, api_name: :Tag + + # @!attribute digest + # + # @return [String, nil] + optional :digest, String, api_name: :Digest + + # @!attribute size + # + # @return [Integer, nil] + optional :size, Integer, api_name: :Size + + # @!method initialize(tag: nil, digest: nil, size: nil) + # @param tag [String] + # + # @param digest [String] + # + # @param size [Integer] + end + end + end +end diff --git a/lib/docker_engine_ruby/resources/images.rb b/lib/docker_engine_ruby/resources/images.rb index 2581ce9..a577e55 100644 --- a/lib/docker_engine_ruby/resources/images.rb +++ b/lib/docker_engine_ruby/resources/images.rb @@ -114,10 +114,14 @@ def delete(name, params = {}) # # @param request_options [DockerEngineRuby::RequestOptions, Hash{Symbol=>Object}, nil] # + # @yieldparam message [DockerEngineRuby::Models::BuildInfo] each progress message, as it arrives + # + # @raise [DockerEngineRuby::Errors::StreamError] when the daemon reports a failure inside the stream + # # @return [nil] # # @see DockerEngineRuby::Models::ImageBuildParams - def build(params) + def build(params, &block) query_params = [ :buildargs, @@ -147,7 +151,7 @@ def build(params) :version ] parsed, options = DockerEngineRuby::ImageBuildParams.dump_request(params) - @client.request( + messages = @client.request( method: :post, path: "build", query: parsed.slice(*query_params), @@ -158,9 +162,11 @@ def build(params) x_registry_config: "x-registry-config" ), body: parsed[:body], - model: NilClass, + stream: DockerEngineRuby::Internal::JSONLStream, + model: DockerEngineRuby::BuildInfo, options: options ) + each_progress(messages, &block) end # Delete builder cache @@ -450,13 +456,17 @@ def prune(params = {}) # # @param request_options [DockerEngineRuby::RequestOptions, Hash{Symbol=>Object}, nil] # + # @yieldparam message [DockerEngineRuby::Models::CreateImageInfo] each progress message, as it arrives + # + # @raise [DockerEngineRuby::Errors::StreamError] when the daemon reports a failure inside the stream + # # @return [nil] # # @see DockerEngineRuby::Models::ImagePullParams - def pull(params) + def pull(params, &block) query_params = [:changes, :from_image, :from_src, :message, :platform, :repo, :tag] parsed, options = DockerEngineRuby::ImagePullParams.dump_request(params) - @client.request( + messages = @client.request( method: :post, path: "images/create", query: parsed.slice(*query_params).transform_keys(from_image: "fromImage", from_src: "fromSrc"), @@ -464,9 +474,11 @@ def pull(params) x_registry_auth: "x-registry-auth" ), body: parsed[:body], - model: NilClass, + stream: DockerEngineRuby::Internal::JSONLStream, + model: DockerEngineRuby::CreateImageInfo, options: options ) + each_progress(messages, &block) end # Push an image @@ -483,21 +495,27 @@ def pull(params) # # @param request_options [DockerEngineRuby::RequestOptions, Hash{Symbol=>Object}, nil] # + # @yieldparam message [DockerEngineRuby::Models::PushImageInfo] each progress message, as it arrives + # + # @raise [DockerEngineRuby::Errors::StreamError] when the daemon reports a failure inside the stream + # # @return [nil] # # @see DockerEngineRuby::Models::ImagePushParams - def push(name, params) + def push(name, params, &block) query_params = [:platform, :tag] parsed, options = DockerEngineRuby::ImagePushParams.dump_request(params) query = DockerEngineRuby::Internal::Util.encode_query_params(parsed.slice(*query_params)) - @client.request( + messages = @client.request( method: :post, path: ["images/%1$s/push", name], query: query, headers: parsed.except(*query_params).transform_keys(x_registry_auth: "x-registry-auth"), - model: NilClass, + stream: DockerEngineRuby::Internal::JSONLStream, + model: DockerEngineRuby::PushImageInfo, options: options ) + each_progress(messages, &block) end # Search images @@ -548,6 +566,23 @@ def tag(name, params = {}) ) end + private + + # Docker answers a failed build, pull or push with 200 and an error in the last message, + # so the stream is read to the end and that message turned into an exception. + def each_progress(messages) + messages.each do |message| + detail = message.error_detail + error = detail&.message || message.error + raise DockerEngineRuby::Errors::StreamError.new(error, detail: detail) if error + + yield(message) if block_given? + end + nil + end + + public + # @api private # # @param client [DockerEngineRuby::Client] diff --git a/rbi/docker_engine_ruby/errors.rbi b/rbi/docker_engine_ruby/errors.rbi index 9eb326c..f507c90 100644 --- a/rbi/docker_engine_ruby/errors.rbi +++ b/rbi/docker_engine_ruby/errors.rbi @@ -26,6 +26,20 @@ module DockerEngineRuby end end + class StreamError < DockerEngineRuby::Errors::Error + sig { returns(T.nilable(DockerEngineRuby::Models::ErrorDetail)) } + attr_reader :detail + + sig do + params( + message: String, + detail: T.nilable(DockerEngineRuby::Models::ErrorDetail) + ).returns(T.attached_class) + end + def self.new(message, detail: nil) + end + end + class APIError < DockerEngineRuby::Errors::Error sig { returns(URI::Generic) } attr_accessor :url diff --git a/rbi/docker_engine_ruby/internal/jsonl_stream.rbi b/rbi/docker_engine_ruby/internal/jsonl_stream.rbi new file mode 100644 index 0000000..472cc1d --- /dev/null +++ b/rbi/docker_engine_ruby/internal/jsonl_stream.rbi @@ -0,0 +1,39 @@ +# typed: strong + +module DockerEngineRuby + module Internal + class JSONLStream + include Enumerable + + extend T::Generic + + Elem = type_member(:out) + + sig do + params( + model: T.anything, + url: URI::Generic, + status: Integer, + headers: T::Hash[String, String], + response: Net::HTTPResponse, + unwrap: T.anything, + stream: T::Enumerable[String] + ).void + end + def initialize( + model:, + url:, + status:, + headers:, + response:, + unwrap:, + stream: + ) + end + + sig { override.params(blk: T.proc.params(message: Elem).void).void } + def each(&blk) + end + end + end +end diff --git a/rbi/docker_engine_ruby/models.rbi b/rbi/docker_engine_ruby/models.rbi index 0bbb9a8..7baa95a 100644 --- a/rbi/docker_engine_ruby/models.rbi +++ b/rbi/docker_engine_ruby/models.rbi @@ -291,4 +291,16 @@ module DockerEngineRuby VolumeUpdateParams = DockerEngineRuby::Models::VolumeUpdateParams WaitResponse = DockerEngineRuby::Models::WaitResponse + + BuildInfo = DockerEngineRuby::Models::BuildInfo + + CreateImageInfo = DockerEngineRuby::Models::CreateImageInfo + + ErrorDetail = DockerEngineRuby::Models::ErrorDetail + + ImageID = DockerEngineRuby::Models::ImageID + + ProgressDetail = DockerEngineRuby::Models::ProgressDetail + + PushImageInfo = DockerEngineRuby::Models::PushImageInfo end diff --git a/rbi/docker_engine_ruby/models/build_info.rbi b/rbi/docker_engine_ruby/models/build_info.rbi new file mode 100644 index 0000000..5ab0f07 --- /dev/null +++ b/rbi/docker_engine_ruby/models/build_info.rbi @@ -0,0 +1,122 @@ +# typed: strong + +module DockerEngineRuby + module Models + class BuildInfo < DockerEngineRuby::Internal::Type::BaseModel + OrHash = + T.type_alias do + T.any( + DockerEngineRuby::BuildInfo, + DockerEngineRuby::Internal::AnyHash + ) + end + + sig { returns(T.nilable(String)) } + attr_reader :id + + sig { params(id: String).void } + attr_writer :id + + # Строка вывода сборки. + sig { returns(T.nilable(String)) } + attr_reader :stream + + sig { params(stream: String).void } + attr_writer :stream + + # Устаревшее поле: то же, что `error_detail.message`. + sig { returns(T.nilable(String)) } + attr_reader :error + + sig { params(error: String).void } + attr_writer :error + + sig { returns(T.nilable(DockerEngineRuby::ErrorDetail)) } + attr_reader :error_detail + + sig { params(error_detail: DockerEngineRuby::ErrorDetail::OrHash).void } + attr_writer :error_detail + + sig { returns(T.nilable(String)) } + attr_reader :status + + sig { params(status: String).void } + attr_writer :status + + # Устаревшее поле: прогресс строкой. + sig { returns(T.nilable(String)) } + attr_reader :progress + + sig { params(progress: String).void } + attr_writer :progress + + sig { returns(T.nilable(DockerEngineRuby::ProgressDetail)) } + attr_reader :progress_detail + + sig do + params(progress_detail: DockerEngineRuby::ProgressDetail::OrHash).void + end + attr_writer :progress_detail + + # Классический сборщик кладёт сюда ID образа, BuildKit — base64 protobuf `StatusResponse` при `id` = `moby.buildkit.trace`. + sig { returns(T.nilable(DockerEngineRuby::BuildInfo::Aux::Variants)) } + attr_reader :aux + + sig { params(aux: DockerEngineRuby::BuildInfo::Aux::Variants).void } + attr_writer :aux + + sig do + params( + id: String, + stream: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail::OrHash, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail::OrHash, + aux: DockerEngineRuby::BuildInfo::Aux::Variants + ).returns(T.attached_class) + end + def self.new( + id: nil, + stream: nil, + error: nil, + error_detail: nil, + status: nil, + progress: nil, + progress_detail: nil, + aux: nil + ) + end + + sig do + override.returns( + { + id: String, + stream: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail, + aux: DockerEngineRuby::BuildInfo::Aux::Variants + } + ) + end + def to_hash + end + + module Aux + extend DockerEngineRuby::Internal::Type::Union + + Variants = T.type_alias { T.any(DockerEngineRuby::ImageID, String) } + + sig do + override.returns(T::Array[DockerEngineRuby::BuildInfo::Aux::Variants]) + end + def self.variants + end + end + end + end +end diff --git a/rbi/docker_engine_ruby/models/create_image_info.rbi b/rbi/docker_engine_ruby/models/create_image_info.rbi new file mode 100644 index 0000000..54c25b3 --- /dev/null +++ b/rbi/docker_engine_ruby/models/create_image_info.rbi @@ -0,0 +1,90 @@ +# typed: strong + +module DockerEngineRuby + module Models + class CreateImageInfo < DockerEngineRuby::Internal::Type::BaseModel + OrHash = + T.type_alias do + T.any( + DockerEngineRuby::CreateImageInfo, + DockerEngineRuby::Internal::AnyHash + ) + end + + sig { returns(T.nilable(String)) } + attr_reader :id + + sig { params(id: String).void } + attr_writer :id + + # Устаревшее поле: то же, что `error_detail.message`. + sig { returns(T.nilable(String)) } + attr_reader :error + + sig { params(error: String).void } + attr_writer :error + + sig { returns(T.nilable(DockerEngineRuby::ErrorDetail)) } + attr_reader :error_detail + + sig { params(error_detail: DockerEngineRuby::ErrorDetail::OrHash).void } + attr_writer :error_detail + + sig { returns(T.nilable(String)) } + attr_reader :status + + sig { params(status: String).void } + attr_writer :status + + # Устаревшее поле: прогресс строкой. + sig { returns(T.nilable(String)) } + attr_reader :progress + + sig { params(progress: String).void } + attr_writer :progress + + sig { returns(T.nilable(DockerEngineRuby::ProgressDetail)) } + attr_reader :progress_detail + + sig do + params(progress_detail: DockerEngineRuby::ProgressDetail::OrHash).void + end + attr_writer :progress_detail + + sig do + params( + id: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail::OrHash, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail::OrHash + ).returns(T.attached_class) + end + def self.new( + id: nil, + error: nil, + error_detail: nil, + status: nil, + progress: nil, + progress_detail: nil + ) + end + + sig do + override.returns( + { + id: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail + } + ) + end + def to_hash + end + end + end +end diff --git a/rbi/docker_engine_ruby/models/error_detail.rbi b/rbi/docker_engine_ruby/models/error_detail.rbi new file mode 100644 index 0000000..9b846dc --- /dev/null +++ b/rbi/docker_engine_ruby/models/error_detail.rbi @@ -0,0 +1,37 @@ +# typed: strong + +module DockerEngineRuby + module Models + class ErrorDetail < DockerEngineRuby::Internal::Type::BaseModel + OrHash = + T.type_alias do + T.any( + DockerEngineRuby::ErrorDetail, + DockerEngineRuby::Internal::AnyHash + ) + end + + # Код ошибки. + sig { returns(T.nilable(Integer)) } + attr_reader :code + + sig { params(code: Integer).void } + attr_writer :code + + # Сообщение об ошибке. + sig { returns(T.nilable(String)) } + attr_reader :message + + sig { params(message: String).void } + attr_writer :message + + sig { params(code: Integer, message: String).returns(T.attached_class) } + def self.new(code: nil, message: nil) + end + + sig { override.returns({ code: Integer, message: String }) } + def to_hash + end + end + end +end diff --git a/rbi/docker_engine_ruby/models/image_id.rbi b/rbi/docker_engine_ruby/models/image_id.rbi new file mode 100644 index 0000000..86d0e48 --- /dev/null +++ b/rbi/docker_engine_ruby/models/image_id.rbi @@ -0,0 +1,26 @@ +# typed: strong + +module DockerEngineRuby + module Models + class ImageID < DockerEngineRuby::Internal::Type::BaseModel + OrHash = + T.type_alias do + T.any(DockerEngineRuby::ImageID, DockerEngineRuby::Internal::AnyHash) + end + + sig { returns(T.nilable(String)) } + attr_reader :id + + sig { params(id: String).void } + attr_writer :id + + sig { params(id: String).returns(T.attached_class) } + def self.new(id: nil) + end + + sig { override.returns({ id: String }) } + def to_hash + end + end + end +end diff --git a/rbi/docker_engine_ruby/models/progress_detail.rbi b/rbi/docker_engine_ruby/models/progress_detail.rbi new file mode 100644 index 0000000..8c47c2d --- /dev/null +++ b/rbi/docker_engine_ruby/models/progress_detail.rbi @@ -0,0 +1,35 @@ +# typed: strong + +module DockerEngineRuby + module Models + class ProgressDetail < DockerEngineRuby::Internal::Type::BaseModel + OrHash = + T.type_alias do + T.any( + DockerEngineRuby::ProgressDetail, + DockerEngineRuby::Internal::AnyHash + ) + end + + sig { returns(T.nilable(Integer)) } + attr_reader :current + + sig { params(current: Integer).void } + attr_writer :current + + sig { returns(T.nilable(Integer)) } + attr_reader :total + + sig { params(total: Integer).void } + attr_writer :total + + sig { params(current: Integer, total: Integer).returns(T.attached_class) } + def self.new(current: nil, total: nil) + end + + sig { override.returns({ current: Integer, total: Integer }) } + def to_hash + end + end + end +end diff --git a/rbi/docker_engine_ruby/models/push_image_info.rbi b/rbi/docker_engine_ruby/models/push_image_info.rbi new file mode 100644 index 0000000..c67cd9d --- /dev/null +++ b/rbi/docker_engine_ruby/models/push_image_info.rbi @@ -0,0 +1,140 @@ +# typed: strong + +module DockerEngineRuby + module Models + class PushImageInfo < DockerEngineRuby::Internal::Type::BaseModel + OrHash = + T.type_alias do + T.any( + DockerEngineRuby::PushImageInfo, + DockerEngineRuby::Internal::AnyHash + ) + end + + sig { returns(T.nilable(String)) } + attr_reader :id + + sig { params(id: String).void } + attr_writer :id + + # Устаревшее поле: то же, что `error_detail.message`. + sig { returns(T.nilable(String)) } + attr_reader :error + + sig { params(error: String).void } + attr_writer :error + + sig { returns(T.nilable(DockerEngineRuby::ErrorDetail)) } + attr_reader :error_detail + + sig { params(error_detail: DockerEngineRuby::ErrorDetail::OrHash).void } + attr_writer :error_detail + + sig { returns(T.nilable(String)) } + attr_reader :status + + sig { params(status: String).void } + attr_writer :status + + # Устаревшее поле: прогресс строкой. + sig { returns(T.nilable(String)) } + attr_reader :progress + + sig { params(progress: String).void } + attr_writer :progress + + sig { returns(T.nilable(DockerEngineRuby::ProgressDetail)) } + attr_reader :progress_detail + + sig do + params(progress_detail: DockerEngineRuby::ProgressDetail::OrHash).void + end + attr_writer :progress_detail + + # Итог публикации: тег, дайджест и размер. + sig { returns(T.nilable(DockerEngineRuby::PushImageInfo::Aux)) } + attr_reader :aux + + sig { params(aux: DockerEngineRuby::PushImageInfo::Aux::OrHash).void } + attr_writer :aux + + sig do + params( + id: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail::OrHash, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail::OrHash, + aux: DockerEngineRuby::PushImageInfo::Aux::OrHash + ).returns(T.attached_class) + end + def self.new( + id: nil, + error: nil, + error_detail: nil, + status: nil, + progress: nil, + progress_detail: nil, + aux: nil + ) + end + + sig do + override.returns( + { + id: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail, + aux: DockerEngineRuby::PushImageInfo::Aux + } + ) + end + def to_hash + end + + class Aux < DockerEngineRuby::Internal::Type::BaseModel + OrHash = + T.type_alias do + T.any( + DockerEngineRuby::PushImageInfo::Aux, + DockerEngineRuby::Internal::AnyHash + ) + end + + sig { returns(T.nilable(String)) } + attr_reader :tag + + sig { params(tag: String).void } + attr_writer :tag + + sig { returns(T.nilable(String)) } + attr_reader :digest + + sig { params(digest: String).void } + attr_writer :digest + + sig { returns(T.nilable(Integer)) } + attr_reader :size + + sig { params(size: Integer).void } + attr_writer :size + + sig do + params(tag: String, digest: String, size: Integer).returns( + T.attached_class + ) + end + def self.new(tag: nil, digest: nil, size: nil) + end + + sig { override.returns({ tag: String, digest: String, size: Integer }) } + def to_hash + end + end + end + end +end diff --git a/rbi/docker_engine_ruby/resources/images.rbi b/rbi/docker_engine_ruby/resources/images.rbi index 0e5c806..04ca88e 100644 --- a/rbi/docker_engine_ruby/resources/images.rbi +++ b/rbi/docker_engine_ruby/resources/images.rbi @@ -73,7 +73,9 @@ module DockerEngineRuby target: String, version: DockerEngineRuby::ImageBuildParams::Version::OrSymbol, x_registry_config: String, - request_options: DockerEngineRuby::RequestOptions::OrHash + request_options: DockerEngineRuby::RequestOptions::OrHash, + blk: + T.nilable(T.proc.params(message: DockerEngineRuby::BuildInfo).void) ).void end def build( @@ -131,7 +133,8 @@ module DockerEngineRuby version: nil, # Header param x_registry_config: nil, - request_options: {} + request_options: {}, + &blk ) end @@ -364,7 +367,11 @@ module DockerEngineRuby repo: String, tag: String, x_registry_auth: String, - request_options: DockerEngineRuby::RequestOptions::OrHash + request_options: DockerEngineRuby::RequestOptions::OrHash, + blk: + T.nilable( + T.proc.params(message: DockerEngineRuby::CreateImageInfo).void + ) ).void end def pull( @@ -386,7 +393,8 @@ module DockerEngineRuby tag: nil, # Header param x_registry_auth: nil, - request_options: {} + request_options: {}, + &blk ) end @@ -397,7 +405,11 @@ module DockerEngineRuby x_registry_auth: String, platform: String, tag: String, - request_options: DockerEngineRuby::RequestOptions::OrHash + request_options: DockerEngineRuby::RequestOptions::OrHash, + blk: + T.nilable( + T.proc.params(message: DockerEngineRuby::PushImageInfo).void + ) ).void end def push( @@ -409,7 +421,8 @@ module DockerEngineRuby platform: nil, # Query param tag: nil, - request_options: {} + request_options: {}, + &blk ) end diff --git a/sig/docker_engine_ruby/errors.rbs b/sig/docker_engine_ruby/errors.rbs index 8f536d1..1bde32c 100644 --- a/sig/docker_engine_ruby/errors.rbs +++ b/sig/docker_engine_ruby/errors.rbs @@ -16,6 +16,15 @@ module DockerEngineRuby ) -> void end + class StreamError < DockerEngineRuby::Errors::Error + attr_reader detail: DockerEngineRuby::Models::ErrorDetail? + + def initialize: ( + String message, + ?detail: DockerEngineRuby::Models::ErrorDetail? + ) -> void + end + class APIError < DockerEngineRuby::Errors::Error attr_accessor url: URI::Generic diff --git a/sig/docker_engine_ruby/internal/jsonl_stream.rbs b/sig/docker_engine_ruby/internal/jsonl_stream.rbs new file mode 100644 index 0000000..e8b4efd --- /dev/null +++ b/sig/docker_engine_ruby/internal/jsonl_stream.rbs @@ -0,0 +1,19 @@ +module DockerEngineRuby + module Internal + class JSONLStream[Elem] + include Enumerable[Elem] + + def initialize: ( + model: top, + url: URI::Generic, + status: Integer, + headers: ::Hash[String, String], + response: top, + unwrap: top, + stream: Enumerable[String] + ) -> void + + def each: { (Elem message) -> void } -> void + end + end +end diff --git a/sig/docker_engine_ruby/models.rbs b/sig/docker_engine_ruby/models.rbs index 9236511..af179cc 100644 --- a/sig/docker_engine_ruby/models.rbs +++ b/sig/docker_engine_ruby/models.rbs @@ -288,4 +288,16 @@ module DockerEngineRuby class VolumeUpdateParams = DockerEngineRuby::Models::VolumeUpdateParams class WaitResponse = DockerEngineRuby::Models::WaitResponse + + class BuildInfo = DockerEngineRuby::Models::BuildInfo + + class CreateImageInfo = DockerEngineRuby::Models::CreateImageInfo + + class ErrorDetail = DockerEngineRuby::Models::ErrorDetail + + class ImageID = DockerEngineRuby::Models::ImageID + + class ProgressDetail = DockerEngineRuby::Models::ProgressDetail + + class PushImageInfo = DockerEngineRuby::Models::PushImageInfo end diff --git a/sig/docker_engine_ruby/models/build_info.rbs b/sig/docker_engine_ruby/models/build_info.rbs new file mode 100644 index 0000000..44315bc --- /dev/null +++ b/sig/docker_engine_ruby/models/build_info.rbs @@ -0,0 +1,85 @@ +module DockerEngineRuby + module Models + type build_info = + { + id: String, + stream: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail, + aux: DockerEngineRuby::Models::BuildInfo::aux + } + + class BuildInfo < DockerEngineRuby::Internal::Type::BaseModel + attr_reader id: String? + + def id=: (String) -> String + + attr_reader stream: String? + + def stream=: (String) -> String + + attr_reader error: String? + + def error=: (String) -> String + + attr_reader error_detail: DockerEngineRuby::ErrorDetail? + + def error_detail=: ( + DockerEngineRuby::ErrorDetail + ) -> DockerEngineRuby::ErrorDetail + + attr_reader status: String? + + def status=: (String) -> String + + attr_reader progress: String? + + def progress=: (String) -> String + + attr_reader progress_detail: DockerEngineRuby::ProgressDetail? + + def progress_detail=: ( + DockerEngineRuby::ProgressDetail + ) -> DockerEngineRuby::ProgressDetail + + attr_reader aux: DockerEngineRuby::Models::BuildInfo::aux? + + def aux=: ( + DockerEngineRuby::Models::BuildInfo::aux + ) -> DockerEngineRuby::Models::BuildInfo::aux + + def initialize: ( + ?id: String, + ?stream: String, + ?error: String, + ?error_detail: DockerEngineRuby::ErrorDetail, + ?status: String, + ?progress: String, + ?progress_detail: DockerEngineRuby::ProgressDetail, + ?aux: DockerEngineRuby::Models::BuildInfo::aux + ) -> void + + def to_hash: -> { + id: String, + stream: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail, + aux: DockerEngineRuby::Models::BuildInfo::aux + } + + type aux = DockerEngineRuby::ImageID | String + + module Aux + extend DockerEngineRuby::Internal::Type::Union + + def self?.variants: -> ::Array[DockerEngineRuby::Models::BuildInfo::aux] + end + end + end +end diff --git a/sig/docker_engine_ruby/models/create_image_info.rbs b/sig/docker_engine_ruby/models/create_image_info.rbs new file mode 100644 index 0000000..8c27452 --- /dev/null +++ b/sig/docker_engine_ruby/models/create_image_info.rbs @@ -0,0 +1,61 @@ +module DockerEngineRuby + module Models + type create_image_info = + { + id: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail + } + + class CreateImageInfo < DockerEngineRuby::Internal::Type::BaseModel + attr_reader id: String? + + def id=: (String) -> String + + attr_reader error: String? + + def error=: (String) -> String + + attr_reader error_detail: DockerEngineRuby::ErrorDetail? + + def error_detail=: ( + DockerEngineRuby::ErrorDetail + ) -> DockerEngineRuby::ErrorDetail + + attr_reader status: String? + + def status=: (String) -> String + + attr_reader progress: String? + + def progress=: (String) -> String + + attr_reader progress_detail: DockerEngineRuby::ProgressDetail? + + def progress_detail=: ( + DockerEngineRuby::ProgressDetail + ) -> DockerEngineRuby::ProgressDetail + + def initialize: ( + ?id: String, + ?error: String, + ?error_detail: DockerEngineRuby::ErrorDetail, + ?status: String, + ?progress: String, + ?progress_detail: DockerEngineRuby::ProgressDetail + ) -> void + + def to_hash: -> { + id: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail + } + end + end +end diff --git a/sig/docker_engine_ruby/models/error_detail.rbs b/sig/docker_engine_ruby/models/error_detail.rbs new file mode 100644 index 0000000..95acc50 --- /dev/null +++ b/sig/docker_engine_ruby/models/error_detail.rbs @@ -0,0 +1,19 @@ +module DockerEngineRuby + module Models + type error_detail = { code: Integer, message: String } + + class ErrorDetail < DockerEngineRuby::Internal::Type::BaseModel + attr_reader code: Integer? + + def code=: (Integer) -> Integer + + attr_reader message: String? + + def message=: (String) -> String + + def initialize: (?code: Integer, ?message: String) -> void + + def to_hash: -> { code: Integer, message: String } + end + end +end diff --git a/sig/docker_engine_ruby/models/image_id.rbs b/sig/docker_engine_ruby/models/image_id.rbs new file mode 100644 index 0000000..65e3c65 --- /dev/null +++ b/sig/docker_engine_ruby/models/image_id.rbs @@ -0,0 +1,15 @@ +module DockerEngineRuby + module Models + type image_id = { id: String } + + class ImageID < DockerEngineRuby::Internal::Type::BaseModel + attr_reader id: String? + + def id=: (String) -> String + + def initialize: (?id: String) -> void + + def to_hash: -> { id: String } + end + end +end diff --git a/sig/docker_engine_ruby/models/progress_detail.rbs b/sig/docker_engine_ruby/models/progress_detail.rbs new file mode 100644 index 0000000..464b159 --- /dev/null +++ b/sig/docker_engine_ruby/models/progress_detail.rbs @@ -0,0 +1,19 @@ +module DockerEngineRuby + module Models + type progress_detail = { current: Integer, total: Integer } + + class ProgressDetail < DockerEngineRuby::Internal::Type::BaseModel + attr_reader current: Integer? + + def current=: (Integer) -> Integer + + attr_reader total: Integer? + + def total=: (Integer) -> Integer + + def initialize: (?current: Integer, ?total: Integer) -> void + + def to_hash: -> { current: Integer, total: Integer } + end + end +end diff --git a/sig/docker_engine_ruby/models/push_image_info.rbs b/sig/docker_engine_ruby/models/push_image_info.rbs new file mode 100644 index 0000000..c101630 --- /dev/null +++ b/sig/docker_engine_ruby/models/push_image_info.rbs @@ -0,0 +1,90 @@ +module DockerEngineRuby + module Models + type push_image_info = + { + id: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail, + aux: DockerEngineRuby::PushImageInfo::Aux + } + + class PushImageInfo < DockerEngineRuby::Internal::Type::BaseModel + attr_reader id: String? + + def id=: (String) -> String + + attr_reader error: String? + + def error=: (String) -> String + + attr_reader error_detail: DockerEngineRuby::ErrorDetail? + + def error_detail=: ( + DockerEngineRuby::ErrorDetail + ) -> DockerEngineRuby::ErrorDetail + + attr_reader status: String? + + def status=: (String) -> String + + attr_reader progress: String? + + def progress=: (String) -> String + + attr_reader progress_detail: DockerEngineRuby::ProgressDetail? + + def progress_detail=: ( + DockerEngineRuby::ProgressDetail + ) -> DockerEngineRuby::ProgressDetail + + attr_reader aux: DockerEngineRuby::PushImageInfo::Aux? + + def aux=: ( + DockerEngineRuby::PushImageInfo::Aux + ) -> DockerEngineRuby::PushImageInfo::Aux + + def initialize: ( + ?id: String, + ?error: String, + ?error_detail: DockerEngineRuby::ErrorDetail, + ?status: String, + ?progress: String, + ?progress_detail: DockerEngineRuby::ProgressDetail, + ?aux: DockerEngineRuby::PushImageInfo::Aux + ) -> void + + def to_hash: -> { + id: String, + error: String, + error_detail: DockerEngineRuby::ErrorDetail, + status: String, + progress: String, + progress_detail: DockerEngineRuby::ProgressDetail, + aux: DockerEngineRuby::PushImageInfo::Aux + } + + type aux = { tag: String, digest: String, size: Integer } + + class Aux < DockerEngineRuby::Internal::Type::BaseModel + attr_reader tag: String? + + def tag=: (String) -> String + + attr_reader digest: String? + + def digest=: (String) -> String + + attr_reader size: Integer? + + def size=: (Integer) -> Integer + + def initialize: (?tag: String, ?digest: String, ?size: Integer) -> void + + def to_hash: -> { tag: String, digest: String, size: Integer } + end + end + end +end diff --git a/sig/docker_engine_ruby/resources/images.rbs b/sig/docker_engine_ruby/resources/images.rbs index 81c0eef..492010c 100644 --- a/sig/docker_engine_ruby/resources/images.rbs +++ b/sig/docker_engine_ruby/resources/images.rbs @@ -47,7 +47,9 @@ module DockerEngineRuby ?version: DockerEngineRuby::Models::ImageBuildParams::version, ?x_registry_config: String, ?request_options: DockerEngineRuby::request_opts - ) -> nil + ) ?{ + (DockerEngineRuby::BuildInfo message) -> void + } -> nil def build_prune: ( ?all: bool, @@ -140,7 +142,9 @@ module DockerEngineRuby ?tag: String, ?x_registry_auth: String, ?request_options: DockerEngineRuby::request_opts - ) -> nil + ) ?{ + (DockerEngineRuby::CreateImageInfo message) -> void + } -> nil def push: ( String name, @@ -148,7 +152,9 @@ module DockerEngineRuby ?platform: String, ?tag: String, ?request_options: DockerEngineRuby::request_opts - ) -> nil + ) ?{ + (DockerEngineRuby::PushImageInfo message) -> void + } -> nil def search: ( term: String, diff --git a/test/docker_engine_ruby/helpers/buildkit_status_test.rb b/test/docker_engine_ruby/helpers/buildkit_status_test.rb new file mode 100644 index 0000000..2bebf1c --- /dev/null +++ b/test/docker_engine_ruby/helpers/buildkit_status_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require_relative "buildkit_trace_fixture" + +class DockerEngineRuby::Test::BuildkitStatusTest < Minitest::Test + def test_plain_printer_renders_steps_logs_and_errors + lines = [] + printer = DockerEngineRuby::Helpers::BuildkitStatus::PlainPrinter.new { lines << _1 } + BUILDKIT_TRACE.each { printer.print(DockerEngineRuby::Helpers::BuildkitStatus.decode(_1)) } + + assert_includes(lines.join("\n"), "[2/3] RUN echo line-one && echo line-two") + assert(lines.any? { _1.end_with?(" line-one") }) + assert(lines.any? { _1.end_with?(" line-two") }) + assert( + lines.any? do + _1.include?(%(ERROR: process "/bin/sh -c exit 3" did not complete successfully: exit code: 3)) + end + ) + end +end diff --git a/test/docker_engine_ruby/helpers/buildkit_trace_fixture.rb b/test/docker_engine_ruby/helpers/buildkit_trace_fixture.rb new file mode 100644 index 0000000..72e29c7 --- /dev/null +++ b/test/docker_engine_ruby/helpers/buildkit_trace_fixture.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Captured from Docker 29.8 building `RUN echo line-one && echo line-two` then `RUN exit 3` with BuildKit. +BUILDKIT_TRACE = %w[ + Cm8KR3NoYTI1NjowNjI2YTc2MmNhZDA0YTY2YTA0NTA5NTIyMjU3MzI5YTkyMTYzMTk2MGM1ZTA0YmE3MzIzMDM5ZTZiN2Q2YWUzGiRbaW50ZXJuYWxdIGxvYWQgcmVtb3RlIGJ1aWxkIGNvbnRleHQ= + Cn0KR3NoYTI1NjowNjI2YTc2MmNhZDA0YTY2YTA0NTA5NTIyMjU3MzI5YTkyMTYzMTk2MGM1ZTA0YmE3MzIzMDM5ZTZiN2Q2YWUzGiRbaW50ZXJuYWxdIGxvYWQgcmVtb3RlIGJ1aWxkIGNvbnRleHQqDAiM6eXVBhCuzJPoAQ== + CosBCkdzaGEyNTY6MDYyNmE3NjJjYWQwNGE2NmEwNDUwOTUyMjI1NzMyOWE5MjE2MzE5NjBjNWUwNGJhNzMyMzAzOWU2YjdkNmFlMxokW2ludGVybmFsXSBsb2FkIHJlbW90ZSBidWlsZCBjb250ZXh0KgwIjOnl1QYQrsyT6AEyDAiM6eXVBhCe/7OFAg== + CosBCkdzaGEyNTY6MDYyNmE3NjJjYWQwNGE2NmEwNDUwOTUyMjI1NzMyOWE5MjE2MzE5NjBjNWUwNGJhNzMyMzAzOWU2YjdkNmFlMxokW2ludGVybmFsXSBsb2FkIHJlbW90ZSBidWlsZCBjb250ZXh0KgwIjOnl1QYQxZXShQIyDAiM6eXVBhCAj9eFAg== + CqMBCkdzaGEyNTY6Zjg3Mjg1NDMzZmIyY2I0NGZkMjc4MDVlOGI4MjBhMDIxN2ZjNTI2ZjQ0YzAyMjYyOWJkMWE2NDY1ZGQ1MzM4MxJHc2hhMjU2OjA2MjZhNzYyY2FkMDRhNjZhMDQ1MDk1MjIyNTczMjlhOTIxNjMxOTYwYzVlMDRiYTczMjMwMzllNmI3ZDZhZTMaD2NvcHkgL2NvbnRleHQgLw== + CrEBCkdzaGEyNTY6Zjg3Mjg1NDMzZmIyY2I0NGZkMjc4MDVlOGI4MjBhMDIxN2ZjNTI2ZjQ0YzAyMjYyOWJkMWE2NDY1ZGQ1MzM4MxJHc2hhMjU2OjA2MjZhNzYyY2FkMDRhNjZhMDQ1MDk1MjIyNTczMjlhOTIxNjMxOTYwYzVlMDRiYTczMjMwMzllNmI3ZDZhZTMaD2NvcHkgL2NvbnRleHQgLyoMCIzp5dUGEMvmkZMC + Cr8BCkdzaGEyNTY6Zjg3Mjg1NDMzZmIyY2I0NGZkMjc4MDVlOGI4MjBhMDIxN2ZjNTI2ZjQ0YzAyMjYyOWJkMWE2NDY1ZGQ1MzM4MxJHc2hhMjU2OjA2MjZhNzYyY2FkMDRhNjZhMDQ1MDk1MjIyNTczMjlhOTIxNjMxOTYwYzVlMDRiYTczMjMwMzllNmI3ZDZhZTMaD2NvcHkgL2NvbnRleHQgLyoMCIzp5dUGEMvmkZMCMgwIjOnl1QYQyOGAsgI= + CpMBCkdzaGEyNTY6ZmRlMGU0ZjRhMTA3NzQwNjUxMjNkYzhhYzE2ZDBjZjllMmYwMmRlMmRiZWNiOGY4ZTllOTBhY2QyMjFkZjliNBo6W2ludGVybmFsXSBsb2FkIG1ldGFkYXRhIGZvciBkb2NrZXIuaW8vbGlicmFyeS9hbHBpbmU6My4yMCoMCIzp5dUGEN3sw7cC + CqEBCkdzaGEyNTY6ZmRlMGU0ZjRhMTA3NzQwNjUxMjNkYzhhYzE2ZDBjZjllMmYwMmRlMmRiZWNiOGY4ZTllOTBhY2QyMjFkZjliNBo6W2ludGVybmFsXSBsb2FkIG1ldGFkYXRhIGZvciBkb2NrZXIuaW8vbGlicmFyeS9hbHBpbmU6My4yMCoMCIzp5dUGEN3sw7cCMgwIjOnl1QYQquW02QM= + CqQBCkdzaGEyNTY6ZmQxMGI0OTYzMjQ2NTY0NWExN2M2MDNjMDhhZGFhZWJjYTUwMjY0ZTZjNzNhYWM4ODhmMGEyMTcwYmY5ZmFjYxJHc2hhMjU2OjI0YTA1ZDgzZmZjYzVjMTg4YTdhOTBlYzg1ZjU0OWM4YzMzZDgyNGI5OGIxOTQwYjZhMzM0NjcyOTk0NDBhNWMaEFszLzNdIFJVTiBleGl0IDMKvAEKR3NoYTI1NjoyNGEwNWQ4M2ZmY2M1YzE4OGE3YTkwZWM4NWY1NDljOGMzM2Q4MjRiOThiMTk0MGI2YTMzNDY3Mjk5NDQwYTVjEkdzaGEyNTY6NjBmODA1YmU4MTRkOTUwZGJlNDZkNDYwZjJjOTZmYjlkOTc2OThiZGU2YTdlYmJkZDQ5OWViMTJiN2UwZDYxORooWzIvM10gUlVOIGVjaG8gbGluZS1vbmUgJiYgZWNobyBsaW5lLXR3bwq7AQpHc2hhMjU2OjYwZjgwNWJlODE0ZDk1MGRiZTQ2ZDQ2MGYyYzk2ZmI5ZDk3Njk4YmRlNmE3ZWJiZGQ0OTllYjEyYjdlMGQ2MTkacFsxLzNdIEZST00gZG9ja2VyLmlvL2xpYnJhcnkvYWxwaW5lOjMuMjBAc2hhMjU2OmQ5ZTg1M2U4N2U1NTUyNmY2YjI5MTdkZjkxYTIxMTVjMzZkZDdjNjk2YTM1YmUxMjE2M2Q0NGU2ZTJhNGI2YmM= + CskBCkdzaGEyNTY6NjBmODA1YmU4MTRkOTUwZGJlNDZkNDYwZjJjOTZmYjlkOTc2OThiZGU2YTdlYmJkZDQ5OWViMTJiN2UwZDYxORpwWzEvM10gRlJPTSBkb2NrZXIuaW8vbGlicmFyeS9hbHBpbmU6My4yMEBzaGEyNTY6ZDllODUzZTg3ZTU1NTI2ZjZiMjkxN2RmOTFhMjExNWMzNmRkN2M2OTZhMzViZTEyMTYzZDQ0ZTZlMmE0YjZiYyoMCIzp5dUGEJXPjtsD + EtQBCm1yZXNvbHZlIGRvY2tlci5pby9saWJyYXJ5L2FscGluZTozLjIwQHNoYTI1NjpkOWU4NTNlODdlNTU1MjZmNmIyOTE3ZGY5MWEyMTE1YzM2ZGQ3YzY5NmEzNWJlMTIxNjNkNDRlNmUyYTRiNmJjEkdzaGEyNTY6NjBmODA1YmU4MTRkOTUwZGJlNDZkNDYwZjJjOTZmYjlkOTc2OThiZGU2YTdlYmJkZDQ5OWViMTJiN2UwZDYxOTIMCIzp5dUGELuFstwDOgwIjOnl1QYQyoCy3AM= + EuABCm1yZXNvbHZlIGRvY2tlci5pby9saWJyYXJ5L2FscGluZTozLjIwQHNoYTI1NjpkOWU4NTNlODdlNTU1MjZmNmIyOTE3ZGY5MWEyMTE1YzM2ZGQ3YzY5NmEzNWJlMTIxNjNkNDRlNmUyYTRiNmJjEkdzaGEyNTY6NjBmODA1YmU4MTRkOTUwZGJlNDZkNDYwZjJjOTZmYjlkOTc2OThiZGU2YTdlYmJkZDQ5OWViMTJiN2UwZDYxOTILCI3p5dUGEI3yswo6DAiM6eXVBhDKgLLcA0ILCI3p5dUGEPXrswo= + CtYBCkdzaGEyNTY6NjBmODA1YmU4MTRkOTUwZGJlNDZkNDYwZjJjOTZmYjlkOTc2OThiZGU2YTdlYmJkZDQ5OWViMTJiN2UwZDYxORpwWzEvM10gRlJPTSBkb2NrZXIuaW8vbGlicmFyeS9hbHBpbmU6My4yMEBzaGEyNTY6ZDllODUzZTg3ZTU1NTI2ZjZiMjkxN2RmOTFhMjExNWMzNmRkN2M2OTZhMzViZTEyMTYzZDQ0ZTZlMmE0YjZiYyoMCIzp5dUGEJXPjtsDMgsIjenl1QYQprm6Cg== + CsoBCkdzaGEyNTY6NjBmODA1YmU4MTRkOTUwZGJlNDZkNDYwZjJjOTZmYjlkOTc2OThiZGU2YTdlYmJkZDQ5OWViMTJiN2UwZDYxORpwWzEvM10gRlJPTSBkb2NrZXIuaW8vbGlicmFyeS9hbHBpbmU6My4yMEBzaGEyNTY6ZDllODUzZTg3ZTU1NTI2ZjZiMjkxN2RmOTFhMjExNWMzNmRkN2M2OTZhMzViZTEyMTYzZDQ0ZTZlMmE0YjZiYyABKgsIjenl1QYQiaCLCw== + CtcBCkdzaGEyNTY6NjBmODA1YmU4MTRkOTUwZGJlNDZkNDYwZjJjOTZmYjlkOTc2OThiZGU2YTdlYmJkZDQ5OWViMTJiN2UwZDYxORpwWzEvM10gRlJPTSBkb2NrZXIuaW8vbGlicmFyeS9hbHBpbmU6My4yMEBzaGEyNTY6ZDllODUzZTg3ZTU1NTI2ZjZiMjkxN2RmOTFhMjExNWMzNmRkN2M2OTZhMzViZTEyMTYzZDQ0ZTZlMmE0YjZiYyABKgsIjenl1QYQiaCLCzILCI3p5dUGENGY2Qs= + CskBCkdzaGEyNTY6MjRhMDVkODNmZmNjNWMxODhhN2E5MGVjODVmNTQ5YzhjMzNkODI0Yjk4YjE5NDBiNmEzMzQ2NzI5OTQ0MGE1YxJHc2hhMjU2OjYwZjgwNWJlODE0ZDk1MGRiZTQ2ZDQ2MGYyYzk2ZmI5ZDk3Njk4YmRlNmE3ZWJiZGQ0OTllYjEyYjdlMGQ2MTkaKFsyLzNdIFJVTiBlY2hvIGxpbmUtb25lICYmIGVjaG8gbGluZS10d28qCwiN6eXVBhD9rvsL + Gm0KR3NoYTI1NjoyNGEwNWQ4M2ZmY2M1YzE4OGE3YTkwZWM4NWY1NDljOGMzM2Q4MjRiOThiMTk0MGI2YTMzNDY3Mjk5NDQwYTVjEgwIjenl1QYQ3qnLyAEYASISbGluZS1vbmUKbGluZS10d28K + CtcBCkdzaGEyNTY6MjRhMDVkODNmZmNjNWMxODhhN2E5MGVjODVmNTQ5YzhjMzNkODI0Yjk4YjE5NDBiNmEzMzQ2NzI5OTQ0MGE1YxJHc2hhMjU2OjYwZjgwNWJlODE0ZDk1MGRiZTQ2ZDQ2MGYyYzk2ZmI5ZDk3Njk4YmRlNmE3ZWJiZGQ0OTllYjEyYjdlMGQ2MTkaKFsyLzNdIFJVTiBlY2hvIGxpbmUtb25lICYmIGVjaG8gbGluZS10d28qCwiN6eXVBhD9rvsLMgwIjenl1QYQ/L3a3QE= + CrIBCkdzaGEyNTY6ZmQxMGI0OTYzMjQ2NTY0NWExN2M2MDNjMDhhZGFhZWJjYTUwMjY0ZTZjNzNhYWM4ODhmMGEyMTcwYmY5ZmFjYxJHc2hhMjU2OjI0YTA1ZDgzZmZjYzVjMTg4YTdhOTBlYzg1ZjU0OWM4YzMzZDgyNGI5OGIxOTQwYjZhMzM0NjcyOTk0NDBhNWMaEFszLzNdIFJVTiBleGl0IDMqDAiN6eXVBhDh+uvhAQ== + CokCCkdzaGEyNTY6ZmQxMGI0OTYzMjQ2NTY0NWExN2M2MDNjMDhhZGFhZWJjYTUwMjY0ZTZjNzNhYWM4ODhmMGEyMTcwYmY5ZmFjYxJHc2hhMjU2OjI0YTA1ZDgzZmZjYzVjMTg4YTdhOTBlYzg1ZjU0OWM4YzMzZDgyNGI5OGIxOTQwYjZhMzM0NjcyOTk0NDBhNWMaEFszLzNdIFJVTiBleGl0IDMqDAiN6eXVBhDh+uvhATIMCI3p5dUGEN2czPYCOkdwcm9jZXNzICIvYmluL3NoIC1jIGV4aXQgMyIgZGlkIG5vdCBjb21wbGV0ZSBzdWNjZXNzZnVsbHk6IGV4aXQgY29kZTogMw== +].freeze diff --git a/test/docker_engine_ruby/models/progress_stream_test.rb b/test/docker_engine_ruby/models/progress_stream_test.rb new file mode 100644 index 0000000..7f4d138 --- /dev/null +++ b/test/docker_engine_ruby/models/progress_stream_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +class DockerEngineRuby::Test::ProgressStreamModelTest < Minitest::Test + extend Minitest::Serial + include WebMock::API + + def before_all + super + WebMock.enable! + end + + def teardown + WebMock.reset! + super + end + + def after_all + WebMock.disable! + super + end + + def test_build_yields_each_progress_message + stub_stream( + :post, + "http://localhost/build", + [ + {stream: "Step 1/1 : FROM alpine"}, + {aux: {ID: "sha256:abc"}}, + {stream: "Successfully built abc\n"} + ] + ) + messages = [] + + client.images.build(body: "tar") { messages << _1 } + + assert_equal(["Step 1/1 : FROM alpine", nil, "Successfully built abc\n"], messages.map(&:stream)) + assert_equal("sha256:abc", messages[1].aux.id) + end + + def test_build_raises_on_error_inside_the_stream + stub_stream( + :post, + "http://localhost/build", + [ + {stream: "Step 2/2 : RUN exit 3"}, + {error: "exit code: 3", errorDetail: {code: 3, message: "exit code: 3"}} + ] + ) + + error = assert_raises(DockerEngineRuby::Errors::StreamError) { client.images.build(body: "tar") } + + assert_equal("exit code: 3", error.message) + assert_equal(3, error.detail&.code) + end + + def test_pull_without_block_reads_the_stream_to_the_end + stub_stream( + :post, + "http://localhost/images/create?fromImage=alpine", + [ + {status: "Pulling from library/alpine", id: "3.20"}, + {status: "Status: Image is up to date for alpine:3.20"} + ] + ) + + assert_nil(client.images.pull(body: "", from_image: "alpine")) + end + + def test_aux_is_read_as_the_variant_that_fits + trace = DockerEngineRuby::Internal::Type::Converter.coerce(DockerEngineRuby::BuildInfo, {aux: "c3RhdHVz"}) + image = DockerEngineRuby::Internal::Type::Converter.coerce(DockerEngineRuby::BuildInfo, {aux: {ID: "sha256:abc"}}) + + assert_equal("c3RhdHVz", trace.aux) + assert_equal("sha256:abc", image.aux.id) + end + + private + + def client = DockerEngineRuby::Client.new(base_url: "http://localhost", max_retries: 0) + + def stub_stream(method, url, messages) + body = messages.map { "#{JSON.generate(_1)}\r\n" }.join + stub_request(method, url).to_return( + status: 200, + headers: {"Content-Type" => "application/json"}, + body: body + ) + end +end