From f52be4dc7c034949d5cb449e5156cad43ebd1dc9 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 24 Apr 2025 15:32:43 -0700 Subject: [PATCH 1/4] Buildfix: pin rspec-its; simplify podman install (resolves #597) - Pins rspec-its to 1.x - Removes unnecessary setup steps for Ubuntu 20.10+ from ./scripts/install_podman.sh --- docker-api.gemspec | 2 +- script/install_podman.sh | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/docker-api.gemspec b/docker-api.gemspec index 1a131c76..3d652824 100644 --- a/docker-api.gemspec +++ b/docker-api.gemspec @@ -16,7 +16,7 @@ Gem::Specification.new do |gem| gem.add_dependency 'multi_json' gem.add_development_dependency 'rake' gem.add_development_dependency 'rspec', '~> 3.0' - gem.add_development_dependency 'rspec-its' + gem.add_development_dependency 'rspec-its', '~> 1' gem.add_development_dependency 'pry' gem.add_development_dependency 'single_cov' gem.add_development_dependency 'webmock' diff --git a/script/install_podman.sh b/script/install_podman.sh index f5d48789..1e6d8543 100755 --- a/script/install_podman.sh +++ b/script/install_podman.sh @@ -1,12 +1,5 @@ #!/bin/sh set -ex -. /etc/os-release - -curl -L https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/Release.key | sudo apt-key add - - -echo "deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/ /" > /etc/apt/sources.list.d/podman.list - apt-get update - apt-get install -y podman From 1462fc360da57ac02414efad4c98953b26e8d7fe Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 24 Apr 2025 15:13:08 -0700 Subject: [PATCH 2/4] Send :read_timeout when streaming events (#584) - Corrects a bug in `Docker::Connection.compile_request_params` that incorrectly stripped out `read_timeout` when it was nil. - Sets `Docker::Event.stream` to skip timeouts by default. - Sets `Docker::Event.stream` to avoid automatically retrying timeout-related events by default. - Adds timeout-related event streaming tests. A particularly slow (60s+) test can be enabled by setting `RUN_SLOW_TESTS=1` in the testing environment. --- lib/docker/connection.rb | 32 ++++++++++++++++---------------- lib/docker/event.rb | 18 ++++++++++++++---- spec/docker/event_spec.rb | 38 ++++++++++++++++++++++++++++++++++++++ spec/spec_helper.rb | 4 ++++ 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/lib/docker/connection.rb b/lib/docker/connection.rb index d678d243..7894f3ca 100644 --- a/lib/docker/connection.rb +++ b/lib/docker/connection.rb @@ -137,24 +137,24 @@ def version end private - # Given an HTTP method, path, optional query, extra options, and block, - # compiles a request. + def compile_request_params(http_method, path, query = nil, opts = nil, &block) - query ||= {} opts ||= {} - headers = opts.delete(:headers) || {} - content_type = opts[:body].nil? ? 'text/plain' : 'application/json' - user_agent = "Swipely/Docker-API #{Docker::VERSION}" + query ||= opts.delete(:query) || {} + + default_headers = { + 'Content-Type' => opts[:body].nil? ? 'text/plain' : 'application/json', + 'User-Agent' => "Swipely/Docker-API #{Docker::VERSION}", + } + headers = default_headers.merge(opts.delete(:headers) || {}) + { - :method => http_method, - :path => path, - :query => query, - :headers => { 'Content-Type' => content_type, - 'User-Agent' => user_agent, - }.merge(headers), - :expects => (200..204).to_a << 301 << 304, - :idempotent => http_method == :get, - :request_block => block, - }.merge(opts).reject { |_, v| v.nil? } + method: http_method, + path: path, + headers:, + query:, + expects: (200..204).to_a << 301 << 304, + idempotent: http_method == :get, + }.merge(opts).tap { |params| params[:request_block] = block if block } end end diff --git a/lib/docker/event.rb b/lib/docker/event.rb index 4945bca4..2d2cb41f 100644 --- a/lib/docker/event.rb +++ b/lib/docker/event.rb @@ -30,11 +30,21 @@ class << self include Docker::Error def stream(opts = {}, conn = Docker.connection, &block) - conn.get('/events', opts, :response_block => lambda { |b, r, t| - b.each_line do |line| - block.call(new_event(line, r, t)) + # Disable timeouts by default + opts[:read_timeout] = nil unless opts.key? :read_timeout + + # By default, avoid retrying timeout errors. Set opts[:retry_errors] to override this. + opts[:retry_errors] ||= Excon::DEFAULT_RETRY_ERRORS.reject do |cls| + cls == Excon::Error::Timeout + end + + opts[:response_block] = lambda do |chunk, remaining, total| + chunk.each_line do |event_json| + block.call(new_event(event_json, remaining, total)) end - }) + end + + conn.get('/events', opts.delete(:query), opts) end def since(since, opts = {}, conn = Docker.connection, &block) diff --git a/spec/docker/event_spec.rb b/spec/docker/event_spec.rb index 0ba6effe..b8c130a6 100644 --- a/spec/docker/event_spec.rb +++ b/spec/docker/event_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'spec_helper' +require 'securerandom' SingleCov.covered! uncovered: 5 @@ -87,6 +88,43 @@ container.remove end + + context 'with timeouts' do + # @see https://github.com/upserve/docker-api/issues/584 + + it 'does not (seem to) time out by default' do + # @note Excon passes timeout-related arguments directly to IO.select, which in turn makes a system call, + # so it's not possible to mock this (e.g. w/Timecop). + skip_slow_test + expect { Timeout.timeout(65) { stream_events_with_timeout } } + .to raise_error Timeout::Error + end + + it 'times out immediately if read_timeout < Timeout.timeout' do + expect { Timeout.timeout(1) { stream_events_with_timeout(0) } } + .to raise_error Docker::Error::TimeoutError + end + + it 'times out after timeout(1) if read_timeout=2' do + expect { Timeout.timeout(1) { stream_events_with_timeout(2) } } + .to raise_error Timeout::Error + end + + private + + def stream_events_with_timeout(read_timeout = []) + opts = { + # Filter to avoid unexpected Docker events interfering with timeout behavior + query: { filters: { container: [SecureRandom.uuid] }.to_json }, + # Use [] to differentiate between explicit nil and not providing an arg (falling back to the default) + read_timeout:, + }.reject { |_, v| v.empty? rescue false } + + Docker::Event.stream(opts) do |event| + raise "Unexpected event interfered with timeout test: #{event}" + end + end + end end describe ".since" do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8be80cab..117b91b9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -24,6 +24,10 @@ def project_dir end module SpecHelpers + def skip_slow_test + skip "Disabled because ENV['RUN_SLOW_TESTS'] not set" unless ENV['RUN_SLOW_TESTS'] + end + def skip_without_auth skip "Disabled because of missing auth" if ENV['DOCKER_API_USER'] == 'debbie_docker' end From c8c1617a2a856dad400df7588e678dc9f47ff9ff Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Fri, 25 Apr 2025 10:16:56 -0700 Subject: [PATCH 3/4] Bugfix: Support Ruby < 3.1 hash syntax --- lib/docker/connection.rb | 4 ++-- spec/docker/event_spec.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/docker/connection.rb b/lib/docker/connection.rb index 7894f3ca..c41f26e4 100644 --- a/lib/docker/connection.rb +++ b/lib/docker/connection.rb @@ -151,8 +151,8 @@ def compile_request_params(http_method, path, query = nil, opts = nil, &block) { method: http_method, path: path, - headers:, - query:, + headers: headers, + query: query, expects: (200..204).to_a << 301 << 304, idempotent: http_method == :get, }.merge(opts).tap { |params| params[:request_block] = block if block } diff --git a/spec/docker/event_spec.rb b/spec/docker/event_spec.rb index b8c130a6..b289aa71 100644 --- a/spec/docker/event_spec.rb +++ b/spec/docker/event_spec.rb @@ -117,7 +117,7 @@ def stream_events_with_timeout(read_timeout = []) # Filter to avoid unexpected Docker events interfering with timeout behavior query: { filters: { container: [SecureRandom.uuid] }.to_json }, # Use [] to differentiate between explicit nil and not providing an arg (falling back to the default) - read_timeout:, + read_timeout: read_timeout, }.reject { |_, v| v.empty? rescue false } Docker::Event.stream(opts) do |event| From e56ebfac0d25f7b225808af417ee64194979f706 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 23 Apr 2025 10:52:04 -0700 Subject: [PATCH 4/4] ISSUE-593: Conform Docker::Event to Engine spec - Refactors Docker::Event to provide access to its source (JSON) data. - Adds `scope` to the Docker::Event class. --- lib/docker/event.rb | 106 ++++++++++++++++++++------------------ spec/docker/event_spec.rb | 2 + 2 files changed, 59 insertions(+), 49 deletions(-) diff --git a/lib/docker/event.rb b/lib/docker/event.rb index 2d2cb41f..f4b753b4 100644 --- a/lib/docker/event.rb +++ b/lib/docker/event.rb @@ -1,29 +1,28 @@ # frozen_string_literal: true # This class represents a Docker Event. +# @see https://github.com/moby/moby/blob/master/api/types/events/events.go +# @see https://docs.docker.com/reference/api/engine/version/v1.49/#tag/System/operation/SystemEvents class Docker::Event include Docker::Error # Represents the actor object nested within an event class Actor - attr_accessor :ID, :Attributes + attr_reader :info - def initialize(actor_attributes = {}) - [:ID, :Attributes].each do |sym| - value = actor_attributes[sym] - if value.nil? - value = actor_attributes[sym.to_s] - end - send("#{sym}=", value) - end + def initialize(actor_data = {}) + @info = actor_data.transform_keys { |k| k.downcase.to_sym } + end - if self.Attributes.nil? - self.Attributes = {} - end + def id + info[:id] end + alias_method :ID, :id - alias_method :id, :ID - alias_method :attributes, :Attributes + def attributes + info[:attributes] || {} + end + alias_method :Attributes, :attributes end class << self @@ -53,52 +52,61 @@ def since(since, opts = {}, conn = Docker.connection, &block) def new_event(body, remaining, total) return if body.nil? || body.empty? - json = Docker::Util.parse_json(body) - Docker::Event.new(json) + info = Docker::Util.parse_json(body) + Docker::Event.new(info) end end - attr_accessor :Type, :Action, :time, :timeNano - attr_reader :Actor - # Deprecated interface - attr_accessor :status, :from + attr_reader :info - def initialize(event_attributes = {}) - [:Type, :Action, :Actor, :time, :timeNano, :status, :from].each do |sym| - value = event_attributes[sym] - if value.nil? - value = event_attributes[sym.to_s] - end - send("#{sym}=", value) - end + def initialize(event_data = {}) + @info = event_data.transform_keys { |k| k.downcase.to_sym } + end - if @Actor.nil? - value = event_attributes[:id] - if value.nil? - value = event_attributes['id'] - end - self.Actor = Actor.new(ID: value) - end + def action + info[:action] end + alias_method :Action, :action - def ID - self.actor.ID + def actor + @actor = Actor.new(info[:actor] || {}) if !defined? @actor + @actor end + alias_method :Actor, :actor - def Actor=(actor) - return if actor.nil? - if actor.is_a? Actor - @Actor = actor - else - @Actor = Actor.new(actor) - end + def from + # @deprecated Use `actor.attributes['image']` instead + # Only applicable to container events. See Docker docs for details. + info[:from] || actor.attributes['image'] end - alias_method :type, :Type - alias_method :action, :Action - alias_method :actor, :Actor - alias_method :time_nano, :timeNano - alias_method :id, :ID + def id + # @deprecated Use `actor.id` instead + info[:id] || actor.id + end + + def scope + info[:scope] + end + + def status + # @deprecated Use `action` instead + info[:status] || action + end + + def time + info[:time] + end + + def time_nano + info[:timenano] + end + alias_method :timeNano, :time_nano + + def type + info[:type] + end + alias_method :Type, :type def to_s if type.nil? && action.nil? diff --git a/spec/docker/event_spec.rb b/spec/docker/event_spec.rb index b289aa71..da97cc96 100644 --- a/spec/docker/event_spec.rb +++ b/spec/docker/event_spec.rb @@ -19,6 +19,7 @@ 'Type' => 'container', 'from' => 'tianon/true', 'id' => 'bb2c783a32330b726f18d1eb44d80c899ef45771b4f939326e0fefcfc7e05db8', + 'scope' => 'local', 'status' => 'start', 'time' => 1461083270, 'timeNano' => 1461083270652069004 @@ -193,6 +194,7 @@ def stream_events_with_timeout(read_timeout = []) event.actor.id ).to eq('bb2c783a32330b726f18d1eb44d80c899ef45771b4f939326e0fefcfc7e05db8') expect(event.actor.attributes).to eq('image' => 'tianon/true', 'name' => 'true-dat') + expect(event.scope).to eq('local') expect(event.time).to eq 1461083270 expect(event.time_nano).to eq 1461083270652069004 end