diff --git a/.chamber.pub.pem b/.chamber.pub.pem new file mode 100644 index 0000000..d19def7 --- /dev/null +++ b/.chamber.pub.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4Gg5nTM/XOIuivTcgfKH +w+wMHnP6bGKEPNxH68FBfIH5p8cShuCRaKDJw9q/tXwd5C0wRVNXuRnqSu/Rj977 +Z4SnjmAsW9rYWbT6mDsAzCcurY21QjRd37oIddLBWApYv/8gCnut3mN3X4V88HOF +tVthj1x36ACPse5DnmLxnmPZRgtuIrWICpzVrlNUSgTDbo8u/KjRPlTiko8n7gDy +tOA7mVfOG1YUZA4n8FBzeINSUup4b0knpAZKkibamra7fqWxzVDOan++aqx+aubW +IScgTj/rbYMG2IMAF0hkEDLUiP860znBerbVXcCAdA0kUWAdAw12BsbT/51/tmvH +VwIDAQAB +-----END PUBLIC KEY----- diff --git a/.gitignore b/.gitignore index 5ded32f..cfe29f9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ .rbenv-gemsets bin o-rdoc + +# Private and protected key files for Chamber +.chamber.pem +.chamber.pem.enc diff --git a/bin/setup-and-test.sh b/bin/setup-and-test.sh index 0ac4542..ac3dff8 100644 --- a/bin/setup-and-test.sh +++ b/bin/setup-and-test.sh @@ -6,6 +6,8 @@ das_setup() { gem install excon -v 0.57.1 gem install awesome_print -v 1.8.0 + gem install chamber -v 2.10.1 + gem install prolog-dry_types -v 0.3.4 gem install semantic_logger -v 4.1.1 gem install terminal-table -v 1.8.0 gem install thor -v 0.19.4 diff --git a/do_api_scripting.gemspec b/do_api_scripting.gemspec index ea7d753..5712836 100644 --- a/do_api_scripting.gemspec +++ b/do_api_scripting.gemspec @@ -30,14 +30,17 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] + spec.add_dependency "chamber", '2.10.1' spec.add_dependency "dry-struct", '0.3.1' spec.add_dependency "excon", "0.57.1" + spec.add_dependency "prolog-dry_types", '0.3.4' spec.add_dependency "semantic_logger", '4.1.1' + spec.add_dependency "terminal-table", '1.8.0' spec.add_dependency 'thor', '0.19.4' - spec.add_development_dependency "bundler", "1.15.1" + spec.add_development_dependency "bundler", "1.15.3" spec.add_development_dependency "rake", "12.0.0" - spec.add_development_dependency "minitest", "5.10.2" + spec.add_development_dependency "minitest", "5.10.3" spec.add_development_dependency "minitest-matchers", '1.4.1' spec.add_development_dependency "minitest-reporters", '1.1.14' diff --git a/exe/settings.yml b/exe/settings.yml new file mode 100644 index 0000000..e7c7183 --- /dev/null +++ b/exe/settings.yml @@ -0,0 +1,33 @@ +# 2017/07/25 +# ---------- +# +# To decrypt and use this data, you *must* first decrypt the .chamber.pem.enc +# file for this Git repository using the "passphrase" you have been given +# securely out-of-band (which was generated by the command sequence +# `chamber secure -f ./exe/settings.yml`). To decrypt the .pem.enc file for use +# with Chamber, run: +# +# $ cp /path/to/{.chamber.pem.enc,.chamber.pem} +# $ ssh-keygen -p -f /path/to/.chamber.pem +# +# Enter the passphrase when prompted and leave the new passphrase blank. +# +# If you want to change the value of the stored API token, set the value for +# `_secure_api_token` to your new desired value and then run `chamber secure` as +# described above. Once secured, this file is safe for committal to a Git public +# repository. **DO NOT** commit `.chamber.pem`, and it's properly paranoid to +# only exchange the putatively-secure `.chamber.pem.enc` file securely. The +# `chamber.pub.pem` file is safe for committal. +# +# As a reminder, to override this value from the command line, run +# +# $ DO_API_TOKEN=your-own-token do_api ... +# +# (or `DO_API_TOKEN=your-own-token bundle exec do_api ...` in developer mode). +# You **MUST** use the explicit environment variable if you do not have the +# `.chamber.pem` file! +# + +do: + _secure_api_token: NdJMO7AzqhP3kADCxeChf8I7lk0bLYg/lwHw/F7v8QrdPlyLfXWAUz4hhDFiXt5dHqVUA50nrsEBxOqjNQE71O0hHkexI/j9wbqlWfbbgQoPoilO4CzzHkyj71sqXxyav/QLjn3B0lf43/SD3JUE3QnpbzsskYrdC9d5v7jGzFbk3MshpRZ94229NwC6eXLcZaTQEEPqz12reW96duJHmo9DvikB3IFgrmLURlrjVNg2abYpu3KooHbHMxEW/c+7fuwYnXnBrSentuehytPTY6z9g7SWmXikJNy1K56BF55m7YrWK9gsZC2l7zflBr8DIDf7GKPKviaELLQF+nBxAw== + # _secure_api_token: This is a dummy value. Override with the DO_API_TOKEN environment variable if you have no .chamber.pem file. diff --git a/lib/do_api_scripting/api/all_droplets.rb b/lib/do_api_scripting/api/all_droplets.rb new file mode 100644 index 0000000..7869501 --- /dev/null +++ b/lib/do_api_scripting/api/all_droplets.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'excon' +require 'prolog/dry_types' + +require_relative './droplet_list' +require_relative './all_droplets/data_request' +require_relative './all_droplets/stubs' + +# Code to support scripting the DigitalOcean API, e.g., for use with Ansible. +module DoApiScripting + module API + # Class uses DO API to retrieve list of all Droplets owned by user + class AllDroplets + def self.call(request_module: DEFAULT_REQUEST_MODULE) + new(request_module).call + end + + def call + return_obj + end + + protected + + def initialize(request_module) + @request_module = request_module + self + end + + private + + attr_reader :request_module + + DEFAULT_REQUEST_MODULE = API::AllDroplets::DataRequest::NonStubs + private_constant :DEFAULT_REQUEST_MODULE + + DUMMY_DATA = { + meta: { total: 3 }, + links: {}, + droplets: [ + { + created_at: '2017-06-21T08:20:42Z', + id: '52434906', + name: 'suirdemo1-test', + networks: { + v4: [ + { type: 'private', ip_address: '10.130.10.113' }, + { type: 'public', ip_address: '128.199.105.180' } + ], + v6: [] + }, + region: { name: 'Singapore 1', slug: 'sgp1' }, + size_slug: '512mb', + status: 'active' + }, + { + created_at: '2017-06-22T10:07:34Z', + id: '52569259', + name: 'suirdemo2', + networks: { + v4: [ + { type: 'private', ip_address: '10.130.19.125' }, + { type: 'public', ip_address: '128.199.73.100' } + ], + v6: [] + }, + region: { name: 'Singapore 1', slug: 'sgp1' }, + size_slug: '512mb', + status: 'active' + } + ] + }.freeze + private_constant :DUMMY_DATA + + # Reek says this is a :reek:UtilityFunction. + def droplet_data + data = DataRequest.get(request_module: request_module) + droplets = data.body[:droplets].map do |datum| + droplet_from_datum(datum) + end + { droplets: droplets, status: data.status } + end + + # Reek sees this as a :reek:UtilityFunction. We'll get around to it. + def droplet_from_datum(datum) + intermediate = datum.reject do |attrib, _| + %i[networks region].include?(attrib) + end + intermediate[:public_ip] = datum.dig(:networks, :v4, 1, :ip_address) + intermediate[:region_name] = datum.dig(:region, :name) + DropletInfo.new intermediate + end + + def return_obj + DropletList.new droplet_data.to_h + end + end # class DoApiScripting::API::AllDroplets + end +end diff --git a/lib/do_api_scripting/api/all_droplets/data_request.rb b/lib/do_api_scripting/api/all_droplets/data_request.rb new file mode 100644 index 0000000..a9e50a7 --- /dev/null +++ b/lib/do_api_scripting/api/all_droplets/data_request.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'chamber' +require 'excon' +require 'json' + +require_relative './non_stubs' + +# This **must** be done before any Chamber environment is accessed. Repeated +# calls to `Chamber.load` apparently (and logically) are harmless, only adding a +# performance penalty in the event of large Chamber config files being parsed. + +Chamber.load basepath: "#{ENV['PWD']}/exe" + +# Code to support scripting the DigitalOcean API, e.g., for use with Ansible. +module DoApiScripting + module API + # Class uses DO API to retrieve list of all Droplets owned by user + class AllDroplets + # Class encapsulating sending request to/receiving response from DO API. + class DataRequest + def self.get(auth_header: :default, request_module: NonStubs) + new(auth_header, request_module).get + end + + def get + Struct.new(:status, :body).new response.status, body + end + + protected + + def initialize(auth_header_in, stub_module) + @stubs = stub_module + @auth_header = auth_header_in + @auth_header = DEFAULT_AUTH_HEADER if auth_header_in == :default + @response = nil + self + end + + private + + attr_reader :auth_header, :stubs + + DEFAULT_AUTH_HEADER = "Bearer #{Chamber.env.do.api_token}" + private_constant :DEFAULT_AUTH_HEADER + + URL = 'https://api.digitalocean.com/v2/droplets' + private_constant :URL + + def body + JSON.parse(response.body, symbolize_names: true) + end + + def headers + { + 'Content-Type': 'application/json', + 'Authorization': auth_header + } + end + + def response + @response ||= stubs.request(headers: headers, url: URL) + end + end # class DoApiScripting::API::AllDroplets::DataRequest + end # class DoApiScripting::API::AllDroplets + end +end diff --git a/lib/do_api_scripting/api/all_droplets/non_stubs.rb b/lib/do_api_scripting/api/all_droplets/non_stubs.rb new file mode 100644 index 0000000..9d96880 --- /dev/null +++ b/lib/do_api_scripting/api/all_droplets/non_stubs.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'excon' + +# Code to support scripting the DigitalOcean API, e.g., for use with Ansible. +module DoApiScripting + module API + # Class uses DO API to retrieve list of all Droplets owned by user + class AllDroplets + # Class encapsulating sending request to/receiving response from DO API. + class DataRequest + # Make the API request without applying stubs. + module NonStubs + def self.request(headers:, url:) + Excon.get(url, headers: headers) + end + end + end # class DoApiScripting::API::AllDroplets::DataRequest + end # class DoApiScripting::API::AllDroplets + end +end diff --git a/lib/do_api_scripting/api/all_droplets/stubs.rb b/lib/do_api_scripting/api/all_droplets/stubs.rb new file mode 100644 index 0000000..445cea1 --- /dev/null +++ b/lib/do_api_scripting/api/all_droplets/stubs.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'excon' + +# Code to support scripting the DigitalOcean API, e.g., for use with Ansible. +module DoApiScripting + module API + # Class uses DO API to retrieve list of all Droplets owned by user + class AllDroplets + # Class encapsulating sending request to/receiving response from DO API. + class DataRequest + # Stubbing data shouldn't be part of the core logic, should it? :P + module Stubs + def self.request(headers:, url:) + on + resp = Excon.get(url, request_params(headers)) + off + resp + end + + def self.off + Excon.stubs.clear + end + + def self.on + Excon.stub({}, status: 200, body: JSON.dump(DUMMY_DATA)) + end + + def self.request_params(headers) + { mock: true, headers: headers } + end + + DUMMY_DATA = { + meta: { total: 3 }, + links: {}, + droplets: [ + { + created_at: '2017-06-21T08:20:42Z', + id: '52434906', + name: 'suirdemo1-test', + networks: { + v4: [ + { type: 'private', ip_address: '10.130.10.113' }, + { type: 'public', ip_address: '128.199.105.180' } + ], + v6: [] + }, + region: { name: 'Singapore 1', slug: 'sgp1' }, + size_slug: '512mb', + status: 'active' + }, + { + created_at: '2017-06-22T10:07:34Z', + id: '52569259', + name: 'suirdemo2', + networks: { + v4: [ + { type: 'private', ip_address: '10.130.19.125' }, + { type: 'public', ip_address: '128.199.73.100' } + ], + v6: [] + }, + region: { name: 'Singapore 1', slug: 'sgp1' }, + size_slug: '512mb', + status: 'active' + } + ] + }.freeze + private_constant :DUMMY_DATA + end + end # class DoApiScripting::API::AllDroplets::DataRequest + end # class DoApiScripting::API::AllDroplets + end +end diff --git a/lib/do_api_scripting/api/droplet_info.rb b/lib/do_api_scripting/api/droplet_info.rb new file mode 100644 index 0000000..4e0c769 --- /dev/null +++ b/lib/do_api_scripting/api/droplet_info.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'prolog/dry_types' + +# Code to support scripting the DigitalOcean API, e.g., for use with Ansible. +module DoApiScripting + module API + # Attributes of a DO Droplet to be reported on via our API. + class DropletInfo < Dry::Struct::Value + attribute :id, Types::Coercible::Int + attribute :name, Types::Strict::String + attribute :created_at, Types::Json::Time + attribute :public_ip, Types::Strict::String # custom type? + attribute :region_name, Types::Strict::String + attribute :size_slug, Types::Strict::String + attribute :status, Types::Strict::String + end # class DoApiScripting::API::DropletInfo + end +end diff --git a/lib/do_api_scripting/api/droplet_list.rb b/lib/do_api_scripting/api/droplet_list.rb new file mode 100644 index 0000000..b60569d --- /dev/null +++ b/lib/do_api_scripting/api/droplet_list.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'prolog/dry_types' + +require_relative './droplet_info' + +# Code to support scripting the DigitalOcean API, e.g., for use with Ansible. +module DoApiScripting + module API + # Container class returning information about Droplets from API. + class DropletList < Dry::Struct::Value + attribute :droplets, Types::Strict::Array.member(DropletInfo) + attribute :status, Types::Strict::Int + end # class DoApiScripting::API::DropletList + end +end diff --git a/lib/do_api_scripting/cli/all_droplets.rb b/lib/do_api_scripting/cli/all_droplets.rb index 49c2dc4..83badeb 100644 --- a/lib/do_api_scripting/cli/all_droplets.rb +++ b/lib/do_api_scripting/cli/all_droplets.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'thor' +require 'terminal-table' # Code to support scripting the DigitalOcean API, e.g., for use with Ansible. module DoApiScripting @@ -9,7 +10,25 @@ class CLI < Thor desc 'all_droplets', 'Summarise all Droplets owned by the user' def all_droplets - say "Would summarise all Droplets owned by PAT #{ENV['DO_API_TOKEN']}" + # say "Would summarise all Droplets owned by PAT #{ENV['DO_API_TOKEN']}" + say all_droplets_table + end + + private + + DUMMY_DATA_ROWS = [ + %w[ID 56789012 56789023], + %w[Name amazon nile], + %w[Status active active], + ['Created At', '2017-06-21T08:20:42Z', '2017-06-22T10:07:34Z'], + %w[Size 512mb 512mb], + ['Public IP', '128.255.210.199', '128.199.173.42'], + ['Region Name', 'Singapore 4', 'Singapore 4'] + ].freeze + + # Reek calls this out as a :reek:UtilityFunction. Yes, it is. + def all_droplets_table + Terminal::Table.new(rows: DUMMY_DATA_ROWS).to_s end end # class DoApiScripting::CLI end diff --git a/test/do_api_scripting/api/all_droplets/data_request_test.rb b/test/do_api_scripting/api/all_droplets/data_request_test.rb new file mode 100644 index 0000000..d448cc4 --- /dev/null +++ b/test/do_api_scripting/api/all_droplets/data_request_test.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'test_helper' + +require 'do_api_scripting/api/all_droplets/data_request' +require 'do_api_scripting/api/all_droplets/stubs' +require 'do_api_scripting/api/droplet_info' + +# Code to support scripting the DigitalOcean API, e.g., for use with Ansible. +module DoApiScripting + module API + # Class uses DO API to retrieve list of all Droplets owned by user + class AllDroplets + describe 'API::AllDroplets::DataRequest' do + let(:described_class) { DataRequest } + + describe 'has a .get method returning an object that' do + describe 'responds to' do + let(:obj) do + described_class.get(request_module: DataRequest::Stubs) + end + + it ':status, returning a value of 200' do + expect(obj.status).must_equal 200 + end + + it ':body, returning a Hash' do + expect(obj.body.respond_to?(:to_hash)).must_equal true + end + + describe ':body, returning a Hash that' do + let(:droplet_data) { obj.body[:droplets] } + let(:droplets) do + droplet_data.map do |datum| + DropletInfo.new id: datum[:id], name: datum[:name], + created_at: datum[:created_at], + size_slug: datum[:size_slug], + status: datum[:status], + public_ip: datum.dig(:networks, :v4, 1, + :ip_address), + region_name: datum.dig(:region, :name) + end + end + + it 'has a :droplets key for an Array value' do + expect(droplet_data.respond_to?(:to_ary)).must_equal true + end + + it 'has a :droplets key for an Array of Droplet data' do + expect(droplets.respond_to?(:to_ary)).must_equal true + end + end # describe ':body, returning a Hash that' + end # describe 'responds to' + end # describe 'has a .get method returning an object that' + end # describe 'DoApiScripting::API::AllDroplets::DataRequest' + end # class DoApiScripting::API::AllDroplets + end +end diff --git a/test/do_api_scripting/api/all_droplets_test.rb b/test/do_api_scripting/api/all_droplets_test.rb new file mode 100644 index 0000000..c5afa01 --- /dev/null +++ b/test/do_api_scripting/api/all_droplets_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'test_helper' + +require 'do_api_scripting/api/all_droplets' + +# Code to support scripting the DigitalOcean API, e.g., for use with Ansible. +module DoApiScripting + module API + describe 'API::AllDroplets' do + let(:described_class) { API::AllDroplets } + + describe 'has a .call method' do + describe 'that returns an object whose' do + let(:call_result) { described_class.call params } + let(:params) { { request_module: request_module } } + let(:request_module) { API::AllDroplets::DataRequest::Stubs } + + it ':droplets reader returns an Array' do + expect(call_result.droplets.to_ary.length).must_be :>=, 0 + end + + it ':status reader returns a positive integer' do + expect(call_result.status.to_int).must_be :>, 0 + end + end # describe 'that returns an object whose' + end # describe 'has a .call method' + end # describe 'API::AllDroplets' + end +end diff --git a/test/do_api_scripting/cli/all_droplets_test.rb b/test/do_api_scripting/cli/all_droplets_test.rb index c5c7870..ca9396e 100644 --- a/test/do_api_scripting/cli/all_droplets_test.rb +++ b/test/do_api_scripting/cli/all_droplets_test.rb @@ -20,10 +20,52 @@ module DoApiScripting expect(out_streams.err).must_be :empty? end - it 'produces a DUMMIED message containing $DO_API_TOKEN' do - expr = /Would summarise all Droplets owned by PAT ([0-9A-Fa-f]{64})\n/ - actual = out_streams.out.match(expr) - expect(actual[1]).must_equal ENV['DO_API_TOKEN'] + # Support class to match a (single- or multiple-)droplet table. + # This probably should be a custom matcher. + class MatchInfoTable + def self.call(dump_str:) + new(dump_str).call + end + + def call + horizontal_borders? && lines_start_with_border? && lines_have_headers? + end + + protected + + def initialize(dump_str) + @lines = dump_str.lines.map(&:rstrip) + @parts = @lines[1..-2].map { |line| line.split('|').map(&:strip) } + self + end + + private + + attr_reader :lines, :parts + + HEADERS = ['ID', 'Name', 'Status', 'Created At', 'Size', 'Public IP', + 'Region Name'].freeze + private_constant :HEADERS + + def horizontal_borders? + ret = lines.first == lines.last + chars = Set.new(lines.first.split('+').join.chars) + ret && (chars.to_a == ['-']) + end + + def lines_have_headers? + actual = parts.map { |part| part[1] } + actual == HEADERS + end + + def lines_start_with_border? + segments = Set.new parts.map(&:first) + segments.to_a == [''] + end + end # class MatchInfoTable + + it 'produces a table-formatted message describing the droplet' do + expect(MatchInfoTable.call(dump_str: out_streams.out)).must_equal true end end # describe 'with a value defined for ENV["DO_API_TOKEN"]' diff --git a/test/do_api_scripting/integration/api/all_droplets/data_request_integration_test.rb b/test/do_api_scripting/integration/api/all_droplets/data_request_integration_test.rb new file mode 100644 index 0000000..e5cda8a --- /dev/null +++ b/test/do_api_scripting/integration/api/all_droplets/data_request_integration_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'test_helper' + +require 'do_api_scripting/api/all_droplets/data_request' +require 'do_api_scripting/api/droplet_info' +require 'json' + +# Code to support scripting the DigitalOcean API, e.g., for use with Ansible. +module DoApiScripting + module API + describe 'AllDroplets with live requests' do + let(:req_module) { AllDroplets::DataRequest::NonStubs } + let(:resp) { AllDroplets::DataRequest.get(request_module: req_module) } + let(:droplet_data) { resp.body[:droplets] } + let(:droplets) do + droplet_data.map do |datum| + DropletInfo.new id: datum[:id], name: datum[:name], + created_at: datum[:created_at], + size_slug: datum[:size_slug], status: datum[:status], + public_ip: datum.dig(:networks, :v4, 1, :ip_address), + region_name: datum.dig(:region, :name) + end + end + + it 'reports an array of droplet data' do + expect(droplet_data).must_be_instance_of Array + end + + it 'successfully parses each entry in the droplet-data array' do + expect(droplets).must_be_instance_of Array + end + end # describe 'AllDroplets with live requests' + end +end