diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8f34b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +coverage/ diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..4e1e0d2 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--color diff --git a/Gemfile b/Gemfile index 12bf45d..88ac2a9 100644 --- a/Gemfile +++ b/Gemfile @@ -4,3 +4,14 @@ ruby '2.1.2' gem 'sinatra', require: 'sinatra/base' gem 'sinatra-contrib' gem 'fleet-api', '0.6.1', require: 'fleet' + +group :test do + gem 'rspec' + gem 'its' + gem 'simplecov' +end + +group :development do + gem 'pry', require: false + gem 'pry-nav', require: false +end diff --git a/Gemfile.lock b/Gemfile.lock index 480b115..d750e36 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,9 @@ GEM remote: https://rubygems.org/ specs: backports (3.6.0) + coderay (1.1.0) + diff-lcs (1.2.5) + docile (1.1.5) faraday (0.8.9) multipart-post (~> 1.2.0) faraday_middleware (0.9.0) @@ -9,13 +12,39 @@ GEM fleet-api (0.6.1) faraday (= 0.8.9) faraday_middleware (= 0.9.0) + its (0.2.0) + rspec-core + method_source (0.8.2) multi_json (1.10.1) multipart-post (1.2.0) + pry (0.10.1) + coderay (~> 1.1.0) + method_source (~> 0.8.1) + slop (~> 3.4) + pry-nav (0.2.4) + pry (>= 0.9.10, < 0.11.0) rack (1.5.2) rack-protection (1.5.3) rack rack-test (0.6.2) rack (>= 1.0) + rspec (3.1.0) + rspec-core (~> 3.1.0) + rspec-expectations (~> 3.1.0) + rspec-mocks (~> 3.1.0) + rspec-core (3.1.7) + rspec-support (~> 3.1.0) + rspec-expectations (3.1.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.1.0) + rspec-mocks (3.1.3) + rspec-support (~> 3.1.0) + rspec-support (3.1.2) + simplecov (0.8.2) + docile (~> 1.1.0) + multi_json + simplecov-html (~> 0.8.0) + simplecov-html (0.8.0) sinatra (1.4.5) rack (~> 1.4) rack-protection (~> 1.4) @@ -27,6 +56,7 @@ GEM rack-test sinatra (~> 1.4.0) tilt (~> 1.3) + slop (3.6.0) tilt (1.4.1) PLATFORMS @@ -34,5 +64,10 @@ PLATFORMS DEPENDENCIES fleet-api (= 0.6.1) + its + pry + pry-nav + rspec + simplecov sinatra sinatra-contrib diff --git a/app.rb b/app.rb index 560156d..88f82cd 100644 --- a/app.rb +++ b/app.rb @@ -10,6 +10,7 @@ require 'app/models' require 'app/routes' +require 'app/utils' module FleetAdapter class App < Sinatra::Application diff --git a/app/models/service.rb b/app/models/service.rb index 7cb7cb9..9e9dc4e 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -4,26 +4,31 @@ module FleetAdapter module Models class Service + using FleetAdapter::StringExtensions + attr_accessor :id, :name, :source, :links, :command, :ports, - :expose, :environment, :volumes, :deployment + :expose, :environment, :volumes, :deployment, :prefix attr_reader :status def self.find(id) - new('id' => id).tap(&:refresh) + new(id: id).tap(&:refresh) end def self.create_all(attrs) - attrs.map { |service_attrs| Service.new(service_attrs).tap(&:load) } + attrs.map { |service_attrs| self.create(service_attrs) }.flatten end def self.create(attrs) - new(attrs).tap(&:load) + count = attrs.fetch(:deployment, {}).fetch(:count, 1).to_i + + count.times.map do |i| + new(attrs, i + 1).tap(&:load) + end end - def initialize(attrs) - self.name = attrs[:name] - self.id = attrs[:id] || (name.end_with?(".service") ? name : "#{name}.service") + def initialize(attrs, index=nil) + self.name = attrs[:name].to_s.sanitize self.source = attrs[:source] self.links = attrs[:links] || [] self.command= attrs[:command] @@ -32,6 +37,15 @@ def initialize(attrs) self.environment = attrs[:environment] || [] self.volumes = attrs[:volumes] || [] self.deployment = attrs[:deployment] || {} + self.prefix = self.name + + self.name += "@#{index}" if self.deployment[:count] && self.deployment[:count] != 1 + + if attrs[:id] + self.id = attrs[:id] + else + self.id = "#{name}.service" + end end def load @@ -61,72 +75,77 @@ def refresh end end - def docker_run_string - [ - '/usr/bin/docker run', - '--rm', - "--name #{name}", - link_flags, - port_flags, - expose_flags, - environment_flags, - volume_flags, - source, - command - ].flatten.compact.join(' ').strip - end + private def service_def + { + 'Unit' => unit_block, + 'Service' => service_block, + 'X-Fleet' => fleet_block + } + end + + def unit_block unit_block = {} - if links - dep_services = links.map do |link| - "#{link[:name]}.service" + unless links.empty? + dependencies = links.map do |link| + "#{link[:name].sanitize}.service" end.join(' ') - unit_block['After'] = dep_services - unit_block['Requires'] = dep_services + unit_block['After'] = dependencies + unit_block['Wants'] = dependencies end - docker_rm = "-/usr/bin/docker rm #{name}" - service_block = { + return unit_block + end + + def service_block + docker_rm = "-/usr/bin/docker rm #{prefix}" + service_registration = "/usr/bin/etcdctl set app/#{name.upcase}/#{name.upcase}_SERVICE_HOST ${COREOS_PRIVATE_IPV4}" + + { + # A hack to be able to have two ExecStartPre values + 'EnvironmentFile'=>'/etc/environment', + :ExecStartPre => "#{service_registration}", 'ExecStartPre' => "-/usr/bin/docker pull #{source}", - 'ExecStart' => docker_run_string, + 'ExecStart' => "-/bin/bash -c \"#{docker_run_string}\"", 'ExecStartPost' => docker_rm, - 'ExecStop' => "-/usr/bin/docker kill #{name}", + 'ExecStop' => "-/bin/bash -c \"/usr/bin/etcdctl rm app/#{name.upcase} --recursive && /usr/bin/docker kill #{prefix}\"", 'ExecStopPost' => docker_rm, 'Restart' => 'always', 'RestartSec' => '10', 'TimeoutStartSec' => '5min' } - - { - 'Unit' => unit_block, - 'Service' => service_block - } end - private + def fleet_block + { 'Conflicts' => id.gsub(/@\d\./, "@*.") } + end - def link_flags - return unless links - links.map do |link| - option = '--link ' - option << link[:name] - option << ':' - option << (link[:alias] ? link[:alias] : link[:name]) - option - end + def docker_run_string + [ + '/usr/bin/docker run', + '--rm', + "--name #{name.split('@').first}", + port_flags, + expose_flags, + environment_flags, + link_flags, + volume_flags, + source, + command + ].flatten.compact.join(' ').strip end def port_flags return unless ports + ports.map do |port| option = '-p ' if port[:hostInterface] || port[:hostPort] option << "#{port[:hostInterface]}:" if port[:hostInterface] - option << "#{port[:hostPort]}" if port[:hostport] - option << ':' + option << "#{port[:hostPort]}:" unless port[:hostPort].to_s.empty? end option << "#{port[:containerPort]}" option << '/udp' if port[:protocol] && port[:protocol].upcase == 'UDP' @@ -139,16 +158,64 @@ def expose_flags expose.map { |exposed_port| "--expose #{exposed_port}" } end + def link_flags + return unless links + link_vars = [] + + # add environment variables for linked services for etcd discovery + links.each do |link| + link_alias = link[:alias].upcase if link[:alias] + link_name = link[:name].sanitize.upcase + + min_port = link[:exposed_ports].sort_by { |exposed_port| exposed_port[:containerPort] }.first + + link_vars.push( + { + variable: (link_alias ? "#{link_alias}_SERVICE_HOST" : "#{link_name}_SERVICE_HOST").upcase, + value: "`/usr/bin/etcdctl get app/#{link_name}/#{link_name}_SERVICE_HOST`" + }, + { + variable: (link_alias ? "#{link_alias}_PORT" : "#{link_name}_PORT").upcase, + value: "#{min_port[:protocol]}://`/usr/bin/etcdctl get app/#{link_name}/#{link_name}_SERVICE_HOST`:#{min_port[:hostPort]}" + } + ) + + # Docker-esque container linking variables + link[:exposed_ports].each do |exposed_port| + link_vars.push( + { + variable: (link_alias ? "#{link_alias}_PORT_#{exposed_port[:containerPort]}_#{exposed_port[:protocol]}" : "#{link_name}_PORT_#{exposed_port[:containerPort]}_#{exposed_port[:protocol]}").upcase, + value: "#{exposed_port[:protocol]}://`/usr/bin/etcdctl get app/#{link_name}/#{link_name}_SERVICE_HOST`:#{exposed_port[:hostPort]}" + }, + { + variable: (link_alias ? "#{link_alias}_PORT_#{exposed_port[:containerPort]}_#{exposed_port[:protocol]}_PROTO" : "#{link_name}_PORT_#{exposed_port[:containerPort]}_#{exposed_port[:protocol]}_PROTO").upcase, + value: exposed_port[:protocol] + }, + { + variable: (link_alias ? "#{link_alias}_PORT_#{exposed_port[:containerPort]}_#{exposed_port[:protocol]}_PORT" : "#{link_name}_PORT_#{exposed_port[:containerPort]}_#{exposed_port[:protocol]}_PORT").upcase, + value: exposed_port[:hostPort] + }, + { + variable: (link_alias ? "#{link_alias}_PORT_#{exposed_port[:containerPort]}_#{exposed_port[:protocol]}_ADDR" : "#{link_name}_PORT_#{exposed_port[:containerPort]}_#{exposed_port[:protocol]}_ADDR").upcase, + value: "`/usr/bin/etcdctl get app/#{link_name}/#{link_name}_SERVICE_HOST`" + } + ) + end + end + + link_vars.map { |link| "-e #{link[:variable]}=#{link[:value]}" } + end + def environment_flags return unless environment - environment.map { |env| "-e \"#{env[:variable]}=#{env[:value]}\"" } + environment.map { |env| "-e \'#{env[:variable]}=#{env[:value]}\'" } end def volume_flags return unless volumes volumes.map do |volume| option = '-v ' - option << "#{volume[:hostPath]}:" if volume[:hostPath].present? + option << "#{volume[:hostPath]}:" unless volume[:hostPath] == nil || volume[:hostPath] == '' option << volume[:containerPath] option end diff --git a/app/models/service_sorter.rb b/app/models/service_sorter.rb new file mode 100644 index 0000000..28b6488 --- /dev/null +++ b/app/models/service_sorter.rb @@ -0,0 +1,82 @@ +module FleetAdapter + module Models + class ServiceSorter + require 'set' + + class << self + + def sort(services) + @services = services + @unmarked = Array.new(@services) + @temporary_marked = Set.new + @sorted_services = [] + + until @unmarked.empty? + visit(@unmarked[-1]) + end + + return @sorted_services + end + + private + + def visit(n) + if @temporary_marked.include?(n[:name]) + if get_service_names_for(n[:links]).include?(n[:name]) + raise ArgumentError, "An image can not link to itself: #{n[:name]}" + else + raise ArgumentError, "Circular import between #{n[:name]} and #{@temporary_marked}" + end + end + + if @unmarked.include?(n) + @temporary_marked.add(n[:name]) + @services.each do |service| + if get_service_names_for(service[:links]).include?(n[:name]) + # There should be only one instance of a dependent service + limit_deployment_count(n) + set_link_port_and_protocol(service, n) + visit(service) + end + end + @temporary_marked.delete(n[:name]) + @unmarked.delete(n) + @sorted_services.insert(0, n) + end + end + + def get_service_names_for(links) + links ||= [] + links.map { |link| link[:name] } + end + + def limit_deployment_count(service) + service[:deployment] ||= {} + service[:deployment][:count] = 1 + end + + # Sets the minimum port and its protocol on the service link to a dependency + def set_link_port_and_protocol(service, dependency) + return if service[:links].to_a.empty? + + exposed_ports = ports_and_protocols_for(dependency) + + service[:links].find { |link| link[:name] == dependency[:name] }.merge!({ exposed_ports: exposed_ports }) + end + + # Finds the explicitly exposed ports (:ports and :expose) on the dependency and + # creates a hash of the port and protocol for each + def ports_and_protocols_for(service) + if service[:ports].to_a.empty? + raise ArgumentError, "#{service[:name]} does not have an explicit port binding" + end + + mapped_ports = service[:ports].map do |exposed_port| + exposed_port.merge!({ protocol: 'tcp' }) unless exposed_port.has_key?(:proto) + end + mapped_ports.flatten + end + end + end + end +end diff --git a/app/routes.rb b/app/routes.rb index 5e0d915..37997d8 100644 --- a/app/routes.rb +++ b/app/routes.rb @@ -1,4 +1,7 @@ module FleetAdapter + + API_VERSION = 'v1' + module Routes autoload :Base, 'app/routes/base' autoload :Services, 'app/routes/services' diff --git a/app/routes/base.rb b/app/routes/base.rb index a02e17f..8a9704e 100644 --- a/app/routes/base.rb +++ b/app/routes/base.rb @@ -10,10 +10,18 @@ class Base < Sinatra::Application @payload = symbolize_keys(JSON.parse(request.body.read)) rescue nil end + configure do + set show_exceptions: false + end + error Fleet::NotFound do status 404 end + error ArgumentError do + [422, {'Content-Type' => 'application/json'}, { error: env['sinatra.error'].message }.to_json ] + end + private def symbolize_keys(obj) diff --git a/app/routes/services.rb b/app/routes/services.rb index 417525c..f6c4919 100644 --- a/app/routes/services.rb +++ b/app/routes/services.rb @@ -1,19 +1,20 @@ require 'app/models/service' +require 'app/models/service_sorter' require 'fleet' module FleetAdapter module Routes class Services < Base - post '/services' do - services = Service.create_all(@payload) + post "/#{API_VERSION}/services" do + sorted_services = ServiceSorter.sort(@payload) + services = Service.create_all(sorted_services) services.each(&:start) - status 201 json services.map { |service| { id: service.id } } end - get '/services/:id' do + get "/#{API_VERSION}/services/:id" do service = Service.find(params[:id]) result = { @@ -24,10 +25,10 @@ class Services < Base json result end - put '/services/:id' do + put "/#{API_VERSION}/services/:id" do service = Service.find(params[:id]) - case @payload['desiredState'] + case @payload[:desiredState] when 'started' service.start status 204 @@ -39,7 +40,7 @@ class Services < Base end end - delete '/services/:id' do + delete "/#{API_VERSION}/services/:id" do Service.find(params[:id]).destroy status 204 end diff --git a/app/utils.rb b/app/utils.rb new file mode 100644 index 0000000..41a6000 --- /dev/null +++ b/app/utils.rb @@ -0,0 +1,11 @@ +module FleetAdapter + + module StringExtensions + refine String do + def sanitize + gsub(/[\W_]/, '-').downcase + end + end + end + +end diff --git a/spec/api/services_spec.rb b/spec/api/services_spec.rb new file mode 100644 index 0000000..7401761 --- /dev/null +++ b/spec/api/services_spec.rb @@ -0,0 +1,191 @@ +require_relative '../spec_helper' + +describe FleetAdapter::Routes::Services do + + let(:id) { 'foo@1.service' } + + describe 'POST /services' do + + let(:service_name) { 'myservice' } + + let(:request_body) do + [ + { name: service_name, source: 'foo/bar' } + ].to_json + end + + before do + allow_any_instance_of(Service).to receive(:load) + allow_any_instance_of(Service).to receive(:start) + end + + it 'loads the services' do + expect_any_instance_of(Service).to receive(:load).exactly(:once) + post '/v1/services', request_body + end + + it 'starts the services' do + expect_any_instance_of(Service).to receive(:start).exactly(:once) + post '/v1/services', request_body + end + + it 'returns an array of service IDs' do + expected = [{ id: "#{service_name}.service" }].to_json + + post '/v1/services', request_body + expect(last_response.body).to eq expected + end + + it 'has an application/json Content-Type' do + post '/v1/services', request_body + expect(last_response.headers['Content-Type']).to eq 'application/json' + end + + it 'returns a 201 status' do + post '/v1/services', request_body + expect(last_response.status).to eq 201 + end + + context 'when a dependency has no exposed ports' do + let(:request_body) do + [ + { name: 'service', source: 'foo/bar', links: [{ name: 'dependency' }] }, + { name: 'dependency', source: 'bar/foo' } + ].to_json + end + + it 'returns a 422 status' do + post '/v1/services', request_body + expect(last_response.status).to eq 422 + end + + it 'includes an error message indicating the service should expose a port' do + expected = { error: 'dependency does not have an explicit port binding' }.to_json + post '/v1/services', request_body + expect(last_response.body).to eq expected + end + end + end + + describe 'GET /services/:id' do + + let(:model) { Service.new(id: id) } + let(:status) { 'started' } + + before do + allow(Service).to receive(:find).and_return(model) + allow(model).to receive(:status).and_return(status) + end + + it 'returns the status formatted as JSON' do + expected = { id: model.id, actualState: model.status }.to_json + + get "/v1/services/#{id}" + expect(last_response.body).to eq expected + end + + it 'has an application/json Content-Type' do + get "/v1/services/#{id}" + expect(last_response.headers['Content-Type']).to eq 'application/json' + end + + it 'returns a 200 status' do + get "/v1/services/#{id}" + expect(last_response.status).to eq 200 + end + + context 'when the service cannot be found' do + + before do + allow(Service).to receive(:find).and_raise(Fleet::NotFound.new('Key not found')) + end + + it 'returns a 404 status' do + get "/v1/services/#{id}" + expect(last_response.status).to eq 404 + end + end + + end + + describe 'PUT /services/:id' do + + let(:model) { Service.new(id: id) } + + before do + allow(Service).to receive(:find).and_return(model) + end + + context "when attempting to start" do + let(:request_body) do + { desiredState: 'started' }.to_json + end + + before do + allow(model).to receive(:start).and_return(true) + end + + it 'returns a 204 status' do + put "/v1/services/#{id}", request_body + expect(last_response.status).to eq 204 + end + end + + context "when attempting to stop" do + let(:request_body) do + { desiredState: 'stopped' }.to_json + end + + before do + allow(model).to receive(:stop).and_return(true) + end + + it 'returns a 204 status' do + put "/v1/services/#{id}", request_body + expect(last_response.status).to eq 204 + end + end + + context "when sending some other desired state" do + let(:request_body) do + { desiredState: 'thrashing wildly' }.to_json + end + + before do + allow(model).to receive(:stop).and_return(true) + end + + it 'returns a 400 status' do + put "/v1/services/#{id}", request_body + expect(last_response.status).to eq 400 + end + end + end + + describe 'DELETE /services/:id' do + + let(:model) { Service.new(id: id) } + + before do + allow(Service).to receive(:find).and_return(model) + allow(model).to receive(:destroy) + end + + it 'finds the service with the given id' do + expect(Service).to receive(:find).with(id) + delete "/v1/services/#{id}" + end + + it 'destroys the service' do + expect(model).to receive(:destroy) + delete "/v1/services/#{id}" + end + + it 'returns a 204 status' do + delete "/v1/services/#{id}" + expect(last_response.status).to eq 204 + end + end + +end + diff --git a/spec/models/service_sorter_spec.rb b/spec/models/service_sorter_spec.rb new file mode 100644 index 0000000..86b88b7 --- /dev/null +++ b/spec/models/service_sorter_spec.rb @@ -0,0 +1,87 @@ +require_relative '../spec_helper' + +describe FleetAdapter::Models::ServiceSorter do + + describe '.sort' do + let(:services) { hash_from_fixture('post-services.json') } + subject(:sorted_services) { described_class.sort(services) } + + its(:count) { should eq 2 } + + it 'sorts services to start dependencies first' do + expect(subject.first[:links]).to be_nil + expect(subject.last[:links].count).to be 1 + end + + it 'sets the deployment count to 1 for dependencies' do + expect(subject.first[:deployment][:count]).to eq 1 # was 3 + end + + it 'adds the port and protocol of the dependency to the dependent link hash' do + expect(subject.last[:links].first[:name]).to eq 'DB' + expect(subject.last[:links].first[:alias]).to eq 'DB_1' + expect(subject.last[:links].first[:exposed_ports].first[:hostPort]).to eq 1111 + expect(subject.last[:links].first[:exposed_ports].first[:containerPort]).to eq 3306 + expect(subject.last[:links].first[:exposed_ports].first[:protocol]).to eq 'tcp' + end + + context 'when a container links to itself' do + let(:services) { hash_from_fixture('post-services.json') } + + before do + @db_service = services.find { |service| service[:name] == 'DB' } + @db_service[:links] = [{name: "DB", alias: "DB_1"}] + end + + it 'raises an exception' do + expect{ described_class.sort(services) }.to raise_error(ArgumentError, /image can not link to itself/) + end + + end + + context 'when a container link links to a service that links back to it' do + let(:services) { hash_from_fixture('post-services.json') } + + before do + @db_service = services.find { |service| service[:name] == 'DB' } + @db_service[:links] = [{name: "WP", alias: "wordpress"}] + end + + it 'raises an exception' do + expect{ described_class.sort(services) }.to raise_error(ArgumentError, /Circular import/) + end + + end + end + + describe '.get_service_names_for' do + + let(:links) do + [ + {name: "FOO", alias: "BAR"}, + {name: "BAZ", alias: "QUUX"}, + ] + end + + it 'returns an array of just the link names' do + expect(described_class.send(:get_service_names_for, links)).to contain_exactly("FOO", "BAZ") + end + end + + describe '.ports_and_protocols_for' do + it 'raises an exception if there are no ports' do + service = { ports: [] } + expect{ described_class.send(:ports_and_protocols_for, service) }.to raise_error(ArgumentError, /does not have an explicit port binding/) + end + + it 'responds with mapped ports' do + service = { ports: [{ hostPort: 80, containerPort: 1234, protocol: 'tcp' }] } + expect(described_class.send(:ports_and_protocols_for, service)).to match_array([{ hostPort: 80, containerPort: 1234, protocol: 'tcp' }]) + end + + it 'adds the tcp protocol if no protocol type is defined' do + service = { ports: [{ hostPort: 80, containerPort: 1234 }] } + expect(described_class.send(:ports_and_protocols_for, service)).to match_array([{ hostPort: 80, containerPort: 1234, protocol: 'tcp' }]) + end + end +end diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb new file mode 100644 index 0000000..2252cb6 --- /dev/null +++ b/spec/models/service_spec.rb @@ -0,0 +1,327 @@ +require_relative '../spec_helper' +require_relative '../../app/utils' + +describe FleetAdapter::Models::Service do + using FleetAdapter::StringExtensions + + let(:attrs) do + { + name: 'foo', + source: 'bar', + command: '/bin/bash', + ports: [{ hostPort: 3306, containerPort: 3306 }], + expose: [3306], + environment: [{ variable: 'DB_PASSWORD', value: 'password' }], + volumes: [{ hostPath: '/foo/bar', containerPath: '/bar/baz' }], + links: [{ name: 'db', alias: 'db_1' }], + deployment: { count: 10 } + } + end + + let(:fake_fleet_client) do + double(:fake_fleet_client, + load: true, + start: true, + stop: true, + destroy: true, + status: {} + ) + end + + subject { described_class.new(attrs, 1) } + + describe '.find' do + before do + allow(Fleet).to receive(:new) { fake_fleet_client } + end + + it 'returns a new Service model with the id set' do + expect(described_class.find('asdf').id).to eq 'asdf' + end + + it 'refreshes the status of the Service model' do + expect(described_class.find('asdf').status).to_not be_nil + end + end + + describe '#initialize' do + + context 'when no attrs are specified' do + subject { described_class.new({}, 1) } + + its(:name) { is_expected.to be_empty } + its(:source) { is_expected.to be_nil } + its(:command) { is_expected.to be_nil } + its(:expose) { is_expected.to eq [] } + its(:ports) { is_expected.to eq [] } + its(:environment) { is_expected.to eq [] } + its(:volumes) { is_expected.to eq [] } + its(:links) { is_expected.to eq [] } + its(:deployment) { is_expected.to eq({}) } + end + + context 'when attrs are specified' do + its(:source) { is_expected.to eq attrs[:source] } + its(:command) { is_expected.to eq attrs[:command] } + its(:ports) { is_expected.to eq attrs[:ports] } + its(:environment) { is_expected.to eq attrs[:environment] } + its(:volumes) { is_expected.to eq attrs[:volumes] } + its(:links) { is_expected.to eq attrs[:links] } + its(:deployment) { is_expected.to eq attrs[:deployment] } + end + + context 'when the name has a space' do + subject { described_class.new({ name: 'foo bar', deployment: { count: 1 } }, 1) } + it 'replaces spaces with underscores' do + expect(subject.name).to eq('foo-bar') + end + end + + context 'when index is specified' do + context 'when deployment count is more than 1' do + it 'adds an @ to the name' do + expect(subject.name).to eq('foo@1') + end + end + + context 'when deployment count is not more than 1' do + subject { described_class.new({name: 'foo', deployment: { count: 1 } }, 1) } + it 'name does not include @' do + expect(subject.name).to eq('foo') + end + end + + context 'when there is no id' do + it 'sets the id to name.service' do + expect(subject.id).to eq("#{subject.name}.service") + end + end + end + end + + [:start, :stop, :destroy].each do |method| + describe "##{method}" do + + before do + allow(Fleet).to receive(:new) { fake_fleet_client } + end + + it "sends a #{method} message to the fleet client" do + expect(fake_fleet_client).to receive(method).with(subject.id) + subject.send(method) + end + + it 'returns the result of the fleet call' do + expect(subject.send(method)).to eql true + end + end + end + describe '#load' do + before do + allow(Fleet).to receive(:new) { fake_fleet_client } + subject.stub(:service_def).and_return({}) + end + + it 'sends the id and service_def to the fleet client' do + expect(fake_fleet_client).to receive(:load).with(subject.id, {}) + subject.send(:load) + end + end + + + describe '#refresh' do + before do + allow(Fleet).to receive(:new) { fake_fleet_client } + end + + it 'gets the status of a unit' do + expect(fake_fleet_client).to receive(:status).with(subject.id) + subject.refresh + end + + context 'when the service is active' do + before do + fake_fleet_client.stub(:status).and_return({ active_state: 'active' }) + end + + it 'returns started' do + expect(subject.refresh).to eq('started') + end + end + + context 'when the service is failed' do + before do + fake_fleet_client.stub(:status).and_return({ active_state: 'failed' }) + end + + it 'returns stopped' do + expect(subject.refresh).to eq('stopped') + end + end + + context 'when the service is neither active nor failed' do + before do + fake_fleet_client.stub(:status).and_return({ active_state: 'foo' }) + end + + it 'returns error' do + expect(subject.refresh).to eq('error') + end + end + end + + describe '#docker_run_string' do + before do + subject.links = [] + end + context 'when the service specifies exposed ports' do + it 'generates a docker command with --expose' do + expect(subject.send(:docker_run_string)).to include '--expose 3306' + end + end + + context 'when the service specifies port mappings' do + before do + subject.ports = [{ + hostInterface: '0.0.0.0', + hostPort: '8000', + containerPort: '3000' + }] + end + + it 'generates a docker command with -p' do + expect(subject.send(:docker_run_string)).to include '-p 0.0.0.0:8000:3000' + end + + context 'when the hostPort is empty' do + + before do + subject.ports = [{ + hostInterface: nil, + hostPort: '', + containerPort: '3000' + }] + end + + it 'does not include the colon affixed to the host port info' do + expect(subject.send(:docker_run_string)).to include '-p 3000' + end + end + + context 'when the UDP protocol is specified' do + + before do + subject.ports = [{ + containerPort: '3306', + protocol: 'udp' + }] + end + + it 'generates a docker command with -p with the udp protocol' do + expect(subject.send(:docker_run_string)).to include '-p 3306/udp' + end + end + + context 'when the TCP protocol is specified' do + + before do + subject.ports = [{ + containerPort: '3306', + protocol: 'tcp' + }] + end + + it 'generates a docker command with -p with no protocol' do + expect(subject.send(:docker_run_string)).to include '-p 3306' + end + end + end + + + context 'when the service specifies environment vars' do + it 'generates a docker command with -e' do + expect(subject.send(:docker_run_string)).to include "-e 'DB_PASSWORD=password'" + end + end + + context 'when the service specifies docker links' do + it 'translates docker links to environment variables' do + subject.links = [{ name: 'db', alias: 'db_1', exposed_ports: [{ protocol: 'tcp', containerPort: 3306, hostPort: 3306 }] }] + expect(subject.send(:docker_run_string)).to include '-e DB_1_SERVICE_HOST=`/usr/bin/etcdctl get app/DB/DB_SERVICE_HOST`' + expect(subject.send(:docker_run_string)).to include '-e DB_1_PORT=tcp://`/usr/bin/etcdctl get app/DB/DB_SERVICE_HOST`:3306' + expect(subject.send(:docker_run_string)).to include '-e DB_1_PORT_3306_TCP_PROTO=tcp' + expect(subject.send(:docker_run_string)).to include '-e DB_1_PORT_3306_TCP_ADDR=`/usr/bin/etcdctl get app/DB/DB_SERVICE_HOST`' + expect(subject.send(:docker_run_string)).to include '-e DB_1_PORT_3306_TCP=tcp://`/usr/bin/etcdctl get app/DB/DB_SERVICE_HOST`:3306' + expect(subject.send(:docker_run_string)).to include '-e DB_1_PORT_3306_TCP_PORT=3306' + end + + it 'sanitizes the link names when creating the env vars' do + subject.links = [{ name: 'db_@:.-', alias: 'db_1', exposed_ports: [{ protocol: 'tcp', containerPort: 3306, hostPort: 3306 }] }] + expect(subject.send(:docker_run_string)).to include '-e DB_1_SERVICE_HOST=`/usr/bin/etcdctl get app/DB-----/DB-----_SERVICE_HOST`' + end + end + + context 'when the service specifies volumes' do + it 'generates a docker command with -v' do + expect(subject.send(:docker_run_string)).to include '-v /foo/bar:/bar/baz' + end + + it 'excludes the : if only a container volume is specified' do + subject.volumes.first[:hostPath] = '' + expect(subject.send(:docker_run_string)).to include '-v /bar/baz' + end + end + end + + describe '#service_def' do + before do + subject.links = [{ name: 'db', alias: 'db', exposed_ports: [{ hostPort: 1, containerPort: 2, protocol: 'tcp' }] }] + end + context 'when the service has links' do + it 'assigns dependencies to the unit_block' do + expect(subject.send(:service_def)['Unit']['After']).to eq('db.service') + expect(subject.send(:service_def)['Unit']['Wants']).to eq('db.service') + end + end + + context 'when the service has no links' do + before do + subject.links = [] + end + it 'does not assign dependencies to the unit_block' do + expect(subject.send(:service_def)['Unit']['After']).to be_nil + expect(subject.send(:service_def)['Unit']['Wants']).to be_nil + end + end + + it 'creates a docker rm string command with the service name only' do + expect(subject.send(:service_def)['Service']['ExecStartPost']).to eq('-/usr/bin/docker rm foo') + expect(subject.send(:service_def)['Service']['ExecStopPost']).to eq('-/usr/bin/docker rm foo') + end + + it 'creates a docker kill string command with the service name only' do + expect(subject.send(:service_def)['Service']['ExecStop']).to eq('-/bin/bash -c "/usr/bin/etcdctl rm app/FOO@1 --recursive && /usr/bin/docker kill foo"') + end + + it 'adds an X-Fleet block to the unit file' do + expect(subject.send(:service_def).keys).to include 'X-Fleet' + end + + context 'when the service is scaled' do + it 'adds a wildcard Conflicts rule to the X-Fleet block' do + expect(subject.send(:service_def)['X-Fleet']['Conflicts']).to eq('foo@*.service') + end + end + + context 'when the service is not scaled' do + before do + subject.id = 'foo.service' + subject.deployment[:count] = 1 + end + + it 'adds a non-wildcard Conflicts rule to the X-Fleet block' do + expect(subject.send(:service_def)['X-Fleet']['Conflicts']).to eq('foo.service') + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..955bd4a --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,30 @@ +require 'its' +require 'rack/test' +require 'simplecov' +SimpleCov.start + +require File.expand_path '../../app.rb', __FILE__ + +ENV['RACK_ENV'] = 'test' + +module ApiType + include Rack::Test::Methods + + def app + FleetAdapter::App + end +end + +RSpec.configure do |c| + c.include ApiType, type: :api, file_path: %r(spec/api) +end + +def fixture_data(filename, path='support/fixtures') + filename += '.json' if File.extname(filename).empty? + file_path = File.expand_path(File.join(path, filename), __dir__) + File.read(file_path).gsub(/\s+/, '') +end + +def hash_from_fixture(filename, path='support/fixtures') + JSON.parse(fixture_data(filename, path), :symbolize_names => true) +end diff --git a/spec/support/fixtures/post-services.json b/spec/support/fixtures/post-services.json new file mode 100644 index 0000000..012f255 --- /dev/null +++ b/spec/support/fixtures/post-services.json @@ -0,0 +1,22 @@ +[ + { + "name": "WP", + "source": "centurylink/wordpress:3.9.1", + "links": [{"name": "DB", "alias": "DB_1"}], + "ports": [ {"hostPort": 8000, "containerPort": 80}], + "environment": [ + {"variable": "DB_PASSWORD", "value": "pass@word01" }, + {"variable": "DB_NAME", "value": "wordpress" } + ], + "deployment": { "count": 3 } + }, + { + "name": "DB", + "source": "centurylink/mysql:5.5", + "environment": [ + {"variable": "MYSQL_ROOT_PASSWORD", "value": "pass@word01"} + ], + "ports": [ {"hostPort": 1111, "containerPort": 3306} ], + "deployment": { "count": 3 } + } +] diff --git a/spec/utils_spec.rb b/spec/utils_spec.rb new file mode 100644 index 0000000..6ed9e55 --- /dev/null +++ b/spec/utils_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe FleetAdapter::StringExtensions do + + using described_class + + it 'adds a #sanitize method to strings' do + expect { String.new.sanitize }.to_not raise_error + end + + describe '#sanitize' do + + subject { 'FOO-1_1.2@3' } + + it 'properly sanitizes the string for Fleet' do + expect(subject.sanitize).to eq 'foo-1-1-2-3' + end + end +end