From 031939ab8e40d9b12af27b45ffa5f56056d8973a Mon Sep 17 00:00:00 2001 From: Markus Bucher Date: Wed, 23 Oct 2024 22:00:47 +0200 Subject: [PATCH] Add connector for Ubuntu Security Api --- Gemfile | 2 + Gemfile.lock | 11 ++++ lib/ubuntu_security_api.rb | 72 ++++++++++++++++++++++ spec/lib/ubuntu_security_api_spec.rb | 91 ++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 lib/ubuntu_security_api.rb create mode 100644 spec/lib/ubuntu_security_api_spec.rb diff --git a/Gemfile b/Gemfile index 463cca8..baed4b1 100644 --- a/Gemfile +++ b/Gemfile @@ -43,3 +43,5 @@ group :development, optional: true do end gem "feedjira", "~> 3.2", group: :monitor + +gem "faraday", "~> 2.12", group: :monitor diff --git a/Gemfile.lock b/Gemfile.lock index cee5a46..66357ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,6 +9,12 @@ GEM crass (1.0.6) diff-lcs (1.5.1) docile (1.4.1) + faraday (2.12.0) + faraday-net_http (>= 2.0, < 3.4) + json + logger + faraday-net_http (3.3.0) + net-http feedjira (3.2.3) loofah (>= 2.3.1, < 3) sax-machine (>= 1.0, < 2) @@ -17,10 +23,13 @@ GEM json-stream (1.0.0) json-streamer (2.1.0) json-stream + logger (1.6.1) loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) method_source (1.1.0) + net-http (0.4.1) + uri nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) parallel (1.20.1) @@ -78,6 +87,7 @@ GEM test-unit (3.6.2) power_assert unicode-display_width (2.6.0) + uri (0.13.1) PLATFORMS x86_64-linux @@ -85,6 +95,7 @@ PLATFORMS DEPENDENCIES byebug (~> 11.1) bzip2-ffi (~> 1.0) + faraday (~> 2.12) feedjira (~> 3.2) json-streamer (~> 2.1) parallel (~> 1.20, < 1.21) diff --git a/lib/ubuntu_security_api.rb b/lib/ubuntu_security_api.rb new file mode 100644 index 0000000..30196b5 --- /dev/null +++ b/lib/ubuntu_security_api.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'faraday' +require 'date' + +# API doc: https://ubuntu.com/security/api/docs +class UbuntuSecurityApi + attr_accessor :retry + + def initialize(opts={}) + @base_url = 'https://ubuntu.com' + @retry = opts[:retry] + end + + def yesterdays_erratum(release=nil) + # ASSUMPTION: among the latest 5 USNs there should be at least one older than one day + res = latest_errata(release, 5).select { |usn| Date.parse(usn['published']) < Date.today } + + raise 'Could not find USN older than one day among the newest 5' if res.empty? + + res.first + end + + def latest_errata(release=nil, limit=nil) + opts = { + order: 'newest', + show_hidden: false + } + opts[:release] = release unless release.nil? + + opts[:limit] = limit unless limit.nil? + + attempt = @retry + begin + res = conn.get('/security/notices.json', opts) + res.body['notices'] + rescue Faraday::ServerError + raise if attempt.nil? || attempt.zero? + + attempt -= 1 + retry + end + end + + def latest_erratum(release=nil) + latest_errata(release, 1).first + end + + private + + def conn + @conn ||= Faraday.new( + url: @base_url, + headers: { 'Content-Type' => 'application/json' } + ) do |builder| + # Sets the Content-Type header to application/json on each request. + # Also, if the request body is a Hash, it will automatically be encoded as JSON. + builder.request :json + + # Parses JSON response bodies. + # If the response body is not valid JSON, it will raise a Faraday::ParsingError. + builder.response :json + + # Raises an error on 4xx and 5xx responses. + builder.response :raise_error + + # Logs requests and responses. + # By default, it only logs the request method and URL, and the request/response headers. + # builder.response :logger + end + end +end diff --git a/spec/lib/ubuntu_security_api_spec.rb b/spec/lib/ubuntu_security_api_spec.rb new file mode 100644 index 0000000..1ada91c --- /dev/null +++ b/spec/lib/ubuntu_security_api_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' +require './lib/ubuntu_security_api' + +RSpec.describe UbuntuSecurityApi do + let(:usa) { described_class.new(retry: 3) } + + context 'when get' do + let(:conn) { instance_double(Faraday::Connection) } + let(:notice) { { 'id' => 'USN-1337-1', 'published' => '2024-10-23T19:45:29.964Z' } } + let(:notices) do + [ + notice, + { 'id' => 'USN-1237-1', 'published' => '2024-10-22T18:45:29.964Z' }, + { 'id' => 'USN-1236-1', 'published' => '2024-10-22T17:45:29.964Z' }, + { 'id' => 'USN-1235-1', 'published' => '2024-10-22T16:45:29.964Z' }, + { 'id' => 'USN-1234-1', 'published' => '2024-10-22T15:45:29.964Z' } + ] + end + let(:response) { instance_double(Faraday::Response) } + + before do + allow(usa).to receive(:conn).and_return(conn) + allow(conn).to receive(:get).and_return(response) + allow(response).to receive(:body).and_return({ 'notices' => notices }) + end + + it 'latest errata' do + expect(usa.latest_errata).to eq(notices) + end + + it 'latest n errata' do + allow(response).to receive(:body).and_return({ 'notices' => notices.append({ 'id' => 'too_old' }) }) + expect(usa.latest_errata(nil, 5)).to eq(notices) + end + + it 'latest release erratua' do + usa.latest_errata('bionic') + expect(conn).to have_received(:get).with('/security/notices.json', + { release: 'bionic', + order: 'newest', + show_hidden: false }) + end + + it 'latest erratum' do + allow(response).to receive(:body).and_return({ 'notices' => notices }) + + expect(usa.latest_erratum).to eq(notice) + end + + it "yesterday's erratum" do + notice_today = { 'id' => 'USN-4711-1', 'published' => '2024-10-24T19:45:29.964Z' } + allow(response).to receive(:body).and_return({ 'notices' => [notice_today, *notices.slice(0, 4)] }) + allow(Date).to receive(:today).and_return(Date.new(2024, 10, 24)) + + expect(usa.yesterdays_erratum).to eq(notice) + end + + it "no yesterday's erratum" do + allow(Date).to receive(:today).and_return(Date.new(2024, 10, 22)) + allow(response).to receive(:body).and_return({ 'notices' => notices }) + + expect { usa.yesterdays_erratum }.to raise_error(RuntimeError) + end + + # rubocop:disable RSpec/ExampleLength + it 'retries on server-error 5XX' do + raise_server_error = 2 + + allow(conn).to receive(:get).thrice do + if raise_server_error.zero? + response + else + raise_server_error -= 1 + raise Faraday::ServerError + end + end + + expect(usa.latest_errata).to eq(notices) + end + # rubocop:enable RSpec/ExampleLength + + it 'fails on client-error 4XX' do + allow(response).to receive(:body).and_return({ 'notices' => [] }) + allow(conn).to receive(:get).and_raise(Faraday::ClientError).once + + expect { usa.latest_errata }.to raise_error(Faraday::ClientError) + end + end +end