├── log └── .keep ├── lib ├── tasks │ └── .keep ├── settings.rb ├── exceptions.rb ├── container_images.rb ├── container_lifecycle.rb ├── cloud_controller_http_client.rb ├── configuration.rb ├── uaa_session.rb ├── request_response_logger.rb └── docker_host_port_allocator.rb ├── app ├── models │ ├── concerns │ │ └── .keep │ ├── catalog.rb │ ├── container_manager.rb │ ├── service.rb │ ├── plan.rb │ ├── credentials.rb │ └── docker_manager.rb ├── controllers │ ├── concerns │ │ └── .keep │ ├── application_controller.rb │ ├── v2 │ │ ├── catalogs_controller.rb │ │ ├── base_controller.rb │ │ ├── service_bindings_controller.rb │ │ └── service_instances_controller.rb │ └── manage │ │ ├── auth_controller.rb │ │ └── instances_controller.rb ├── assets │ ├── stylesheets │ │ ├── application.css.scss │ │ └── cf-containers-broker.css │ └── images │ │ ├── favicon.ico │ │ └── icon-container.png └── views │ ├── errors │ ├── not_authorized.html.erb │ └── approvals_error.html.erb │ ├── layouts │ └── application.html.erb │ └── manage │ └── instances │ └── show.html.erb ├── .rspec ├── bin ├── rake ├── bundle ├── rails ├── fetch_container_images ├── update_all_containers └── run.sh ├── config ├── unicorn.conf.rb ├── boot.rb ├── environment.rb ├── initializers │ ├── wrap_parameters.rb │ ├── secret_token.rb │ └── omniauth.rb ├── routes.rb ├── environments │ ├── development.rb │ ├── test.rb │ ├── assets.rb │ └── production.rb ├── application.rb └── settings.yml ├── config.ru ├── .travis.yml ├── Rakefile ├── Gemfile ├── .dockerignore ├── .gitignore ├── spec ├── lib │ ├── container_images_spec.rb │ ├── cloud_controller_http_client_spec.rb │ ├── configuration_spec.rb │ ├── request_response_logger_spec.rb │ ├── docker_host_port_allocator_spec.rb │ └── uaa_session_spec.rb ├── controllers │ ├── v2 │ │ ├── catalogs_controller_spec.rb │ │ ├── service_bindings_controller_spec.rb │ │ └── service_instances_controller_spec.rb │ └── manage │ │ ├── auth_controller_spec.rb │ │ └── instances_controller_spec.rb ├── spec_helper.rb ├── support │ └── controller_helpers.rb └── models │ ├── catalog_spec.rb │ ├── plan_spec.rb │ ├── container_manager_spec.rb │ ├── service_spec.rb │ └── credentials_spec.rb ├── Dockerfile ├── SYSLOG_DRAIN.md ├── SETTINGS.md ├── CREDENTIALS.md ├── Gemfile.lock ├── DOCKER.md ├── README.md └── LICENSE /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | --profile 4 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css.scss: -------------------------------------------------------------------------------- 1 | @import 'pivotal-styles-full'; 2 | @import 'cf-containers-broker'; 3 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /app/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudfoundry-community/cf-containers-broker/HEAD/app/assets/images/favicon.ico -------------------------------------------------------------------------------- /app/assets/images/icon-container.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudfoundry-community/cf-containers-broker/HEAD/app/assets/images/icon-container.png -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /config/unicorn.conf.rb: -------------------------------------------------------------------------------- 1 | # See http://unicorn.bogomips.org/Unicorn/Configurator.html for complete documentation. 2 | worker_processes 1 3 | listen 80 4 | timeout 120 5 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../../config/application', __FILE__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | 4 | require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE']) 5 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | CfContainersBroker::Application.initialize! 6 | -------------------------------------------------------------------------------- /bin/fetch_container_images: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require File.expand_path('../../config/application', __FILE__) 4 | require 'container_images' 5 | 6 | Rails.logger = Logger.new(STDOUT) 7 | ContainerImages.fetch 8 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | class ApplicationController < ActionController::Base 4 | protect_from_forgery 5 | end 6 | -------------------------------------------------------------------------------- /bin/update_all_containers: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require File.expand_path('../../config/application', __FILE__) 4 | require 'container_lifecycle' 5 | 6 | Rails.logger = Logger.new(STDOUT) 7 | ContainerLifecycle.update_all 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | branches: 4 | only: 5 | - master 6 | 7 | rvm: 8 | - 2.5 9 | 10 | bundler_args: --deployment --without development 11 | 12 | script: bundle exec rspec spec 13 | 14 | sudo: false 15 | 16 | cache: bundler 17 | -------------------------------------------------------------------------------- /app/views/errors/not_authorized.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | Not Authorized: You do not have sufficient permissions for the space containing the requested service instance. 4 |
5 |
6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | CfContainersBroker::Application.load_tasks 7 | -------------------------------------------------------------------------------- /lib/settings.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | ENV['SETTINGS_PATH'] ||= File.expand_path('../../config/settings.yml', __FILE__) 4 | 5 | class Settings < Settingslogic 6 | source ENV['SETTINGS_PATH'] 7 | namespace Rails.env 8 | end 9 | -------------------------------------------------------------------------------- /app/controllers/v2/catalogs_controller.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | class V2::CatalogsController < V2::BaseController 4 | def show 5 | render status: 200, json: { services: Catalog.services.map { |service| service.to_hash } } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Fetch Containers Images 4 | if [[ "${SKIP_FETCHING_IMAGES:-X}" == "X" ]]; then 5 | echo "Fetching Containers Images..." 6 | bin/fetch_container_images 7 | else 8 | echo "Skipping fetching container images." 9 | fi 10 | 11 | # Start CF-Containers-Broker 12 | echo "Starting CF-Containers-Broker..." 13 | $@ 14 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file contains settings for ActionController::ParamsWrapper 4 | 5 | # Enable parameter wrapping for JSON. 6 | # ActiveSupport.on_load(:action_controller) do 7 | # wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 8 | # end 9 | 10 | -------------------------------------------------------------------------------- /lib/exceptions.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | module Exceptions 4 | class ArgumentError < StandardError; end 5 | class BackendError < StandardError; end 6 | class NotFound < StandardError; end 7 | class NotImplemented < StandardError; end 8 | class NotSupported < StandardError; end 9 | end 10 | -------------------------------------------------------------------------------- /lib/container_images.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require Rails.root.join('app/models/catalog') 4 | 5 | module ContainerImages 6 | extend self 7 | 8 | def fetch 9 | Rails.logger.info('Looking for container images at the Services Catalog') 10 | Catalog.plans.each do |plan| 11 | plan.container_manager.fetch_image 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/container_lifecycle.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require Rails.root.join('app/models/catalog') 4 | 5 | module ContainerLifecycle 6 | extend self 7 | 8 | def update_all 9 | Rails.logger.info('Updating all labeled containers') 10 | Catalog.plans.each do |plan| 11 | plan.container_manager.update_all_containers 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/views/errors/approvals_error.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | This application requires the following permissions: 4 | 8 | <%= link_to 'Manage third-party access', Configuration.manage_user_profile_url %> 9 |
10 |
11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '~> 2.5.5' 4 | 5 | gem 'rails', '~> 4' 6 | gem 'rails-api' 7 | gem 'settingslogic' 8 | gem 'omniauth-uaa-oauth2' 9 | gem 'nats' 10 | gem 'sass-rails', '>= 6.0.0' 11 | gem 'docker-api' 12 | gem 'tzinfo-data' 13 | 14 | group :production do 15 | gem 'unicorn' 16 | gem 'lograge' 17 | end 18 | 19 | group :development, :test do 20 | gem 'rspec-rails' 21 | end 22 | 23 | group :development do 24 | gem 'guard-rails' 25 | gem 'shotgun' 26 | end 27 | 28 | group :test do 29 | gem 'webmock' 30 | end 31 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | CfContainersBroker::Application.routes.draw do 2 | namespace :v2 do 3 | resource :catalog, only: [:show] 4 | resources :service_instances, only: [:update, :patch, :destroy] do 5 | resources :service_bindings, only: [:update, :destroy] 6 | end 7 | end 8 | 9 | namespace :manage do 10 | get 'auth/cloudfoundry/callback' => 'auth#create' 11 | get 'auth/failure' => 'auth#failure' 12 | get 'instances/:service_guid/:plan_guid/:instance_guid' => 'instances#show', :as => :instance 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | .DS_Store 7 | .idea 8 | .git/ 9 | 10 | # Ignore bundler config. 11 | /.bundle 12 | 13 | # Ignore the default SQLite database. 14 | /db/*.sqlite3 15 | /db/*.sqlite3-journal 16 | 17 | # Ignore all logfiles and tempfiles. 18 | /log/*.log 19 | /tmp 20 | 21 | # Ignore vendored gems 22 | vendor/bundle/ 23 | vendor/cache/ 24 | public/assets/ 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | .DS_Store 7 | .idea 8 | 9 | # Ignore bundler config. 10 | /.bundle 11 | 12 | # Ignore the default SQLite database. 13 | /db/*.sqlite3 14 | /db/*.sqlite3-journal 15 | 16 | # Ignore all logfiles and tempfiles. 17 | /log/*.log 18 | /tmp 19 | 20 | # Ignore vendored gems 21 | vendor/bundle/ 22 | vendor/cache/ 23 | public/assets/ 24 | 25 | # Ignore developer's settings files 26 | config/settings.*.yml 27 | -------------------------------------------------------------------------------- /app/models/catalog.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require Rails.root.join('lib/settings') 4 | require Rails.root.join('app/models/service') 5 | 6 | class Catalog 7 | class << self 8 | def find_service_by_guid(service_guid) 9 | services.find { |service| service.id == service_guid } 10 | end 11 | 12 | def services 13 | (Settings['services'] || []).map { |attrs| Service.build(attrs) } 14 | end 15 | 16 | def find_plan_by_guid(plan_guid) 17 | plans.find { |plan| plan.id == plan_guid } 18 | end 19 | 20 | def plans 21 | services.map { |service| service.plans }.flatten 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | CfContainersBroker::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send. 17 | # config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger. 20 | config.active_support.deprecation = :log 21 | end 22 | -------------------------------------------------------------------------------- /spec/lib/container_images_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe ContainerImages do 6 | let(:subject) { described_class } 7 | let(:plans) { [plan] } 8 | let(:plan) { double('Plan') } 9 | let(:container_manager) { double('ContainerManager') } 10 | 11 | describe '#fetch' do 12 | it 'fetches the image using the container manager' do 13 | expect(Catalog).to receive(:plans).and_return(plans) 14 | expect(plan).to receive(:container_manager).and_return(container_manager) 15 | expect(container_manager).to receive(:fetch_image) 16 | 17 | subject.fetch 18 | end 19 | 20 | context 'when the catalog is empty' do 21 | it 'does nothing' do 22 | expect(Catalog).to receive(:plans).and_return([]) 23 | 24 | subject.fetch 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure your secret_key_base is kept private 11 | # if you're sharing your code publicly. 12 | 13 | # Although this is not needed for an api-only application, rails4 14 | # requires secret_key_base or secret_token to be defined, otherwise an 15 | # error is raised. 16 | # Using secret_token for rails3 compatibility. Change to secret_key_base 17 | # to avoid deprecation warning. 18 | # Can be safely removed in a rails3 api-only application. 19 | CfContainersBroker::Application.config.secret_key_base = ENV['SECRET_TOKEN'] || 'none' 20 | -------------------------------------------------------------------------------- /lib/cloud_controller_http_client.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | class CloudControllerHttpClient 4 | attr_reader :auth_header 5 | 6 | def initialize(auth_header = nil) 7 | @auth_header = auth_header 8 | end 9 | 10 | def get(path) 11 | uri = cc_uri(path) 12 | http = build_http(uri) 13 | 14 | request = Net::HTTP::Get.new(uri) 15 | request['Authorization'] = auth_header 16 | 17 | response = http.request(request) 18 | 19 | JSON.parse(response.body) 20 | end 21 | 22 | private 23 | 24 | def cc_uri(path) 25 | URI.parse("#{Settings.cc_api_uri.gsub(/\/$/, '')}/#{path.gsub(/^\//, '')}") 26 | end 27 | 28 | def build_http(uri) 29 | http = Net::HTTP.new(uri.hostname, uri.port) 30 | http.use_ssl = uri.scheme == 'https' 31 | http.verify_mode = Settings.skip_ssl_validation ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER 32 | 33 | http 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/configuration.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | module Configuration 4 | extend self 5 | 6 | def documentation_url 7 | Settings.services.first.metadata.documentationUrl rescue nil 8 | end 9 | 10 | def support_url 11 | Settings.services.first.metadata.supportUrl rescue nil 12 | end 13 | 14 | def manage_user_profile_url 15 | "#{auth_server_url}/profile" 16 | end 17 | 18 | def auth_server_url 19 | cc_api_info['authorization_endpoint'] 20 | end 21 | 22 | def token_server_url 23 | cc_api_info['token_endpoint'] 24 | end 25 | 26 | def clear 27 | store.clear 28 | end 29 | 30 | private 31 | 32 | def cc_api_info 33 | return store[:cc_api_info] unless store[:cc_api_info].nil? 34 | 35 | cc_client = CloudControllerHttpClient.new 36 | response = cc_client.get('/info') 37 | 38 | store[:cc_api_info] = response 39 | end 40 | 41 | def store 42 | @store ||= {} 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.3.3 2 | LABEL maintainers Ferran Rodenas , Dr Nic Williams 3 | 4 | # Add application code 5 | ADD . /app 6 | 7 | # Prepare application (cache gems & precompile assets) 8 | RUN cd /app && \ 9 | bundle package --all && \ 10 | RAILS_ENV=assets bundle exec rake assets:precompile && \ 11 | rm -rf spec && \ 12 | mkdir /config 13 | 14 | # Add default configuration files 15 | ADD ./config/unicorn.conf.rb /config/unicorn.conf.rb 16 | ADD ./config/settings.yml /config/settings.yml 17 | 18 | # Working directory 19 | WORKDIR /app 20 | 21 | # Define Rails environment 22 | ENV RAILS_ENV production 23 | 24 | # Define Settings file path 25 | ENV SETTINGS_PATH /config/settings.yml 26 | 27 | # Define Docker Remote API 28 | ENV DOCKER_URL unix:///var/run/docker.sock 29 | 30 | # Command to run 31 | ENTRYPOINT ["/app/bin/run.sh"] 32 | CMD ["bundle", "exec", "unicorn", "-c", "/config/unicorn.conf.rb"] 33 | 34 | # Expose listen port 35 | EXPOSE 80 36 | 37 | # Expose the configuration and logs directories 38 | VOLUME ["/config", "/app/log", "/envdir"] 39 | -------------------------------------------------------------------------------- /app/assets/stylesheets/cf-containers-broker.css: -------------------------------------------------------------------------------- 1 | .no-sidebar-main-wrapper { 2 | margin: 0 20px; 3 | left: 0; 4 | right: 0; 5 | width: auto; 6 | } 7 | 8 | .header { 9 | background-color: #243640; 10 | } 11 | 12 | .float-right { 13 | float: right; 14 | } 15 | 16 | .services-container { 17 | padding: 0 15px; 18 | } 19 | 20 | .header-container { 21 | color: #243640; 22 | font-size: 18px; 23 | margin-top: 10px; 24 | margin-bottom: 20px; 25 | } 26 | 27 | .teal-text { 28 | color: #00a79d; 29 | font-family: Monaco, Menlo, Consolas, "Courier New", monospace; 30 | font-size: 13px; 31 | } 32 | 33 | .panel-text { 34 | font-size: 16px; 35 | } 36 | 37 | .panel-message { 38 | max-width: 600px; 39 | background-color: #f6f6f6; 40 | border: none; 41 | border-bottom: 4px solid rgba(0, 0, 0, 0.07); 42 | min-height: 88px; 43 | -moz-background-clip: padding; 44 | /* Firefox 3.6 */ 45 | -webkit-background-clip: padding; 46 | /* Safari 4? Chrome 6? */ 47 | background-clip: padding-box; 48 | /* Firefox 4, Safari 5, Opera 10, IE 9 */ 49 | } 50 | -------------------------------------------------------------------------------- /app/controllers/manage/auth_controller.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | module Manage 4 | class AuthController < ApplicationController 5 | def create 6 | auth = request.env['omniauth.auth'].to_hash 7 | credentials = auth['credentials'] 8 | 9 | token = credentials['token'] 10 | return render 'errors/approvals_error' if token.nil? || token.empty? 11 | 12 | raw_info = auth['extra']['raw_info'] 13 | return render 'errors/approvals_error' unless raw_info 14 | 15 | session[:uaa_user_id] = auth['extra']['raw_info']['user_id'] 16 | session[:uaa_access_token] = credentials['token'] 17 | session[:uaa_refresh_token] = credentials['refresh_token'] 18 | session[:last_seen] = Time.now 19 | 20 | redirect_to manage_instance_path(session[:service_guid], 21 | session[:plan_guid], 22 | session[:instance_guid]) 23 | end 24 | 25 | def failure 26 | render text: params[:message], status: 403 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/controllers/v2/base_controller.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | class V2::BaseController < ActionController::API 4 | include ActionController::HttpAuthentication::Basic::ControllerMethods 5 | 6 | before_filter :authenticate 7 | before_filter :log_request_headers_and_body 8 | after_filter :log_response_headers_and_body 9 | 10 | protected 11 | 12 | def authenticate 13 | authenticate_or_request_with_http_basic do |username, password| 14 | username == Settings.auth_username && password == Settings.auth_password 15 | end 16 | end 17 | 18 | private 19 | 20 | def log_request_headers_and_body 21 | RequestResponseLogger.new('Request:', logger).log_headers_and_body(request.env, 22 | request.body.read) 23 | end 24 | 25 | def log_response_headers_and_body 26 | RequestResponseLogger.new('Response:', logger).log_headers_and_body(response.headers, 27 | response.body, 28 | true) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /config/initializers/omniauth.rb: -------------------------------------------------------------------------------- 1 | require Rails.root.join('app/models/catalog') 2 | 3 | unless Rails.env.assets? 4 | OmniAuth.config.logger = Rails.logger 5 | OmniAuth.config.failure_raise_out_environments = [] 6 | OmniAuth.config.path_prefix = '/manage/auth' 7 | 8 | DASHBOARD_CLIENT_PROC = lambda do |env| 9 | request = Rack::Request.new(env) 10 | if service = Catalog.find_service_by_guid(request.session[:service_guid]) 11 | env['omniauth.strategy'].options[:client_id] = service.dashboard_client['id'] 12 | env['omniauth.strategy'].options[:client_secret] = service.dashboard_client['secret'] 13 | env['omniauth.strategy'].options[:auth_server_url] = Configuration.auth_server_url 14 | env['omniauth.strategy'].options[:token_server_url] = Configuration.token_server_url 15 | env['omniauth.strategy'].options[:scope] = %w(cloud_controller_service_permissions.read openid) 16 | env['omniauth.strategy'].options[:skip_ssl_validation] = Settings.skip_ssl_validation 17 | else 18 | Rails.logger.error("+-> Request for service_guid '#{request.session[:service_guid]}' unknown") 19 | raise "Request for service_guid '#{request.session[:service_guid]}' unknown" 20 | end 21 | end 22 | 23 | Rails.application.config.middleware.use OmniAuth::Builder do 24 | unless (Rails.env.test? || Rails.env.development?) 25 | provider :cloudfoundry, :setup => DASHBOARD_CLIENT_PROC 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/uaa_session.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | class UaaSession 4 | class << self 5 | def build(access_token, refresh_token, service_guid) 6 | token_info = existing_token_info(access_token) 7 | if token_expired?(token_info) 8 | token_info = refreshed_token_info(refresh_token, service_guid) 9 | end 10 | 11 | new(token_info) 12 | end 13 | 14 | private 15 | 16 | def existing_token_info(access_token) 17 | CF::UAA::TokenInfo.new(access_token: access_token, token_type: 'bearer') 18 | end 19 | 20 | def token_expired?(token_info) 21 | header = token_info.auth_header 22 | expiry = CF::UAA::TokenCoder.decode(header.split[1], verify: false)['exp'] 23 | expiry.is_a?(Integer) && expiry <= Time.now.to_i 24 | end 25 | 26 | def refreshed_token_info(refresh_token, service_guid) 27 | service = Catalog.find_service_by_guid(service_guid) 28 | client = CF::UAA::TokenIssuer.new( 29 | Configuration.auth_server_url, 30 | service.dashboard_client['id'], 31 | service.dashboard_client['secret'], 32 | token_target: Configuration.token_server_url, 33 | skip_ssl_validation: Settings.skip_ssl_validation, 34 | ) 35 | client.refresh_token_grant(refresh_token) 36 | end 37 | end 38 | 39 | def initialize(token_info) 40 | @token_info = token_info 41 | end 42 | 43 | def auth_header 44 | token_info.auth_header 45 | end 46 | 47 | def access_token 48 | token_info.info[:access_token] 49 | end 50 | 51 | private 52 | 53 | attr_reader :token_info 54 | end 55 | -------------------------------------------------------------------------------- /spec/controllers/v2/catalogs_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe V2::CatalogsController do 6 | before do 7 | authenticate 8 | end 9 | 10 | describe '#show' do 11 | let(:make_request) { get :show } 12 | let(:services) { [service_1, service_2] } 13 | let(:service_1) { double( 'Service', to_hash: { 'id' => 'service-1' }) } 14 | let(:service_2) { double( 'Service', to_hash: { 'id' => 'service-2' }) } 15 | 16 | before do 17 | allow(Catalog).to receive(:services).and_return(services) 18 | end 19 | 20 | it_behaves_like 'a controller action that requires basic auth' 21 | 22 | it_behaves_like 'a controller action that logs its request and response headers and body' 23 | 24 | context 'there are services at the catalog' do 25 | it 'builds services from the values in Settings' do 26 | make_request 27 | 28 | expect(response.status).to eq(200) 29 | expect(JSON.parse(response.body)).to eq( 30 | { 31 | 'services' => [ 32 | { 'id' => 'service-1' }, 33 | { 'id' => 'service-2' }, 34 | ] 35 | } 36 | ) 37 | end 38 | end 39 | 40 | context 'with an empty catalog' do 41 | let(:services) { [] } 42 | 43 | context 'when there are no services' do 44 | it 'produces an empty catalog' do 45 | make_request 46 | 47 | expect(response.status).to eq(200) 48 | expect(JSON.parse(response.body)).to eq({ 'services' => []}) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | # Pick the frameworks you want: 4 | #require 'active_record/railtie' 5 | require 'action_controller/railtie' 6 | # require 'action_mailer/railtie' 7 | # require 'sprockets/railtie' 8 | # require 'rails/test_unit/railtie' 9 | 10 | # Require the gems listed in Gemfile, including any gems 11 | # you've limited to :test, :development, or :production. 12 | Bundler.require(:default, Rails.env) 13 | 14 | require File.expand_path('../../lib/settings', __FILE__) 15 | 16 | module CfContainersBroker 17 | class Application < Rails::Application 18 | # Settings in config/environments/* take precedence over those specified here. 19 | # Application configuration should go into files in config/initializers 20 | # -- all .rb files in that directory are automatically loaded. 21 | 22 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 23 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 24 | # config.time_zone = 'Central Time (US & Canada)' 25 | 26 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 27 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 28 | # config.i18n.default_locale = :de 29 | 30 | config.assets.enabled = true 31 | 32 | config.autoload_paths += %W(#{config.root}/lib) 33 | config.autoload_paths += Dir["#{config.root}/lib/**/"] 34 | 35 | config.paths.add 'log', with: Settings.log_path 36 | config.middleware.use Rack::Session::Cookie, secret: Settings.cookie_secret, expire_after: Settings.session_expiry 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | CfContainersBroker::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static asset server for tests with Cache-Control for performance. 16 | config.serve_static_files = true 17 | config.static_cache_control = 'public, max-age=3600' 18 | 19 | # Show full error reports and disable caching. 20 | config.consider_all_requests_local = true 21 | config.action_controller.perform_caching = false 22 | 23 | # Raise exceptions instead of rendering exception templates. 24 | config.action_dispatch.show_exceptions = false 25 | 26 | # Disable request forgery protection in test environment. 27 | config.action_controller.allow_forgery_protection = false 28 | 29 | # Tell Action Mailer not to deliver emails to the real world. 30 | # The :test delivery method accumulates sent emails in the 31 | # ActionMailer::Base.deliveries array. 32 | # config.action_mailer.delivery_method = :test 33 | 34 | # Print deprecation notices to the stderr. 35 | config.active_support.deprecation = :stderr 36 | end 37 | -------------------------------------------------------------------------------- /lib/request_response_logger.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | class RequestResponseLogger 4 | attr_reader :logger 5 | attr_reader :message_type 6 | 7 | def initialize(message_type, logger) 8 | @message_type = message_type 9 | @logger = logger 10 | end 11 | 12 | def log_headers_and_body(headers, body, log_all_headers = false) 13 | headers_to_log = log_all_headers ? headers : remove_non_permitted_headers(headers) 14 | 15 | request_summary = { 16 | headers: filtered_headers(headers_to_log), 17 | body: body 18 | } 19 | 20 | logger.info " #{message_type} #{request_summary.to_json}" 21 | end 22 | 23 | private 24 | 25 | def filtered_headers(headers) 26 | headers.keys.each do |k| 27 | headers[k] = '[PRIVATE DATA HIDDEN]' if filtered_keys.include?(k) 28 | end 29 | 30 | headers 31 | end 32 | 33 | def remove_non_permitted_headers(headers) 34 | headers.select { |key, _| permitted_keys.include? key } 35 | end 36 | 37 | def permitted_keys 38 | %w(CONTENT_LENGTH 39 | CONTENT_TYPE 40 | GATEWAY_INTERFACE 41 | PATH_INFO 42 | QUERY_STRING 43 | REMOTE_ADDR 44 | REMOTE_HOST 45 | REQUEST_METHOD 46 | REQUEST_URI 47 | SCRIPT_NAME 48 | SERVER_NAME 49 | SERVER_PORT 50 | SERVER_PROTOCOL 51 | SERVER_SOFTWARE 52 | HTTP_ACCEPT 53 | HTTP_USER_AGENT 54 | HTTP_AUTHORIZATION 55 | HTTP_X_VCAP_REQUEST_ID 56 | HTTP_X_BROKER_API_VERSION 57 | HTTP_HOST 58 | HTTP_VERSION 59 | REQUEST_PATH) 60 | end 61 | 62 | def filtered_keys 63 | %w(HTTP_AUTHORIZATION) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /app/controllers/v2/service_bindings_controller.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | class V2::ServiceBindingsController < V2::BaseController 4 | def update 5 | binding_guid = params.fetch(:id) 6 | instance_guid = params.fetch(:service_instance_id) 7 | service_guid = params.fetch(:service_id) 8 | plan_guid = params.fetch(:plan_id) 9 | app_guid = params.fetch(:app_guid, "") 10 | parameters = params.fetch(:parameters, {}) || {} 11 | 12 | unless plan = Catalog.find_plan_by_guid(plan_guid) 13 | return render status: 404, json: { 14 | 'description' => "Cannot bind a service. Plan #{plan_guid} was not found in the catalog" 15 | } 16 | end 17 | 18 | begin 19 | if plan.container_manager.find(instance_guid) 20 | response = { 'credentials' => plan.container_manager.service_credentials(instance_guid) } 21 | if syslog_drain_url = plan.container_manager.syslog_drain_url(instance_guid) 22 | response['syslog_drain_url'] = syslog_drain_url 23 | end 24 | render status: 201, json: response 25 | else 26 | render status: 404, json: { 27 | 'description' => "Cannot bind a service. Service Instance #{instance_guid} was not found" 28 | } 29 | end 30 | rescue Exception => e 31 | Rails.logger.info(e.inspect) 32 | Rails.logger.info(e.backtrace.join("\n")) 33 | render status: 500, json: { 'description' => e.message } 34 | end 35 | end 36 | 37 | def destroy 38 | binding_guid = params.fetch(:id) 39 | instance_guid = params.fetch(:service_instance_id) 40 | service_guid = params.fetch(:service_id) 41 | plan_guid = params.fetch(:plan_id) 42 | 43 | render status: 200, json: {} 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | ENV['RAILS_ENV'] ||= 'test' 4 | ENV.delete("EXTERNAL_HOST") 5 | ENV.delete("EXTERNAL_IP") 6 | 7 | require File.expand_path('../../config/environment', __FILE__) 8 | require 'rspec/rails' 9 | require 'webmock/rspec' 10 | require 'docker' 11 | 12 | WebMock.disable_net_connect! 13 | 14 | # Requires supporting ruby files with custom matchers and macros, etc, 15 | # in spec/support/ and its subdirectories. 16 | Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } 17 | 18 | RSpec.configure do |config| 19 | # ## Mock Framework 20 | # 21 | # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line: 22 | # 23 | # config.mock_with :mocha 24 | # config.mock_with :flexmock 25 | # config.mock_with :rr 26 | 27 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 28 | # config.fixture_path = "#{::Rails.root}/spec/fixtures" 29 | 30 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 31 | # examples within a transaction, remove the following line or assign false 32 | # instead of true. 33 | # config.use_transactional_fixtures = true 34 | 35 | # If true, the base class of anonymous controllers will be inferred 36 | # automatically. This will be the default behavior in future versions of 37 | # rspec-rails. 38 | config.infer_base_class_for_anonymous_controllers = true 39 | 40 | config.infer_spec_type_from_file_location! 41 | 42 | # Run specs in random order to surface order dependencies. If you find an 43 | # order dependency and want to debug it, you can fix the order by providing 44 | # the seed, which is printed after each run. 45 | # --seed 1234 46 | config.order = 'random' 47 | end 48 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <% if @dashboard_name %> 5 | <%= @dashboard_name %> 6 | <% else %> 7 | Container Management Dashboard 8 | <% end %> 9 | <%= favicon_link_tag %> 10 | <%= stylesheet_link_tag 'application', media: 'all' %> 11 | 12 | 13 |
14 |
15 |
16 |
17 | CloudFoundry Services 18 |
19 |
20 | <% if Configuration.documentation_url %> 21 | <%= link_to 'Docs', Configuration.documentation_url, class: 'mlxl' %> 22 | <% end %> 23 | <% if Configuration.support_url %> 24 | <%= link_to 'Support', Configuration.support_url, class: 'mlxl' %> 25 | <% end %> 26 |
27 |
28 | 29 |
30 |
31 | <% if @dashboard_image %> 32 | <%= image_tag(@dashboard_image.html_safe, height: '64', width: '64') %> 33 | <% else %> 34 | <%= image_tag 'icon-container.png' %> 35 | <% end %> 36 | 37 | <% if @dashboard_name %> 38 | <%= @dashboard_name %> 39 | <% else %> 40 | Container Management Dashboard 41 | <% end %> 42 | 43 |
44 | 45 | <%= yield %> 46 |
47 |
48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /SYSLOG_DRAIN.md: -------------------------------------------------------------------------------- 1 | # Syslog Drain 2 | 3 | Each service `plan` defined at the [settings](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/SETTINGS.md) 4 | file can have a single syslog drain. If defined, Cloud Foundry would drain events and logs to the service for the 5 | bound applications. 6 | 7 | As defined at the [Application Log Streaming](http://docs.cloudfoundry.org/services/app-log-streaming.html), a `syslog_drain` 8 | permission is required for events and logs to be automatically wired to applications. 9 | 10 | ## Properties format 11 | 12 | Each service `plan` defined at the [settings](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/SETTINGS.md) 13 | file might contain the following properties: 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
FieldRequiredTypeDescription
syslog_drain_portNStringContainer port to be exposed (format: port</protocol>).
syslog_drain_protocolNStringSyslog protocol (syslog, syslog-tls, https).
35 | 36 | ## Example 37 | 38 | This example will create a plan that will provision a [logstash](http://logstash.net/) service container 39 | ([Dockerfile](https://github.com/frodenas/docker-logstash)) and it will expose the syslog drain container port 40 | `514/tcp`. When an application is bound to the service, it will receive a syslog drain URL following this pattern: 41 | `syslog-tls://:#`. Cloud Foundry will automatically drain all the 42 | application events and logs to this URL. 43 | 44 | ```yaml 45 | plans: 46 | - id: '5218782d-7fab-4534-92b8-434204d88c7b' 47 | name: 'free' 48 | container: 49 | backend: 'docker' 50 | image: 'frodenas/logstash' 51 | syslog_drain_port: '514/tcp' 52 | syslog_drain_protocol: 'syslog-tls' 53 | ``` 54 | -------------------------------------------------------------------------------- /lib/docker_host_port_allocator.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require Rails.root.join('lib/exceptions') 4 | require 'docker' 5 | require 'singleton' 6 | 7 | class DockerHostPortAllocator 8 | include Singleton 9 | 10 | attr_reader :allocated_ports, :port_range_start, :port_range_end 11 | 12 | def allocate_host_port(protocol) 13 | # We get used ports the 1st time this instance is invoked, as 14 | # we are assuming no other service beyond this broker is 15 | # going to allocate host ports 16 | unless allocated_ports 17 | set_dynamic_port_range 18 | get_used_ports 19 | end 20 | 21 | (port_range_start..port_range_end).each do |port| 22 | unless allocated_ports[protocol] && allocated_ports[protocol].include?(port) 23 | @allocated_ports[protocol] ||= [] 24 | @allocated_ports[protocol] << port 25 | return port 26 | end 27 | end 28 | 29 | raise Exceptions::BackendError, 'All dynamic ports have been exhausted!' 30 | end 31 | 32 | private 33 | 34 | def set_dynamic_port_range 35 | # Ephemeral port range: http://www.ncftp.com/ncftpd/doc/misc/ephemeral_ports.html 36 | # We assume all nodes in the cluster have the same ephemeral port range 37 | ephemeral_port_range = File.read('/proc/sys/net/ipv4/ip_local_port_range') 38 | port_range = ephemeral_port_range.split() 39 | @port_range_start = port_range.first.to_i 40 | @port_range_end = port_range.last.to_i 41 | rescue 42 | @port_range_start = 32768 43 | @port_range_end = 61000 44 | end 45 | 46 | def get_used_ports 47 | @allocated_ports = {} 48 | containers = Docker::Container.all 49 | containers.each do |container| 50 | ports = container.info.fetch('Ports', {}) 51 | ports.each do |port| 52 | if public_port = port['PublicPort'] 53 | protocol = port['Type'] 54 | @allocated_ports[protocol] ||= [] 55 | @allocated_ports[protocol] << public_port 56 | end 57 | end 58 | end 59 | end 60 | 61 | end 62 | -------------------------------------------------------------------------------- /spec/support/controller_helpers.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | def log_message_matching_type(message_type, log_message) 4 | match_data = /^\s+#{message_type}\s+(.*)/.match(log_message) 5 | if match_data 6 | json_info = match_data[1] 7 | 8 | parsed_data = JSON.parse(json_info) 9 | parsed_data if parsed_data.has_key?('headers') && parsed_data.has_key?('body') 10 | end 11 | end 12 | 13 | def get_logged_message(message_type) 14 | received_log_messages = [] 15 | allow(Rails.logger).to receive(:info) do |log_message| 16 | matching_message = log_message_matching_type(message_type, log_message) 17 | received_log_messages << matching_message unless matching_message.nil? 18 | end 19 | 20 | make_request 21 | 22 | expect(received_log_messages.length).to eq 1 23 | received_log_messages.first 24 | end 25 | 26 | shared_examples_for 'a controller action that logs its request and response headers and body' do 27 | it 'logs the request' do 28 | message = get_logged_message("Request:") 29 | expect(message).to have_key('body') 30 | expect(message['headers']).not_to be_empty 31 | end 32 | 33 | it 'logs the response' do 34 | message = get_logged_message("Response:") 35 | expect(message['body']).not_to be_empty 36 | expect(message['headers']).not_to be_empty 37 | end 38 | end 39 | 40 | shared_examples_for 'a controller action that requires basic auth' do 41 | context 'when the basic-auth username is incorrect' do 42 | before do 43 | set_basic_auth('wrong_username', Settings.auth_password) 44 | end 45 | 46 | it 'responds with a 401' do 47 | make_request 48 | 49 | expect(response.status).to eq(401) 50 | end 51 | end 52 | end 53 | 54 | module ControllerHelpers 55 | extend ActiveSupport::Concern 56 | 57 | def authenticate 58 | set_basic_auth(Settings.auth_username, Settings.auth_password) 59 | end 60 | 61 | def set_basic_auth(username, password) 62 | request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(username, password) 63 | end 64 | end 65 | 66 | RSpec.configure do |config| 67 | config.include ControllerHelpers, type: :controller 68 | end 69 | -------------------------------------------------------------------------------- /spec/lib/cloud_controller_http_client_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe CloudControllerHttpClient do 6 | let(:subject) { described_class.new(auth_header) } 7 | let(:auth_header) { 'auth_header' } 8 | let(:protocol) { 'http' } 9 | let(:cc_api_uri) { "#{protocol}://api.10.0.0.1.xip.io" } 10 | let(:skip_ssl_validation) { true } 11 | let(:net_http) { double('Net::HTTP') } 12 | let(:net_http_get) { double('Net::HTTP::Get') } 13 | let(:response) { double('Response', body: '{}') } 14 | 15 | before do 16 | expect(Net::HTTP).to receive(:new).and_return(net_http) 17 | expect(Net::HTTP::Get).to receive(:new).and_return(net_http_get) 18 | expect(Settings).to receive(:cc_api_uri).and_return(cc_api_uri) 19 | expect(Settings).to receive(:skip_ssl_validation).and_return(skip_ssl_validation) 20 | end 21 | 22 | describe '#get' do 23 | it 'returns the parsed response body' do 24 | expect(net_http).to receive(:use_ssl=).with(false) 25 | expect(net_http).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) 26 | expect(net_http_get).to receive(:[]=).with('Authorization', auth_header) 27 | expect(net_http).to receive(:request).with(net_http_get).and_return(response) 28 | 29 | expect(subject.get('/path/to/endpoint')).to eq(JSON.parse(response.body)) 30 | end 31 | 32 | context 'when the CC uri uses https' do 33 | let(:protocol) { 'https' } 34 | 35 | before do 36 | expect(net_http_get).to receive(:[]=) 37 | expect(net_http).to receive(:request).and_return(response) 38 | end 39 | 40 | it 'sets use_ssl to true' do 41 | expect(net_http).to receive(:use_ssl=).with(true) 42 | expect(net_http).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE) 43 | 44 | subject.get('/path/to/endpoint') 45 | end 46 | 47 | context 'when skip_ssl_validation is false' do 48 | let(:skip_ssl_validation) { false } 49 | 50 | it 'verifies the ssl cert' do 51 | expect(net_http).to receive(:use_ssl=).with(true) 52 | expect(net_http).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_PEER) 53 | 54 | subject.get('/path/to/endpoint') 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /app/models/container_manager.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | class ContainerManager 4 | CONTAINER_PREFIX = 'cf'.freeze 5 | 6 | attr_reader :backend, :credentials, :syslog_drain_port, :syslog_drain_protocol 7 | 8 | def initialize(attrs) 9 | validate_attrs(attrs) 10 | @backend = attrs.fetch('backend') 11 | @credentials = Credentials.new(attrs.fetch('credentials', {})) 12 | @syslog_drain_port = attrs.fetch('syslog_drain_port', nil) 13 | @syslog_drain_protocol = attrs.fetch('syslog_drain_protocol', 'syslog') 14 | end 15 | 16 | def find(guid) 17 | raise Exceptions::NotImplemented, "`find' is not implemented by `#{self.class}'" 18 | end 19 | 20 | def can_allocate?(max_containers, max_plan_containers) 21 | raise Exceptions::NotImplemented, "`can_allocate?' is not implemented by `#{self.class}'" 22 | end 23 | 24 | def create(guid, parameters = {}) 25 | raise Exceptions::NotImplemented, "`create' is not implemented by `#{self.class}'" 26 | end 27 | 28 | def update(guid, parameters = {}) 29 | raise Exceptions::NotImplemented, "`update' is not implemented by `#{self.class}'" 30 | end 31 | 32 | def destroy(guid) 33 | raise Exceptions::NotImplemented, "`destroy' is not implemented by `#{self.class}'" 34 | end 35 | 36 | def fetch_image 37 | raise Exceptions::NotImplemented, "`fetch_image' is not implemented by `#{self.class}'" 38 | end 39 | 40 | def update_all_containers 41 | raise Exceptions::NotImplemented, "`update_all_containers' is not implemented by `#{self.class}'" 42 | end 43 | 44 | def service_credentials(guid) 45 | raise Exceptions::NotImplemented, "`service_credentials' is not implemented by `#{self.class}'" 46 | end 47 | 48 | def syslog_drain_url(guid) 49 | nil 50 | end 51 | 52 | def details(guid) 53 | nil 54 | end 55 | 56 | def processes(guid) 57 | nil 58 | end 59 | 60 | def stdout(guid) 61 | nil 62 | end 63 | 64 | def stderr(guid) 65 | nil 66 | end 67 | 68 | private 69 | 70 | def container_name(guid) 71 | "#{CONTAINER_PREFIX}-#{guid}" 72 | end 73 | 74 | def validate_attrs(attrs) 75 | required_keys = %w(backend) 76 | missing_keys = [] 77 | 78 | required_keys.each do |key| 79 | missing_keys << "#{key}" unless attrs.key?(key) 80 | end 81 | 82 | unless missing_keys.empty? 83 | raise Exceptions::ArgumentError, "Missing Container parameters: #{missing_keys.join(', ')}" 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /app/models/service.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require Rails.root.join('app/models/plan') 4 | 5 | class Service 6 | attr_reader :id, :name, :description, :bindable, :tags, :metadata, :requires, :plans 7 | attr_reader :plan_updateable, :dashboard_client 8 | 9 | def self.build(attrs) 10 | plan_attrs = attrs['plans'] || [] 11 | plans = plan_attrs.map { |attr| Plan.build(attr) } 12 | new(attrs.merge('plans' => plans)) 13 | end 14 | 15 | def initialize(attrs) 16 | validate_attrs(attrs) 17 | 18 | @id = attrs.fetch('id') 19 | @name = attrs.fetch('name') 20 | @description = attrs.fetch('description') 21 | @bindable = attrs.fetch('bindable', true) 22 | @tags = attrs.fetch('tags', []) || [] 23 | @metadata = attrs.fetch('metadata', nil) 24 | @requires = attrs.fetch('requires', []) || [] 25 | @plans = attrs.fetch('plans') 26 | @plan_updateable = attrs.fetch('plan_updateable', true) || true 27 | @dashboard_client = attrs.fetch('dashboard_client', {}) || {} 28 | populate_others 29 | end 30 | 31 | def to_hash 32 | rv = { 33 | 'id' => id, 34 | 'name' => name, 35 | 'description' => description, 36 | 'bindable' => bindable, 37 | 'tags' => tags, 38 | 'metadata' => metadata, 39 | 'requires' => requires, 40 | 'plans' => plans.map(&:to_hash), 41 | 'plan_updateable' => plan_updateable, 42 | } 43 | 44 | # Do not return even a empty map unless we have a value 45 | # (else subway deserialization/serialization will add them it) 46 | unless dashboard_client.empty? 47 | rv['dashboard_client'] = dashboard_client 48 | end 49 | rv 50 | end 51 | 52 | private 53 | 54 | def validate_attrs(attrs) 55 | required_keys = %w(id name description plans) 56 | missing_keys = [] 57 | 58 | required_keys.each do |key| 59 | missing_keys << "#{key}" unless attrs.key?(key) 60 | end 61 | 62 | unless missing_keys.empty? 63 | raise Exceptions::ArgumentError, "Missing Service parameters: #{missing_keys.join(', ')}" 64 | end 65 | end 66 | 67 | def populate_others 68 | base_url = Settings.external_host 69 | protocol = Settings.ssl_enabled ? 'https' : 'http' 70 | unless dashboard_client.empty? 71 | dashboard_client['redirect_uri'] = "#{protocol}://#{base_url}/manage/auth/cloudfoundry/callback" 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/controllers/manage/auth_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe Manage::AuthController do 6 | let(:service_guid) { 'service-guid' } 7 | let(:plan_guid) { 'plan-guid' } 8 | let(:instance_guid) { 'instance-guid' } 9 | 10 | describe '#create' do 11 | let(:auth) { 12 | { 13 | 'extra' => extra, 14 | 'credentials' => credentials, 15 | } 16 | } 17 | let(:credentials) { 18 | { 19 | 'token' => 'access-token', 20 | 'refresh_token' => 'refresh-token', 21 | } 22 | } 23 | let(:extra) { 24 | { 25 | 'raw_info' => { 26 | 'user_id' => 'user-id' 27 | } 28 | } 29 | } 30 | 31 | before do 32 | session[:service_guid] = service_guid 33 | session[:plan_guid] = plan_guid 34 | session[:instance_guid] = instance_guid 35 | request.env['omniauth.auth'] = auth 36 | end 37 | 38 | context 'when access token, refresh token, and user info are present' do 39 | it 'authenticates the user based on the permissions from UAA' do 40 | get :create 41 | expect(response.status).to eql(302) 42 | expect(response).to redirect_to(manage_instance_path(service_guid, plan_guid, instance_guid)) 43 | 44 | expect(session[:uaa_user_id]).to eql('user-id') 45 | expect(session[:uaa_access_token]).to eql('access-token') 46 | expect(session[:uaa_refresh_token]).to eql('refresh-token') 47 | expect(session[:last_seen]).to be_a_kind_of(Time) 48 | end 49 | end 50 | 51 | context 'when omniauth does not yield an access token' do 52 | let(:credentials) { {} } 53 | 54 | it 'renders the approvals error page' do 55 | get :create 56 | 57 | expect(response.status).to eql(200) 58 | expect(response).to render_template 'errors/approvals_error' 59 | end 60 | end 61 | 62 | context 'when omniauth does not yield user info (raw_info)' do 63 | let(:extra) { {} } 64 | 65 | it 'renders the approvals error page' do 66 | get :create 67 | 68 | expect(response.status).to eql(200) 69 | expect(response).to render_template 'errors/approvals_error' 70 | end 71 | end 72 | end 73 | 74 | describe '#failure' do 75 | it 'returns a 403 status code' do 76 | get :failure, message: 'Not allowed' 77 | expect(response.status).to eql(403) 78 | expect(response.body).to eql('Not allowed') 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /app/models/plan.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require Rails.root.join('app/models/container_manager') 4 | require Rails.root.join('app/models/credentials') 5 | 6 | class Plan 7 | attr_reader :id, :name, :description, :metadata, :free, :max_containers, :credentials, 8 | :syslog_drain_port, :syslog_drain_protocol, :container_manager 9 | 10 | def self.build(attrs) 11 | new(attrs) 12 | end 13 | 14 | def initialize(attrs) 15 | validate_attrs(attrs) 16 | 17 | @id = attrs.fetch('id') 18 | @name = attrs.fetch('name') 19 | @description = attrs.fetch('description') 20 | @metadata = attrs.fetch('metadata', nil) 21 | @free = attrs.fetch('free', true) 22 | @max_containers = attrs.fetch('max_containers', nil) 23 | @credentials = attrs.fetch('credentials', {}) 24 | @syslog_drain_port = attrs.fetch('syslog_drain_port', nil) 25 | @syslog_drain_protocol = attrs.fetch('syslog_drain_protocol', 'syslog') 26 | @container_manager = build_container_manager(attrs.fetch('container')) 27 | end 28 | 29 | def to_hash 30 | { 31 | 'id' => id, 32 | 'name' => name, 33 | 'description' => description, 34 | 'metadata' => metadata, 35 | 'free' => free, 36 | } 37 | end 38 | 39 | private 40 | 41 | def validate_attrs(attrs) 42 | required_keys = %w(id name description container) 43 | missing_keys = [] 44 | 45 | required_keys.each do |key| 46 | missing_keys << "#{key}" unless attrs.key?(key) 47 | end 48 | 49 | unless missing_keys.empty? 50 | raise Exceptions::ArgumentError, "Missing Plan parameters: #{missing_keys.join(', ')}" 51 | end 52 | end 53 | 54 | def build_container_manager(attrs) 55 | container_backend = attrs.fetch('backend') 56 | 57 | begin 58 | require Rails.root.join("app/models/#{container_backend}_manager").to_s 59 | rescue LoadError => error 60 | raise Exceptions::NotSupported, "Could not load Container Manager for backend `#{container_backend}'" 61 | end 62 | 63 | container_attrs = attrs.merge('credentials' => credentials, 64 | 'syslog_drain_port' => syslog_drain_port, 65 | 'syslog_drain_protocol' => syslog_drain_protocol, 66 | 'plan_id' => id) 67 | Class.const_get("#{container_backend.capitalize}Manager").new(container_attrs) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/lib/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe Configuration do 6 | let(:subject) { described_class } 7 | let(:cc_client) { double('CloudControllerHttpClient') } 8 | let(:authorization_endpoint) { 'https://login.10.0.0.1.xip.io' } 9 | let(:token_endpoint) { 'https://uaa.10.0.0.1.xip.io' } 10 | let(:cc_info) { 11 | { 12 | 'name' => 'vcap', 13 | 'build' => '2222', 14 | 'support' => 'http://support.cloudfoundry.com', 15 | 'version' => 2, 16 | 'description' => 'Cloud Foundry sponsored by Pivotal', 17 | 'authorization_endpoint' => authorization_endpoint, 18 | 'token_endpoint' => token_endpoint, 19 | 'api_version' => '2.8.0', 20 | 'logging_endpoint' => 'wss://loggregator.10.0.0.1.xip.io', 21 | } 22 | } 23 | 24 | before do 25 | Configuration.clear 26 | allow(CloudControllerHttpClient).to receive(:new).and_return(cc_client) 27 | end 28 | 29 | describe '#documentation_url' do 30 | it 'uses the documentationUrl of the first service in the catalog' do 31 | expect(subject.documentation_url).to eql('https://github.com/frodenas/docker-postgresql') 32 | end 33 | 34 | context 'when the catalog is empty' do 35 | it 'is nil' do 36 | expect(Settings).to receive(:services).and_return([]) 37 | 38 | expect(subject.documentation_url).to be_nil 39 | end 40 | end 41 | end 42 | 43 | describe '#support_url' do 44 | it 'uses the supportUrl of the first service in the catalog' do 45 | expect(subject.support_url).to eql('https://slack.cloudfoundry.org') 46 | end 47 | 48 | context 'when the catalog is empty' do 49 | it 'is nil' do 50 | expect(Settings).to receive(:services).and_return([]) 51 | 52 | expect(subject.support_url).to be_nil 53 | end 54 | end 55 | end 56 | 57 | describe '#manage_user_profile_url' do 58 | it 'uses the cc info endpoint to get the uri for the auth server' do 59 | expect(cc_client).to receive(:get).with('/info').and_return(cc_info) 60 | 61 | expect(subject.manage_user_profile_url).to eql("#{authorization_endpoint}/profile") 62 | end 63 | 64 | end 65 | 66 | describe '#auth_server_url' do 67 | it 'uses the cc info endpoint to get the uri for the auth server' do 68 | expect(cc_client).to receive(:get).with('/info').and_return(cc_info) 69 | 70 | expect(subject.auth_server_url).to eql(authorization_endpoint) 71 | end 72 | end 73 | 74 | describe '#token_server_url' do 75 | it 'uses the cc info endpoint to get the url for the token server' do 76 | expect(cc_client).to receive(:get).with('/info').and_return(cc_info) 77 | 78 | expect(subject.token_server_url).to eql(token_endpoint) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/lib/request_response_logger_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe RequestResponseLogger do 6 | let(:subject) { described_class.new('Message:', rails_logger) } 7 | let(:rails_logger) { double('Rails.logger') } 8 | let(:headers) { 9 | { 10 | 'CONTENT_TYPE' => 'application/json', 11 | 'HTTP_AUTHORIZATION' => 'basic: auth-token', 12 | 'THIS_KEY_SHOULD_NOT_BE_LOGGED' => 'unknown' 13 | } 14 | } 15 | 16 | describe '#log_headers_and_body' do 17 | it 'logs the request headers and body' do 18 | expect(rails_logger).to receive(:info) do |log_message| 19 | json_log_message = log_message.sub(/^\s+Message:\s+/, '') 20 | 21 | request_info = JSON.parse(json_log_message) 22 | expect(request_info['body']).to eq 'body' 23 | expect(request_info['headers']['CONTENT_TYPE']).to eq 'application/json' 24 | end 25 | 26 | subject.log_headers_and_body(headers, 'body') 27 | end 28 | 29 | it 'filters out sensitive data headers' do 30 | expect(rails_logger).to receive(:info) do |log_message| 31 | json_log_message = log_message.sub(/^\s+Message:\s+/, '') 32 | 33 | request_info = JSON.parse(json_log_message) 34 | expect(request_info['headers']['HTTP_AUTHORIZATION']).not_to match 'some-auth-token' 35 | end 36 | 37 | subject.log_headers_and_body(headers, 'body') 38 | end 39 | 40 | it 'does not log unknown headers' do 41 | expect(rails_logger).to receive(:info) do |log_message| 42 | json_log_message = log_message.sub(/^\s+Message:\s+/, '') 43 | 44 | request_info = JSON.parse(json_log_message) 45 | expect(request_info['headers']).not_to have_key('THIS_KEY_SHOULD_NOT_BE_LOGGED') 46 | end 47 | 48 | subject.log_headers_and_body(headers, 'body') 49 | end 50 | 51 | context 'when log_all_headers is true' do 52 | it 'filters out sensitive data headers' do 53 | expect(rails_logger).to receive(:info) do |log_message| 54 | json_log_message = log_message.sub(/^\s+Message:\s+/, '') 55 | 56 | request_info = JSON.parse(json_log_message) 57 | expect(request_info['headers']['HTTP_AUTHORIZATION']).not_to match 'some-auth-token' 58 | end 59 | 60 | subject.log_headers_and_body(headers, 'body', true) 61 | end 62 | 63 | it 'logs unknown headers' do 64 | expect(rails_logger).to receive(:info) do |log_message| 65 | json_log_message = log_message.sub(/^\s+Message:\s+/, '') 66 | 67 | request_info = JSON.parse(json_log_message) 68 | expect(request_info['headers']).to have_key('THIS_KEY_SHOULD_NOT_BE_LOGGED') 69 | end 70 | 71 | subject.log_headers_and_body(headers, 'body', true) 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /config/environments/assets.rb: -------------------------------------------------------------------------------- 1 | CfContainersBroker::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both thread web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application 18 | # Add `rack-cache` to your Gemfile before enabling this. 19 | # For large-scale production use, consider using a caching reverse proxy like nginx, varnish or squid. 20 | # config.action_dispatch.rack_cache = true 21 | 22 | config.serve_static_files = true 23 | config.static_cache_control = 'public, max-age=3600' 24 | config.assets.compile = false 25 | config.assets.digest = true 26 | 27 | # Specifies the header that your server uses for sending files. 28 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for apache 29 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx 30 | 31 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 32 | # config.force_ssl = true 33 | 34 | # Set to :debug to see everything in the log. 35 | config.log_level = :info 36 | 37 | # Prepend all log lines with the following tags. 38 | # config.log_tags = [ :subdomain, :uuid ] 39 | 40 | # Use a different logger for distributed setups. 41 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 42 | 43 | # Use a different cache store in production. 44 | # config.cache_store = :mem_cache_store 45 | 46 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 47 | # config.action_controller.asset_host = 'http://assets.example.com' 48 | 49 | # Ignore bad email addresses and do not raise email delivery errors. 50 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 51 | # config.action_mailer.raise_delivery_errors = false 52 | 53 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 54 | # the I18n.default_locale when a translation can not be found). 55 | config.i18n.fallbacks = true 56 | 57 | # Send deprecation notices to registered listeners. 58 | config.active_support.deprecation = :notify 59 | 60 | # Disable automatic flushing of the log to improve performance. 61 | # config.autoflush_log = false 62 | 63 | # Use default logging formatter so that PID and timestamp are not suppressed. 64 | config.log_formatter = ::Logger::Formatter.new 65 | end 66 | -------------------------------------------------------------------------------- /spec/lib/docker_host_port_allocator_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe DockerHostPortAllocator do 6 | describe '#allocate_host_port' do 7 | let(:subject) {described_class.send(:new) } 8 | let(:port_range_start) { 50000 } 9 | let(:port_range_end) { 60000 } 10 | let(:ephemeral_ports) { "#{port_range_start} #{port_range_end}" } 11 | let(:containers) { {} } 12 | 13 | before do 14 | allow(Docker::Container).to receive(:all).and_return(containers) 15 | end 16 | 17 | context 'when ephemeral ports file exists' do 18 | before do 19 | allow(File).to receive(:read).with('/proc/sys/net/ipv4/ip_local_port_range').and_return(ephemeral_ports) 20 | end 21 | 22 | it 'sets the ephemeral port range to the contents of the ephemeral ports file' do 23 | subject.allocate_host_port('tcp') 24 | expect(subject.port_range_start).to eq(port_range_start) 25 | expect(subject.port_range_end).to eq(port_range_end) 26 | end 27 | end 28 | 29 | context 'when ephemeral ports file does not exists' do 30 | before do 31 | allow(File).to receive(:read).with('/proc/sys/net/ipv4/ip_local_port_range').and_raise(Errno::ENOENT) 32 | end 33 | 34 | it 'sets the ephemeral port range to the defaults' do 35 | subject.allocate_host_port('tcp') 36 | expect(subject.port_range_start).to eq(32768) 37 | expect(subject.port_range_end).to eq(61000) 38 | end 39 | end 40 | 41 | context 'when there are not any container running' do 42 | it 'allocates consecutive ports' do 43 | expect(subject.allocate_host_port('tcp')).to eq(32768) 44 | expect(subject.allocate_host_port('tcp')).to eq(32769) 45 | expect(subject.allocate_host_port('tcp')).to eq(32770) 46 | end 47 | end 48 | 49 | context 'when there are containers running' do 50 | let(:container) { double(Docker::Container, info: { 'Ports' => [{'PublicPort' => 32768, 'Type' => 'udp'}, {'PublicPort' => 32769, 'Type' => 'tcp'}] }) } 51 | let(:containers) { [container] } 52 | 53 | it 'should skip already used ports' do 54 | expect(subject.allocate_host_port('tcp')).to eq(32768) 55 | expect(subject.allocate_host_port('tcp')).to eq(32770) 56 | end 57 | end 58 | 59 | context 'when ports are exhausted' do 60 | let(:port_range_start) { 50000 } 61 | let(:port_range_end) { 50000 } 62 | 63 | before do 64 | allow(File).to receive(:read).with('/proc/sys/net/ipv4/ip_local_port_range').and_return(ephemeral_ports) 65 | end 66 | 67 | it 'raises a BackendError exception' do 68 | expect(subject.allocate_host_port('tcp')).to eq(port_range_start) 69 | expect do 70 | subject.allocate_host_port('tcp') 71 | end.to raise_error Exceptions::BackendError, 'All dynamic ports have been exhausted!' 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /app/views/manage/instances/show.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Instance Details:

4 |
5 |
6 |

Service: <%= @service_name %>

7 |

Plan: <%= @plan_name %>

8 |

Instance GUID: <%= @instance_guid %>

9 |

Provider: <%= @instance_provider %>

10 | <% if @instance_details && !@instance_details.empty? %> 11 | <% @instance_details.each do |title, details| %> 12 |

<%= title %>:

13 |
    14 | <% details.each do |key, value| %> 15 |
  • 16 | <%= key %>: 17 | <% if value.is_a?(Array) %> 18 |
      19 | <% value.each do |v| %> 20 |
    • <%= v %>
    • 21 | <% end %> 22 |
    23 | <% else %> 24 | <%= value %> 25 | <% end %> 26 |
  • 27 | <% end %> 28 |
29 | <% end %> 30 | <% end %> 31 |
32 |
33 | 34 | <% if @instance_processes && !@instance_processes.empty? %> 35 |

Processes:

36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | <% @instance_processes.each do |process| %> 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | <% end %> 63 | 64 |
UserPIDPPIDCStart TimeTimeTTYCommand
<%= process['UID'] %><%= process['PID'] %><%= process['PPID'] %><%= process['C'] %><%= process['STIME'] %><%= process['TTY'] %><%= process['TIME'] %><%= process['CMD'] %>
65 |
66 | <% end %> 67 | 68 | <% if @instance_stdout && !@instance_stdout.empty? %> 69 |

stdout:

70 |
71 |
72 |
<%= @instance_stdout %>
73 |
74 |
75 | <% end %> 76 | 77 | <% if @instance_stderr && !@instance_stderr.empty? %> 78 |

stderr:

79 |
80 |
81 |
<%= @instance_stderr %>
82 |
83 |
84 | <% end %> 85 |
86 |
87 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | CfContainersBroker::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both thread web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application 18 | # Add `rack-cache` to your Gemfile before enabling this. 19 | # For large-scale production use, consider using a caching reverse proxy like nginx, varnish or squid. 20 | # config.action_dispatch.rack_cache = true 21 | 22 | config.serve_static_files = true 23 | config.static_cache_control = 'public, max-age=3600' 24 | config.assets.compile = false 25 | config.assets.digest = true 26 | 27 | # Specifies the header that your server uses for sending files. 28 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for apache 29 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx 30 | 31 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 32 | # config.force_ssl = true 33 | 34 | # Set to :debug to see everything in the log. 35 | config.log_level = :info 36 | 37 | # Prepend all log lines with the following tags. 38 | # 1. service instance ID 39 | config.log_tags = [ lambda { |r| if r.env['REQUEST_PATH'] =~ %r{/service_instances/([^/]+)}; $1; else $1; end } ] 40 | 41 | # Use a different logger for distributed setups. 42 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 43 | config.lograge.enabled = true 44 | 45 | # Use default logging formatter so that PID and timestamp are not suppressed. 46 | # config.log_formatter = ::Logger::Formatter.new 47 | 48 | # Use a different cache store in production. 49 | # config.cache_store = :mem_cache_store 50 | 51 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 52 | # config.action_controller.asset_host = 'http://assets.example.com' 53 | 54 | # Ignore bad email addresses and do not raise email delivery errors. 55 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 56 | # config.action_mailer.raise_delivery_errors = false 57 | 58 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 59 | # the I18n.default_locale when a translation can not be found). 60 | config.i18n.fallbacks = true 61 | 62 | # Send deprecation notices to registered listeners. 63 | config.active_support.deprecation = :notify 64 | 65 | # Disable automatic flushing of the log to improve performance. 66 | # config.autoflush_log = false 67 | 68 | end 69 | -------------------------------------------------------------------------------- /SETTINGS.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | To configure the service broker update the [config/settings.yml](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/config/settings.yml) 4 | file according to your environment. 5 | 6 | ## Properties format 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
FieldRequiredTypeDescription
auth_usernameYStringUsername for authentication access to the service broker.
auth_passwordYStringPassword for authentication access to the service broker.
cookie_secretYStringSession secret key for Rack::Session::Cookie.
session_expiryYStringSession expiry for Rack::Session::Cookie.
cc_api_uriYStringCloud Foundry API URI.
external_ipYStringBroker external IP address
external_hostYStringHostname to use when exposing the dashboard url.
ssl_enabledNBooleanSet if the service broker must use SSL or not (`false` by default).
skip_ssl_validationNBoolenSet if the service broker must skip SSL validation or not when connecting to the CC API (`false` by 68 | default).
host_directoryYStringHost directory prefix to use when containers bind a volume to a host directory.
max_containersNStringMaximum number of containers allowed to provision. If not set or if the value is 0, it would mean users can 81 | provision unlimited containers.
allocate_docker_host_portsNBooleanAllocate automatically host ports when binding a Docker container. This is useful in order to preserve the container exposed host ports in case of a VM restart.
servicesYArrayServices that the service broker provides [1].
services.plansYArrayService Plans that the service broker provides [2].
102 | 103 | [1] See [Services Metadata Fields](http://docs.cloudfoundry.org/services/catalog-metadata.html#services-metadata-fields) 104 | 105 | [2] See [Plan Metadata Fields](http://docs.cloudfoundry.org/services/catalog-metadata.html#plan-metadata-fields) 106 | -------------------------------------------------------------------------------- /app/controllers/v2/service_instances_controller.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | class V2::ServiceInstancesController < V2::BaseController 4 | def update 5 | instance_guid = params.fetch(:id) 6 | plan_guid = params.fetch(:plan_id) 7 | unless plan = Catalog.find_plan_by_guid(plan_guid) 8 | return render status: 404, json: { 9 | 'description' => "Cannot create a service instance. Plan #{plan_guid} was not found in the catalog" 10 | } 11 | end 12 | 13 | begin 14 | if plan.container_manager.find(instance_guid) 15 | update_service(instance_guid, plan_guid, plan) 16 | else 17 | create_service(instance_guid, plan_guid, plan) 18 | end 19 | rescue Exception => e 20 | Rails.logger.info(e.inspect) 21 | Rails.logger.info(e.backtrace.join("\n")) 22 | render status: 500, json: { 'description' => e.inspect } 23 | end 24 | end 25 | 26 | def destroy 27 | instance_guid = params.fetch(:id) 28 | service_guid = params.fetch(:service_id) 29 | plan_guid = params.fetch(:plan_id) 30 | 31 | unless plan = Catalog.find_plan_by_guid(plan_guid) 32 | return render status: 404, json: { 33 | 'description' => "Cannot delete a service instance. Plan #{plan_guid} was not found in the catalog" 34 | } 35 | end 36 | 37 | begin 38 | if plan.container_manager.find(instance_guid) 39 | plan.container_manager.destroy(instance_guid) 40 | render status: 200, json: {} 41 | else 42 | render status: 410, json: {} 43 | end 44 | rescue Exception => e 45 | Rails.logger.info(e.inspect) 46 | Rails.logger.info(e.backtrace.join("\n")) 47 | render status: 500, json: { 'description' => e.message } 48 | end 49 | end 50 | 51 | private 52 | 53 | def create_service(instance_guid, plan_guid, plan) 54 | service_guid = params.fetch(:service_id) 55 | organization_guid = params.fetch(:organization_guid) 56 | space_guid = params.fetch(:space_guid) 57 | parameters = params.fetch(:parameters, {}) || {} 58 | 59 | if plan.container_manager.can_allocate?(Settings.max_containers, plan.max_containers) 60 | plan.container_manager.create(instance_guid, parameters) 61 | render status: 201, json: { dashboard_url: build_dashboard_url(service_guid, 62 | plan_guid, 63 | instance_guid) } 64 | else 65 | render status: 507, json: { 'description' => 'Service capacity has been reached' } 66 | end 67 | end 68 | 69 | def update_service(instance_guid, plan_guid, plan) 70 | service_guid = params.fetch(:service_id) 71 | parameters = params.fetch(:parameters, {}) || {} 72 | previous_values = params.fetch(:previous_values, {}) || {} 73 | 74 | plan.container_manager.update(instance_guid, parameters) 75 | render status: 200, json: {} 76 | end 77 | 78 | def build_dashboard_url(service_guid, plan_guid, instance_guid) 79 | domain = Settings.external_host 80 | path = manage_instance_path(service_guid, plan_guid, instance_guid) 81 | 82 | "#{scheme}://#{domain}#{path}" 83 | end 84 | 85 | def scheme 86 | Settings.ssl_enabled == false ? 'http' : 'https' 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/models/catalog_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe Catalog do 6 | let(:subject) { described_class } 7 | 8 | let(:services) { 9 | [ 10 | service_1, 11 | service_2, 12 | ] 13 | } 14 | let(:service_1) { 15 | double( 16 | 'Service', 17 | id: 'service_id_1', 18 | name: 'service_name_1', 19 | description: 'service_description_1', 20 | plans: service_1_plans, 21 | ) 22 | } 23 | let(:service_1_plans) { [plan_1, plan_2] } 24 | let(:service_2) { 25 | double( 26 | 'Service', 27 | id: 'service_id_2', 28 | name: 'service_name_2', 29 | description: 'service_description_2', 30 | plans: service_2_plans, 31 | ) 32 | } 33 | let(:service_2_plans) { [plan_3] } 34 | let(:plan_1) { 35 | double( 36 | 'Plan', 37 | id: 'plan_id_1', 38 | name: 'plan_name_1', 39 | description: 'plan_description_1', 40 | ) 41 | } 42 | let(:plan_2) { 43 | double( 44 | 'Plan', 45 | id: 'plan_id_2', 46 | name: 'plan_name_2', 47 | description: 'plan_description_2', 48 | ) 49 | } 50 | let(:plan_3) { 51 | double( 52 | 'Plan', 53 | id: 'plan_id_3', 54 | name: 'plan_name_3', 55 | description: 'plan_description_3', 56 | ) 57 | } 58 | 59 | before do 60 | expect(Settings).to receive(:[]).with('services').and_return(services) 61 | allow(Service).to receive(:build).and_return(service_1, service_2) 62 | end 63 | 64 | describe '#find_service_by_guid' do 65 | context 'when service guid exists' do 66 | it 'returns the service' do 67 | expect(subject.find_service_by_guid('service_id_1')).to eq(service_1) 68 | end 69 | end 70 | 71 | context 'when service guid does not exists' do 72 | it 'returns nil' do 73 | expect(subject.find_service_by_guid('unknow')).to be_nil 74 | end 75 | end 76 | end 77 | 78 | describe '#services' do 79 | it 'returns an array of service objects representing the services in the catalog' do 80 | expect(subject.services).to eq([service_1, service_2]) 81 | end 82 | 83 | context 'when there are no services' do 84 | let(:services) { nil } 85 | 86 | it 'returns an empty array' do 87 | expect(subject.services).to eq([]) 88 | end 89 | end 90 | end 91 | 92 | describe '#find_plan_by_guid' do 93 | context 'when plan guid exists' do 94 | it 'returns the plan' do 95 | expect(subject.find_plan_by_guid('plan_id_1')).to eq(plan_1) 96 | end 97 | end 98 | 99 | context 'when plan guid does not exists' do 100 | it 'returns nil' do 101 | expect(subject.find_plan_by_guid('unknow')).to be_nil 102 | end 103 | end 104 | end 105 | 106 | describe '#plans' do 107 | it 'returns an array of plan objects representing the plans in the catalog' do 108 | expect(subject.plans).to eq([plan_1, plan_2, plan_3]) 109 | end 110 | 111 | context 'when there are no plans' do 112 | let(:service_1_plans) { [] } 113 | let(:service_2_plans) { [] } 114 | 115 | it 'returns an empty array' do 116 | expect(subject.plans).to eq([]) 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /app/models/credentials.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | class Credentials 4 | USERNAME_PREFIX = 'USER'.freeze 5 | PASSWORD_PREFIX = 'PWD'.freeze 6 | DBNAME_PREFIX = 'DB'.freeze 7 | 8 | attr_reader :credentials 9 | 10 | def initialize(attrs = {}) 11 | @credentials = attrs 12 | end 13 | 14 | def username_key 15 | credentials.fetch('username', {}).fetch('key', nil) 16 | end 17 | 18 | def username_value(guid) 19 | if username_value = credentials.fetch('username', {}).fetch('value', nil) 20 | username_value 21 | else 22 | Digest::MD5.base64digest("#{USERNAME_PREFIX}-#{guid}").gsub(/[^a-zA-Z0-9]+/, '')[0...16].downcase 23 | end 24 | end 25 | 26 | def password_key 27 | credentials.fetch('password', {}).fetch('key', nil) 28 | end 29 | 30 | def password_value(guid) 31 | if password_value = credentials.fetch('password', {}).fetch('value', nil) 32 | password_value 33 | else 34 | Digest::MD5.base64digest("#{PASSWORD_PREFIX}-#{guid}").gsub(/[^a-zA-Z0-9]+/, '')[0...16].downcase 35 | end 36 | end 37 | 38 | def dbname_key 39 | credentials.fetch('dbname', {}).fetch('key', nil) 40 | end 41 | 42 | def dbname_value(guid) 43 | if dbname_value = credentials.fetch('dbname', {}).fetch('value', nil) 44 | dbname_value 45 | else 46 | Digest::MD5.base64digest("#{DBNAME_PREFIX}-#{guid}").gsub(/[^a-zA-Z0-9]+/, '')[0...16].downcase 47 | end 48 | end 49 | 50 | def hostname_key 51 | credentials.fetch('hostname', {}).fetch('key', nil) 52 | end 53 | 54 | def hostname_value 55 | credentials.fetch('hostname', {}).fetch('value', nil) 56 | end 57 | 58 | def uri_prefix 59 | credentials.fetch('uri', {}).fetch('prefix', nil) 60 | end 61 | 62 | def uri_port 63 | credentials.fetch('uri', {}).fetch('port', nil) 64 | end 65 | 66 | def to_hash(guid, hostname, ports) 67 | service_credentials = { 68 | 'hostname' => hostname, 69 | 'host' => hostname 70 | } 71 | service_credentials['hostname'] = hostname_value if hostname_key 72 | 73 | service_credentials['ports'] = ports unless ports.empty? 74 | if uri_port 75 | if port = ports.fetch(uri_port, nil) 76 | service_credentials['port'] = port 77 | else 78 | Rails.logger.info("+-> Credentials #{uri_port} is not exposed") 79 | end 80 | elsif ports.size == 1 81 | service_credentials['port'] = ports.values[0] 82 | end 83 | 84 | service_credentials['username'] = username_value(guid) if username_key 85 | service_credentials['password'] = password_value(guid) if password_key 86 | service_credentials['dbname'] = dbname_value(guid) if dbname_key 87 | 88 | if uri_prefix 89 | uri = "#{uri_prefix}://" 90 | if service_credentials['username'] 91 | uri << service_credentials['username'] 92 | uri << ":#{service_credentials['password']}" if service_credentials['password'] 93 | uri << '@' 94 | end 95 | uri << service_credentials['hostname'] 96 | uri << ":#{service_credentials['port']}" if service_credentials['port'] 97 | uri << "/#{service_credentials['dbname']}" if service_credentials['dbname'] 98 | 99 | service_credentials['uri'] = uri 100 | end 101 | 102 | service_credentials 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/lib/uaa_session_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe UaaSession do 6 | let(:subject) { described_class } 7 | 8 | describe '#build' do 9 | let(:handler) { subject.build(access_token, refresh_token, service_guid) } 10 | let(:access_token) { 'my_access_token' } 11 | let(:refresh_token) { 'my_refresh_token' } 12 | let(:service_guid) { 'service-guid' } 13 | let(:token_info) { 14 | double('token_info', auth_header: "token #{auth_header}", info: { access_token: access_token }) 15 | } 16 | let(:auth_header) { 'auth_header' } 17 | let(:auth_server_url) { 'https://login.10.0.0.1.xip.io' } 18 | let(:token_server_url) { 'https://uaa.10.0.0.1.xip.io' } 19 | let(:service) { 20 | double('Service', dashboard_client: { 'id' => dashboard_client_id, 'secret' => dashboard_client_secret }) 21 | } 22 | let(:dashboard_client_id) { 'client id' } 23 | let(:dashboard_client_secret) { 'client secret' } 24 | let(:token_issuer) { double(CF::UAA::TokenIssuer) } 25 | 26 | before do 27 | allow(CF::UAA::TokenInfo).to receive(:new) 28 | .with(access_token: access_token, token_type: 'bearer') 29 | .and_return(token_info) 30 | end 31 | 32 | context 'when the access token is not expired' do 33 | before do 34 | expect(CF::UAA::TokenCoder).to receive(:decode) 35 | .with(auth_header, verify: false) 36 | .and_return('exp' => 1.minute.from_now.to_i) 37 | end 38 | 39 | it 'returns a token that is encoded and can be used in a header' do 40 | expect(handler.auth_header).to eql("token #{auth_header}") 41 | end 42 | 43 | it 'sets access token to the given token' do 44 | expect(handler.access_token).to eq(access_token) 45 | end 46 | end 47 | 48 | context 'when the access token is expired' do 49 | before do 50 | expect(CF::UAA::TokenCoder).to receive(:decode) 51 | .with(auth_header, verify: false) 52 | .and_return('exp' => 1.minute.ago.to_i) 53 | expect(Catalog).to receive(:find_service_by_guid) 54 | .with(service_guid) 55 | .and_return(service) 56 | expect(Configuration).to receive(:auth_server_url).and_return(auth_server_url) 57 | expect(Configuration).to receive(:token_server_url).and_return(token_server_url) 58 | expect(CF::UAA::TokenIssuer).to receive(:new) 59 | .with(auth_server_url, 60 | dashboard_client_id, 61 | dashboard_client_secret, 62 | { token_target: token_server_url, 63 | skip_ssl_validation: Settings.skip_ssl_validation }) 64 | .and_return(token_issuer) 65 | expect(token_issuer).to receive(:refresh_token_grant) 66 | .with(refresh_token) 67 | .and_return(token_info) 68 | end 69 | 70 | it 'uses the refresh token to get a new access token' do 71 | expect(handler.auth_header).to eql("token #{auth_header}") 72 | end 73 | 74 | it 'updates the tokens' do 75 | expect(handler.access_token).to eql(access_token) 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/models/plan_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe Plan do 6 | let(:subject) { described_class } 7 | let(:docker_manager) { double('DockerManager') } 8 | let(:plan) do 9 | described_class.build( 10 | 'id' => 'plan_id', 11 | 'name' => 'plan_name', 12 | 'description' => 'plan_description', 13 | 'metadata' => { 14 | 'meta_key' => 'meta_value', 15 | }, 16 | 'free' => false, 17 | 'max_containers' => 5, 18 | 'credentials' => { 19 | 'username' => 'my-username', 20 | }, 21 | 'syslog_drain_port' => '514/udp', 22 | 'syslog_drain_protocol' => 'syslog-tls', 23 | 'container' => { 24 | 'backend' => 'docker', 25 | }, 26 | ) 27 | end 28 | 29 | before do 30 | allow(DockerManager).to receive(:new).and_return(docker_manager) 31 | end 32 | 33 | describe '#build' do 34 | it 'sets the attributes correctly' do 35 | expect(plan.id).to eq('plan_id') 36 | expect(plan.name).to eq('plan_name') 37 | expect(plan.description).to eq('plan_description') 38 | expect(plan.metadata).to eq({ 'meta_key' => 'meta_value' }) 39 | expect(plan.free).to eq(false) 40 | expect(plan.max_containers).to eq(5) 41 | expect(plan.credentials).to eq({ 'username' => 'my-username' }) 42 | expect(plan.syslog_drain_port).to eq('514/udp') 43 | expect(plan.syslog_drain_protocol).to eq('syslog-tls') 44 | expect(plan.container_manager).to eq(docker_manager) 45 | end 46 | 47 | context 'when container backend is not supported' do 48 | it 'raises an exception' do 49 | expect do 50 | described_class.build( 51 | 'id' => 'plan_id', 52 | 'name' => 'plan_name', 53 | 'description' => 'plan_description', 54 | 'container' => { 55 | 'backend' => 'not-supported', 56 | }, 57 | ) 58 | end.to raise_error(Exceptions::NotSupported, "Could not load Container Manager for backend `not-supported'") 59 | end 60 | end 61 | 62 | context 'when mandatory keys are missing' do 63 | it 'should raise a Exceptions::ArgumentError exception' do 64 | expect do 65 | described_class.build({}) 66 | end.to raise_error Exceptions::ArgumentError, 'Missing Plan parameters: id, name, description, container' 67 | end 68 | end 69 | 70 | context 'when optional keys are missing' do 71 | let(:plan) do 72 | described_class.build( 73 | 'id' => 'plan_id', 74 | 'name' => 'plan_name', 75 | 'description' => 'plan_description', 76 | 'container' => { 77 | 'backend' => 'docker', 78 | }, 79 | ) 80 | end 81 | 82 | it 'sets the metadata field to nil' do 83 | expect(plan.metadata).to be_nil 84 | end 85 | 86 | it 'sets the free field to true' do 87 | expect(plan.free).to be_truthy 88 | end 89 | 90 | it 'sets the max_containers field to nil' do 91 | expect(plan.max_containers).to be_nil 92 | end 93 | 94 | it 'sets the credentials field to an empty Hash' do 95 | expect(plan.credentials).to eql({}) 96 | end 97 | 98 | it 'sets the syslog_drain_port field to nil' do 99 | expect(plan.syslog_drain_port).to be_nil 100 | end 101 | 102 | it 'sets the syslog_drain_protocol field to syslog' do 103 | expect(plan.syslog_drain_protocol).to eq('syslog') 104 | end 105 | end 106 | end 107 | 108 | describe '#to_hash' do 109 | it 'contains the correct values' do 110 | plan_hash = plan.to_hash 111 | 112 | expect(plan_hash.fetch('id')).to eq('plan_id') 113 | expect(plan_hash.fetch('name')).to eq('plan_name') 114 | expect(plan_hash.fetch('description')).to eq('plan_description') 115 | expect(plan_hash.fetch('metadata')).to eq({ 'meta_key' => 'meta_value' }) 116 | expect(plan_hash.fetch('free')).to eq(false) 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | # This file should not be used in deployed environments. Instead, set 2 | # the SETTINGS_PATH environment variable to point to a configuration 3 | # file that contains these settings. 4 | 5 | defaults: &defaults 6 | log_path: 'log/<%= Rails.env %>.log' 7 | auth_username: <%= ENV['BROKER_USERNAME'] || "containers" %> 8 | auth_password: <%= ENV['BROKER_PASSWORD'] || "secret" %> 9 | cookie_secret: <%= ENV['COOKIE_SECRET'] || 'e7247dae-a252-4393-afa3-2219c1c02efd' %> 10 | session_expiry: <%= ENV['SESSION_EXPIRY'] || 86400 %> 11 | 12 | # Restrict a maximum number of service instances/containers; 0 if unlimited 13 | max_containers: <%= ENV['MAX_CONTAINERS'] || 0 %> 14 | 15 | # IP/host used as 'host' for binding credentials 16 | external_ip: <%= ENV['EXTERNAL_IP'] || ENV['EXTERNAL_HOST'] || "127.0.0.1" %> 17 | 18 | # Dashboard URL hostname 19 | external_host: <%= ENV['EXTERNAL_HOST'] || "containers.vcap.me" %> 20 | # Dashboard URL protocol - if ssl_enabled then https:// else http:// 21 | ssl_enabled: false 22 | 23 | cc_api_uri: 'http://api.vcap.me' 24 | skip_ssl_validation: true 25 | 26 | # Root folder on host machine for all container volumes 27 | host_directory: '/var/vcap/store/cf-containers-broker/' 28 | 29 | # Allocates ephemeral ports on host machine for each exposed port in Docker image 30 | allocate_docker_host_ports: true 31 | 32 | # If provisioning services with `allocate_docker_host_ports: true` 33 | # you can optionally enable the addition of a DOCKER_HOST_PORT_nnnn env var 34 | # for each exposed port. 35 | # This will create the container and then recreate the container with an additional 36 | # DOCKER_HOST_PORT_nnnn environment variable for each exposed port. 37 | # If you wish to disable this two-step provisioning process, set to true: 38 | enable_host_port_envvar: false 39 | 40 | # Ability to set environment variables to each container 41 | # File "/envdir/FOOBAR" will add an env var $FOOBAR to each Docker container, 42 | # set to the value from the file 43 | container_env_var_dir: '/envdir' 44 | 45 | services: 46 | - name: 'postgresql96' 47 | id: 'ef761cec-14f7-11e7-8dfb-bbab51a4e12a' 48 | description: 'PostgreSQL 9.6 service for application development and testing' 49 | bindable: true 50 | tags: [postgresql96, postgresql, sql, relational] 51 | metadata: 52 | displayName: 'PostgreSQL 9.6' 53 | longDescription: 'A PostgreSQL 9.6 service for development and testing running inside a Docker container' 54 | providerDisplayName: 'Cloud Foundry Community' 55 | documentationUrl: 'https://github.com/frodenas/docker-postgresql' 56 | supportUrl: 'https://slack.cloudfoundry.org' 57 | plans: 58 | - id: 'f30f03fa-14f7-11e7-8d86-cf0d7f2c3728' 59 | name: 'free' 60 | description: 'Free Trial' 61 | container: 62 | backend: 'docker' 63 | image: 'frodenas/postgresql' 64 | tag: '9.6' 65 | persistent_volumes: [/data] 66 | credentials: 67 | username: {key: 'POSTGRES_USERNAME'} 68 | password: {key: 'POSTGRES_PASSWORD'} 69 | dbname: {key: 'POSTGRES_DBNAME'} 70 | uri: {prefix: 'postgres'} 71 | - name: 'redis32' 72 | id: '0fdcc9c0-14f5-11e7-9d8c-cfde16aa4822' 73 | description: 'Redis 3.2 service for application development and testing' 74 | bindable: true 75 | tags: [redis32, redis, key-value] 76 | metadata: 77 | displayName: 'Redis 3.2' 78 | longDescription: 'A Redis 3.2 service for development and testing running inside a Docker container' 79 | providerDisplayName: 'Cloud Foundry Community' 80 | documentationUrl: 'https://github.com/frodenas/docker-redis' 81 | supportUrl: 'https://slack.cloudfoundry.org' 82 | plans: 83 | - id: '13d21792-14f5-11e7-81cd-4357fa4eeda9' 84 | name: 'free' 85 | description: 'Free Trial' 86 | container: 87 | backend: 'docker' 88 | image: 'frodenas/redis' 89 | tag: '3.2' 90 | persistent_volumes: [/data] 91 | credentials: 92 | password: {key: 'REDIS_PASSWORD'} 93 | 94 | assets: 95 | <<: *defaults 96 | 97 | development: 98 | <<: *defaults 99 | 100 | test: 101 | <<: *defaults 102 | 103 | production: 104 | <<: *defaults 105 | -------------------------------------------------------------------------------- /app/controllers/manage/instances_controller.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | module Manage 4 | class InstancesController < ApplicationController 5 | before_filter :redirect_ssl 6 | before_filter :require_login 7 | before_filter :build_uaa_session 8 | before_filter :ensure_all_necessary_scopes_are_approved 9 | before_filter :ensure_can_manage_instance 10 | 11 | def show 12 | service_guid = params.fetch(:service_guid) 13 | plan_guid = params.fetch(:plan_guid) 14 | @instance_guid = params.fetch(:instance_guid) 15 | 16 | unless service = Catalog.find_service_by_guid(service_guid) 17 | return render status: 404, json: { 18 | 'description' => "Cannot create a service instance. Service #{service_guid} was not found in the catalog" 19 | } 20 | end 21 | 22 | unless plan = Catalog.find_plan_by_guid(plan_guid) 23 | return render status: 404, json: { 24 | 'description' => "Cannot create a service instance. Plan #{plan_guid} was not found in the catalog" 25 | } 26 | end 27 | 28 | unless container = plan.container_manager.find(@instance_guid) 29 | return render status: 404, json: { 30 | 'description' => "Cannot create a service instance. Instance #{@instance_guid} was not found" 31 | } 32 | end 33 | 34 | @dashboard_name = "\"#{service.metadata.fetch('displayName', 'Containers')}\" Management Dashboard" 35 | image = service.metadata.fetch('imageUrl', '') 36 | @dashboard_image = image unless image.empty? 37 | @service_name = "#{service.name} (#{service.description})" 38 | @plan_name = "#{plan.name} (#{plan.description})" 39 | @instance_provider = "#{plan.container_manager.backend.capitalize}" 40 | @instance_details = plan.container_manager.details(@instance_guid) 41 | @instance_processes = plan.container_manager.processes(@instance_guid) 42 | @instance_stdout = plan.container_manager.stdout(@instance_guid) 43 | @instance_stderr = plan.container_manager.stderr(@instance_guid) 44 | end 45 | 46 | private 47 | 48 | def redirect_ssl 49 | redirect_to :protocol => 'https://' if Settings.ssl_enabled && request.protocol == 'http://' 50 | true 51 | end 52 | 53 | def require_login 54 | session[:service_guid] = params[:service_guid] 55 | session[:plan_guid] = params[:plan_guid] 56 | session[:instance_guid] = params[:instance_guid] 57 | unless logged_in? 58 | redirect_to '/manage/auth/cloudfoundry' 59 | return false 60 | end 61 | end 62 | 63 | def build_uaa_session 64 | @uaa_session = UaaSession.build(session[:uaa_access_token], session[:uaa_refresh_token], params.fetch(:service_guid)) 65 | session[:uaa_access_token] = @uaa_session.access_token 66 | end 67 | 68 | def ensure_all_necessary_scopes_are_approved 69 | begin 70 | token_hash = CF::UAA::TokenCoder.decode(@uaa_session.access_token, verify: false) 71 | return true if has_necessary_scopes?(token_hash) 72 | rescue 73 | need_to_retry = true 74 | end 75 | 76 | if need_to_retry? 77 | session[:has_retried] = 'true' 78 | redirect_to '/manage/auth/cloudfoundry' 79 | return false 80 | else 81 | session[:has_retried] = 'false' 82 | render 'errors/approvals_error' 83 | return false 84 | end 85 | end 86 | 87 | def ensure_can_manage_instance 88 | cc_client = CloudControllerHttpClient.new(@uaa_session.auth_header) 89 | response_body = cc_client.get("/v2/service_instances/#{params[:instance_guid]}/permissions") 90 | unless !response_body.nil? && response_body['manage'] 91 | render 'errors/not_authorized' 92 | return false 93 | end 94 | end 95 | 96 | def logged_in? 97 | oldest_allowable_last_seen_time = Time.now - Settings.session_expiry 98 | 99 | if session[:uaa_user_id].present? && 100 | session[:uaa_access_token] && 101 | (session[:last_seen] > oldest_allowable_last_seen_time) 102 | session[:last_seen] = Time.now 103 | return true 104 | end 105 | 106 | false 107 | end 108 | 109 | def has_necessary_scopes?(token_hash) 110 | %w(openid cloud_controller_service_permissions.read).all? { |scope| token_hash['scope'].include?(scope) } 111 | end 112 | 113 | def need_to_retry? 114 | session[:has_retried].nil? || session[:has_retried] == 'false' 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /CREDENTIALS.md: -------------------------------------------------------------------------------- 1 | # Credentials 2 | 3 | Each service `plan` defined at the [settings](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/SETTINGS.md) 4 | file can have a single set of credentials. Credentials can be predefined (statically defined in the container or at 5 | the [settings](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/SETTINGS.md) file), or generated 6 | randomly and injected into the container at provision/bind time. 7 | 8 | ## Properties format 9 | 10 | Each service `plan` defined at the [settings](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/SETTINGS.md) 11 | file might contain the following properties: 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 103 | 104 |
FieldRequiredTypeDescription
credentialsNHashCredentials properties.
credentials.usernameNHashProperties to build the `username` credentials field [1].
credentials.username.keyNStringName of the environment variable to pass to the container to set the service username.
credentials.username.valueNStringUsername to send to the container via the environment variable. If not set, and 43 | `credentials.username.key` is set, the broker will create a random username.
credentials.passwordNHashProperties to build the `password` credentials field [1].
credentials.password.keyNStringName of the environment variable to pass to the container to set the service password.
credentials.password.valueNStringPassword to send to the container via the environment variable. If not set, and 62 | `credentials.password.key` is set, the broker will create a random password.
credentials.dnameNHashProperties to build the `dbname` to append to the `uri` credentials field [1].
credentials.dbname.keyNStringName of the environment variable to pass to the container to set the service dbname.
credentials.dbname.valueNStringDbname to send to the container via the environment variable. If not set, and 81 | `credentials.dbname.key` is set, the broker will create a random dbname.
credentials.uriNHashProperties to build the `uri` credentials field [1].
credentials.uri.prefixNStringPrefix (ie `dbtype`) to add at the `uri` part of the credentials.
credentials.uri.portNStringContainer port to be exposed at the the `uri` part of the credentials (format: port</protocol>). The 100 | broker will translate this port to the real exposed host port. This field is not required unless your container 101 | exposes more than 1 port (ie the server port and the web ui port) and you just want to send one of them to the 102 | application binding.
105 | 106 | [1] See [Binding credentials](http://docs.cloudfoundry.org/services/binding-credentials.html) 107 | 108 | ## Example 109 | 110 | This example will use a predefined username named `admin` and it will create a random password and dbname. 111 | Credentials will be sent to the container using the environment variables `SERVICE_USERNAME`, 112 | `SERVICE_PASSWORD` and `SERVICE_DBNAME` respectively. When an application is bound to the 113 | service, it will receive a credentials hash with an URI following this pattern: `mongodb://admin:@:/`. 115 | 116 | ```yaml 117 | credentials: 118 | username: 119 | key: 'SERVICE_USERNAME' 120 | value: 'admin' 121 | password: 122 | key: 'SERVICE_PASSWORD' 123 | dbname: 124 | key: 'SERVICE_DBNAME' 125 | uri: 126 | prefix: 'mongodb' 127 | port: '27017/tcp' 128 | ``` 129 | -------------------------------------------------------------------------------- /spec/models/container_manager_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe ContainerManager do 6 | let(:subject) { described_class.new(attrs) } 7 | let(:attrs) { 8 | { 9 | 'backend' => 'my_backend', 10 | 'credentials' => { 11 | 'username' => 'my-username', 12 | }, 13 | 'syslog_drain_port' => '514/udp', 14 | 'syslog_drain_protocol' => 'syslog-tls', 15 | } 16 | } 17 | let(:guid) { 'guid' } 18 | let(:credentials) { double('Credentials') } 19 | 20 | before do 21 | allow(Credentials).to receive(:new).and_return(credentials) 22 | end 23 | 24 | describe '#initialize' do 25 | it 'sets the attributes correctly' do 26 | expect(subject.backend).to eq('my_backend') 27 | expect(subject.credentials).to eq(credentials) 28 | expect(subject.syslog_drain_port).to eq('514/udp') 29 | expect(subject.syslog_drain_protocol).to eq('syslog-tls') 30 | end 31 | 32 | context 'when mandatory keys are missing' do 33 | let(:attrs) { {} } 34 | 35 | it 'should raise a Exceptions::ArgumentError exception' do 36 | expect do 37 | subject 38 | end.to raise_error Exceptions::ArgumentError, 'Missing Container parameters: backend' 39 | end 40 | end 41 | 42 | context 'when optional keys are missing' do 43 | let(:attrs) { 44 | { 45 | 'backend' => 'my_backend', 46 | } 47 | } 48 | 49 | it 'sets the credentials field to a Credentials object' do 50 | expect(subject.credentials).to eq(credentials) 51 | end 52 | 53 | it 'sets the syslog_drain_port field to nil' do 54 | expect(subject.syslog_drain_port).to be_nil 55 | end 56 | 57 | it 'sets the syslog_drain_protocol field to syslog' do 58 | expect(subject.syslog_drain_protocol).to eq('syslog') 59 | end 60 | end 61 | end 62 | 63 | describe '#find' do 64 | it 'should raise a NotImplemented Exception' do 65 | expect do 66 | subject.find(guid) 67 | end.to raise_error(Exceptions::NotImplemented, "`find' is not implemented by `#{subject.class.name}'") 68 | end 69 | end 70 | 71 | describe '#can_allocate?' do 72 | it 'should raise a NotImplemented Exception' do 73 | expect do 74 | subject.can_allocate?(1,1 ) 75 | end.to raise_error(Exceptions::NotImplemented, "`can_allocate?' is not implemented by `#{subject.class.name}'") 76 | end 77 | end 78 | 79 | describe '#create' do 80 | it 'should raise a NotImplemented Exception' do 81 | expect do 82 | subject.create(guid) 83 | end.to raise_error(Exceptions::NotImplemented, "`create' is not implemented by `#{subject.class.name}'") 84 | end 85 | end 86 | 87 | describe '#destroy' do 88 | it 'should raise a NotImplemented Exception' do 89 | expect do 90 | subject.destroy(guid) 91 | end.to raise_error(Exceptions::NotImplemented, "`destroy' is not implemented by `#{subject.class.name}'") 92 | end 93 | end 94 | 95 | describe '#fetch_image' do 96 | it 'should raise a NotImplemented Exception' do 97 | expect do 98 | subject.fetch_image 99 | end.to raise_error(Exceptions::NotImplemented, "`fetch_image' is not implemented by `#{subject.class.name}'") 100 | end 101 | end 102 | 103 | describe '#update_all_containers' do 104 | it 'should raise a NotImplemented Exception' do 105 | expect do 106 | subject.update_all_containers 107 | end.to raise_error(Exceptions::NotImplemented, "`update_all_containers' is not implemented by `#{subject.class.name}'") 108 | end 109 | end 110 | 111 | describe '#service_credentials' do 112 | it 'should raise a NotImplemented Exception' do 113 | expect do 114 | subject.service_credentials(guid) 115 | end.to raise_error(Exceptions::NotImplemented, "`service_credentials' is not implemented by `#{subject.class.name}'") 116 | end 117 | end 118 | 119 | describe '#syslog_drain_url' do 120 | it 'should return nil' do 121 | expect(subject.syslog_drain_url(guid)).to be_nil 122 | end 123 | end 124 | 125 | describe '#details' do 126 | it 'should return nil' do 127 | expect(subject.details(guid)).to be_nil 128 | end 129 | end 130 | 131 | describe '#processes' do 132 | it 'should return nil' do 133 | expect(subject.processes(guid)).to be_nil 134 | end 135 | end 136 | 137 | describe '#stdout' do 138 | it 'should return nil' do 139 | expect(subject.stdout(guid)).to be_nil 140 | end 141 | end 142 | 143 | describe '#stderr' do 144 | it 'should return nil' do 145 | expect(subject.stderr(guid)).to be_nil 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /spec/models/service_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe Service do 6 | let(:subject) { described_class } 7 | let(:plan) { double('Plan', to_hash: 'plan-hash') } 8 | let(:service) do 9 | described_class.build( 10 | 'id' => 'service_id', 11 | 'name' => 'service_name', 12 | 'description' => 'service_description', 13 | 'bindable' => false, 14 | 'tags' => [ 15 | 'tag', 16 | ], 17 | 'metadata' => { 18 | 'meta_key' => 'meta_value', 19 | }, 20 | 'requires' => [ 21 | 'syslog_drain', 22 | ], 23 | 'plans' => [ 24 | double('Plan') 25 | ], 26 | 'plan_updateable' => true, 27 | 'dashboard_client' => { 28 | 'id' => 'client-id', 29 | 'secret' => 'client-secret', 30 | }, 31 | ) 32 | end 33 | 34 | before do 35 | allow(Plan).to receive(:build).and_return(plan) 36 | end 37 | 38 | describe '#build' do 39 | it 'sets the attributes correctly' do 40 | expect(service.id).to eq('service_id') 41 | expect(service.name).to eq('service_name') 42 | expect(service.description).to eq('service_description') 43 | expect(service.bindable).to be_falsey 44 | expect(service.tags).to eq(['tag']) 45 | expect(service.metadata).to eq({ 'meta_key' => 'meta_value' }) 46 | expect(service.requires).to eq(['syslog_drain']) 47 | expect(service.plans).to eq([plan]) 48 | expect(service.dashboard_client).to eql({ 49 | 'id' => 'client-id', 50 | 'secret' => 'client-secret', 51 | 'redirect_uri' => 'http://containers.vcap.me/manage/auth/cloudfoundry/callback', 52 | }) 53 | end 54 | 55 | context 'when mandatory keys are missing' do 56 | it 'should raise a Exceptions::ArgumentError exception' do 57 | expect do 58 | described_class.build({}) 59 | end.to raise_error Exceptions::ArgumentError, 'Missing Service parameters: id, name, description' 60 | end 61 | end 62 | 63 | context 'when optional keys are missing' do 64 | let(:service) do 65 | described_class.build( 66 | 'id' => 'service_id', 67 | 'name' => 'service_name', 68 | 'description' => 'service_description', 69 | ) 70 | end 71 | 72 | it 'sets the bindable field to true' do 73 | expect(service.bindable).to be_truthy 74 | end 75 | 76 | it 'sets the tags field to an empty array' do 77 | expect(service.tags).to eq([]) 78 | end 79 | 80 | it 'sets the metadata field to nil' do 81 | expect(service.metadata).to be_nil 82 | end 83 | 84 | it 'sets the requires field to an empty array' do 85 | expect(service.requires).to eq([]) 86 | end 87 | 88 | it 'sets the plans field to an empty array' do 89 | expect(service.plans).to eq([]) 90 | end 91 | 92 | it 'sets the plan_updateable field to a boolean' do 93 | expect(service.plan_updateable).to eq(true) 94 | end 95 | 96 | it 'sets the dashboard_client field to an empty hash' do 97 | expect(service.dashboard_client).to eq({}) 98 | end 99 | end 100 | end 101 | 102 | describe '#to_hash' do 103 | it 'contains the correct values' do 104 | service_hash = service.to_hash 105 | 106 | expect(service_hash.fetch('id')).to eq('service_id') 107 | expect(service_hash.fetch('name')).to eq('service_name') 108 | expect(service_hash.fetch('description')).to eq('service_description') 109 | expect(service_hash.fetch('bindable')).to eq(false) 110 | expect(service_hash.fetch('tags')).to eq(['tag']) 111 | expect(service_hash.fetch('metadata')).to eq({ 'meta_key' => 'meta_value' }) 112 | expect(service_hash.fetch('requires')).to eq(['syslog_drain']) 113 | expect(service_hash.fetch('plans')).to eq(['plan-hash']) 114 | expect(service_hash.fetch('dashboard_client')).to eq({ 115 | 'id' => 'client-id', 116 | 'secret' => 'client-secret', 117 | 'redirect_uri' => 'http://containers.vcap.me/manage/auth/cloudfoundry/callback', 118 | }) 119 | end 120 | end 121 | end 122 | 123 | describe Service do 124 | let(:subject) { described_class } 125 | let(:plan) { double('Plan', to_hash: 'plan-hash') } 126 | let(:service) do 127 | described_class.build( 128 | 'id' => 'service_id', 129 | 'name' => 'service_name', 130 | 'description' => 'service_description', 131 | 'bindable' => false, 132 | 'plans' => [ 133 | double('Plan') 134 | ], 135 | 'plan_updateable' => true, 136 | ) 137 | end 138 | 139 | before do 140 | allow(Plan).to receive(:build).and_return(plan) 141 | end 142 | 143 | describe '#to_hash' do 144 | it 'contains the correct values' do 145 | service_hash = service.to_hash 146 | 147 | expect(service_hash.fetch('id')).to eq('service_id') 148 | expect(service_hash.has_value?('dashboard_client')).to eq(false) 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /spec/controllers/v2/service_bindings_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe V2::ServiceBindingsController do 6 | let(:service_id) { 'service-id' } 7 | let(:plan_id) { 'plan-id' } 8 | let(:instance_id) { 'instance-id' } 9 | let(:binding_id) { 'binding-id' } 10 | let(:plan) { double('Plan') } 11 | let(:container_manager) { double('ContainerManager') } 12 | let(:container) { double('Container') } 13 | let(:credentials) { 'credentials-hash' } 14 | let(:syslog_drain_url) { nil } 15 | 16 | before do 17 | authenticate 18 | allow(Docker).to receive(:version).and_return({ 'ApiVersion' => DockerManager::MIN_SUPPORTED_DOCKER_API_VERSION }) 19 | end 20 | 21 | describe '#update' do 22 | let(:make_request) do 23 | put :update, { id: binding_id, service_instance_id: instance_id, service_id: service_id, plan_id: plan_id } 24 | end 25 | 26 | it_behaves_like 'a controller action that requires basic auth' 27 | 28 | it_behaves_like 'a controller action that logs its request and response headers and body' 29 | 30 | context 'when the service instance is bound' do 31 | before do 32 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(plan) 33 | expect(plan).to receive(:container_manager).exactly(3).times.and_return(container_manager) 34 | expect(container_manager).to receive(:find).with(instance_id).and_return(container) 35 | expect(container_manager).to receive(:service_credentials) 36 | .with(instance_id) 37 | .and_return(credentials) 38 | expect(container_manager).to receive(:syslog_drain_url) 39 | .with(instance_id) 40 | .and_return(syslog_drain_url) 41 | end 42 | 43 | it 'returns a 201' do 44 | make_request 45 | 46 | expect(response.status).to eq(201) 47 | end 48 | 49 | it 'returns a hash with credentials' do 50 | make_request 51 | 52 | expect(JSON.parse(response.body)).to eq({ 'credentials' => credentials }) 53 | end 54 | 55 | context 'and has a syslog drain url' do 56 | let(:syslog_drain_url) { 'syslog-drain-url' } 57 | 58 | it 'returns a 201' do 59 | make_request 60 | 61 | expect(response.status).to eq(201) 62 | end 63 | 64 | it 'returns a hash with a syslog drain url' do 65 | make_request 66 | 67 | expect(JSON.parse(response.body)).to eq({ 68 | 'credentials' => credentials, 'syslog_drain_url' => syslog_drain_url 69 | }) 70 | end 71 | end 72 | end 73 | 74 | context 'when the service plan does not exist' do 75 | it 'returns a 404' do 76 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(nil) 77 | 78 | make_request 79 | 80 | expect(response.status).to eq(404) 81 | expect(JSON.parse(response.body)).to eq({ 82 | 'description' => "Cannot bind a service. Plan #{plan_id} was not found in the catalog" 83 | }) 84 | end 85 | end 86 | 87 | context 'when the service instance does not exist' do 88 | it 'returns a 404' do 89 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(plan) 90 | expect(plan).to receive(:container_manager).and_return(container_manager) 91 | expect(container_manager).to receive(:find).with(instance_id).and_return(nil) 92 | make_request 93 | 94 | expect(response.status).to eq(404) 95 | expect(JSON.parse(response.body)).to eq({ 96 | 'description' => "Cannot bind a service. Service Instance #{instance_id} was not found" 97 | }) 98 | end 99 | end 100 | 101 | context 'then the service instance cannot be bound' do 102 | it 'returns a 500' do 103 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(plan) 104 | expect(plan).to receive(:container_manager).twice.and_return(container_manager) 105 | expect(container_manager).to receive(:find).with(instance_id).and_return(container) 106 | expect(container_manager).to receive(:service_credentials) 107 | .with(instance_id) 108 | .and_raise(Exceptions::NotFound, 'Container not found') 109 | make_request 110 | 111 | expect(response.status).to eq(500) 112 | expect(JSON.parse(response.body)).to eq({ 'description' => 'Container not found' }) 113 | end 114 | end 115 | end 116 | 117 | describe '#destroy' do 118 | let(:make_request) { 119 | delete :destroy, id: binding_id, service_instance_id: instance_id, service_id: service_id, plan_id: plan_id 120 | } 121 | 122 | it_behaves_like 'a controller action that requires basic auth' 123 | 124 | it_behaves_like 'a controller action that logs its request and response headers and body' 125 | 126 | it 'returns a 200' do 127 | make_request 128 | 129 | expect(response.status).to eq(200) 130 | expect(response.body).to eq('{}') 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actionmailer (4.2.11.1) 5 | actionpack (= 4.2.11.1) 6 | actionview (= 4.2.11.1) 7 | activejob (= 4.2.11.1) 8 | mail (~> 2.5, >= 2.5.4) 9 | rails-dom-testing (~> 1.0, >= 1.0.5) 10 | actionpack (4.2.11.1) 11 | actionview (= 4.2.11.1) 12 | activesupport (= 4.2.11.1) 13 | rack (~> 1.6) 14 | rack-test (~> 0.6.2) 15 | rails-dom-testing (~> 1.0, >= 1.0.5) 16 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 17 | actionview (4.2.11.1) 18 | activesupport (= 4.2.11.1) 19 | builder (~> 3.1) 20 | erubis (~> 2.7.0) 21 | rails-dom-testing (~> 1.0, >= 1.0.5) 22 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 23 | activejob (4.2.11.1) 24 | activesupport (= 4.2.11.1) 25 | globalid (>= 0.3.0) 26 | activemodel (4.2.11.1) 27 | activesupport (= 4.2.11.1) 28 | builder (~> 3.1) 29 | activerecord (4.2.11.1) 30 | activemodel (= 4.2.11.1) 31 | activesupport (= 4.2.11.1) 32 | arel (~> 6.0) 33 | activesupport (4.2.11.1) 34 | i18n (~> 0.7) 35 | minitest (~> 5.1) 36 | thread_safe (~> 0.3, >= 0.3.4) 37 | tzinfo (~> 1.1) 38 | addressable (2.6.0) 39 | public_suffix (>= 2.0.2, < 4.0) 40 | arel (6.0.4) 41 | builder (3.2.3) 42 | cf-uaa-lib (3.14.3) 43 | httpclient (~> 2.8, >= 2.8.2.4) 44 | multi_json (~> 1.12.0, >= 1.12.1) 45 | coderay (1.1.2) 46 | concurrent-ruby (1.1.5) 47 | crack (0.4.3) 48 | safe_yaml (~> 1.0.0) 49 | crass (1.0.5) 50 | diff-lcs (1.3) 51 | docker-api (1.34.2) 52 | excon (>= 0.47.0) 53 | multi_json 54 | erubis (2.7.0) 55 | eventmachine (1.2.7) 56 | excon (0.66.0) 57 | ffi (1.11.1) 58 | formatador (0.2.5) 59 | globalid (0.4.2) 60 | activesupport (>= 4.2.0) 61 | guard (2.15.0) 62 | formatador (>= 0.2.4) 63 | listen (>= 2.7, < 4.0) 64 | lumberjack (>= 1.0.12, < 2.0) 65 | nenv (~> 0.1) 66 | notiffany (~> 0.0) 67 | pry (>= 0.9.12) 68 | shellany (~> 0.0) 69 | thor (>= 0.18.1) 70 | guard-compat (1.2.1) 71 | guard-rails (0.8.1) 72 | guard (~> 2.11) 73 | guard-compat (~> 1.0) 74 | hashdiff (1.0.0) 75 | hashie (3.6.0) 76 | httpclient (2.8.3) 77 | i18n (0.9.5) 78 | concurrent-ruby (~> 1.0) 79 | kgio (2.11.2) 80 | listen (3.1.5) 81 | rb-fsevent (~> 0.9, >= 0.9.4) 82 | rb-inotify (~> 0.9, >= 0.9.7) 83 | ruby_dep (~> 1.2) 84 | lograge (0.11.2) 85 | actionpack (>= 4) 86 | activesupport (>= 4) 87 | railties (>= 4) 88 | request_store (~> 1.0) 89 | loofah (2.3.1) 90 | crass (~> 1.0.2) 91 | nokogiri (>= 1.5.9) 92 | lumberjack (1.0.13) 93 | mail (2.7.1) 94 | mini_mime (>= 0.1.1) 95 | method_source (0.9.2) 96 | mini_mime (1.0.2) 97 | mini_portile2 (2.4.0) 98 | minitest (5.11.3) 99 | multi_json (1.12.2) 100 | nats (0.11.0) 101 | eventmachine (~> 1.2, >= 1.2) 102 | nenv (0.3.0) 103 | nokogiri (1.10.5) 104 | mini_portile2 (~> 2.4.0) 105 | notiffany (0.1.3) 106 | nenv (~> 0.1) 107 | shellany (~> 0.0) 108 | omniauth (1.9.0) 109 | hashie (>= 3.4.6, < 3.7.0) 110 | rack (>= 1.6.2, < 3) 111 | omniauth-uaa-oauth2 (1.0.0) 112 | cf-uaa-lib (>= 3.2, < 4.0) 113 | omniauth (~> 1.0) 114 | pry (0.12.2) 115 | coderay (~> 1.1.0) 116 | method_source (~> 0.9.0) 117 | public_suffix (3.1.1) 118 | rack (1.6.12) 119 | rack-test (0.6.3) 120 | rack (>= 1.0) 121 | rails (4.2.11.1) 122 | actionmailer (= 4.2.11.1) 123 | actionpack (= 4.2.11.1) 124 | actionview (= 4.2.11.1) 125 | activejob (= 4.2.11.1) 126 | activemodel (= 4.2.11.1) 127 | activerecord (= 4.2.11.1) 128 | activesupport (= 4.2.11.1) 129 | bundler (>= 1.3.0, < 2.0) 130 | railties (= 4.2.11.1) 131 | sprockets-rails 132 | rails-api (0.4.1) 133 | actionpack (>= 3.2.11) 134 | railties (>= 3.2.11) 135 | rails-deprecated_sanitizer (1.0.3) 136 | activesupport (>= 4.2.0.alpha) 137 | rails-dom-testing (1.0.9) 138 | activesupport (>= 4.2.0, < 5.0) 139 | nokogiri (~> 1.6) 140 | rails-deprecated_sanitizer (>= 1.0.1) 141 | rails-html-sanitizer (1.2.0) 142 | loofah (~> 2.2, >= 2.2.2) 143 | railties (4.2.11.1) 144 | actionpack (= 4.2.11.1) 145 | activesupport (= 4.2.11.1) 146 | rake (>= 0.8.7) 147 | thor (>= 0.18.1, < 2.0) 148 | raindrops (0.19.0) 149 | rake (12.3.3) 150 | rb-fsevent (0.10.3) 151 | rb-inotify (0.10.0) 152 | ffi (~> 1.0) 153 | request_store (1.4.1) 154 | rack (>= 1.4) 155 | rspec-core (3.8.2) 156 | rspec-support (~> 3.8.0) 157 | rspec-expectations (3.8.4) 158 | diff-lcs (>= 1.2.0, < 2.0) 159 | rspec-support (~> 3.8.0) 160 | rspec-mocks (3.8.1) 161 | diff-lcs (>= 1.2.0, < 2.0) 162 | rspec-support (~> 3.8.0) 163 | rspec-rails (3.8.2) 164 | actionpack (>= 3.0) 165 | activesupport (>= 3.0) 166 | railties (>= 3.0) 167 | rspec-core (~> 3.8.0) 168 | rspec-expectations (~> 3.8.0) 169 | rspec-mocks (~> 3.8.0) 170 | rspec-support (~> 3.8.0) 171 | rspec-support (3.8.2) 172 | ruby_dep (1.5.0) 173 | safe_yaml (1.0.5) 174 | sass-rails (6.0.0) 175 | sassc-rails (~> 2.1, >= 2.1.1) 176 | sassc (2.1.0) 177 | ffi (~> 1.9) 178 | sassc-rails (2.1.2) 179 | railties (>= 4.0.0) 180 | sassc (>= 2.0) 181 | sprockets (> 3.0) 182 | sprockets-rails 183 | tilt 184 | settingslogic (2.0.9) 185 | shellany (0.0.1) 186 | shotgun (0.9.2) 187 | rack (>= 1.0) 188 | sprockets (3.7.2) 189 | concurrent-ruby (~> 1.0) 190 | rack (> 1, < 3) 191 | sprockets-rails (3.2.1) 192 | actionpack (>= 4.0) 193 | activesupport (>= 4.0) 194 | sprockets (>= 3.0.0) 195 | thor (0.20.3) 196 | thread_safe (0.3.6) 197 | tilt (2.0.9) 198 | tzinfo (1.2.5) 199 | thread_safe (~> 0.1) 200 | tzinfo-data (1.2019.2) 201 | tzinfo (>= 1.0.0) 202 | unicorn (5.5.1) 203 | kgio (~> 2.6) 204 | raindrops (~> 0.7) 205 | webmock (3.6.2) 206 | addressable (>= 2.3.6) 207 | crack (>= 0.3.2) 208 | hashdiff (>= 0.4.0, < 2.0.0) 209 | 210 | PLATFORMS 211 | ruby 212 | 213 | DEPENDENCIES 214 | docker-api 215 | guard-rails 216 | lograge 217 | nats 218 | omniauth-uaa-oauth2 219 | rails (~> 4) 220 | rails-api 221 | rspec-rails 222 | sass-rails (>= 6.0.0) 223 | settingslogic 224 | shotgun 225 | tzinfo-data 226 | unicorn 227 | webmock 228 | 229 | RUBY VERSION 230 | ruby 2.5.5p157 231 | 232 | BUNDLED WITH 233 | 1.17.3 234 | -------------------------------------------------------------------------------- /DOCKER.md: -------------------------------------------------------------------------------- 1 | # Docker backend 2 | 3 | This backend will use [Docker](https://www.docker.com/) for container management. It will leverage the 4 | [Docker Remote API](https://docs.docker.com/reference/api/docker_remote_api/) over a unix socket or tcp connection to 5 | perform actions against Docker. It supports: 6 | 7 | * Prefetching Docker images when the broker is started to speed up containers creation. 8 | * Creating Docker containers when the broker provisions a service. 9 | * Injecting service arbitrary parameters into the Docker container via environment variables on provision time. 10 | * Creating random usernames, passwords and dbnames when binding an application to the service. Those credentials are 11 | sent to the Docker container via environment variables, so the Docker image must support those variables in order to 12 | create the right username/password and dbname (see [CREDENTIALS.md](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/CREDENTIALS.md) 13 | for details). 14 | * Exposing a container port where the bound applications can drain their logs 15 | (see [SYSLOG_DRAIN.md](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/SYSLOG_DRAIN.md) 16 | for details). 17 | * Destroying Docker containers when the broker unprovisions a service. 18 | * Exposing a Management Dashboard with Docker container information, top processes running inside the container, 19 | and the latest stdout and stderr logs. 20 | 21 | ## Prerequisites 22 | 23 | The service broker does not deploy Docker, so you must have a Docker daemon up and running. 24 | 25 | **You must use Docker 1.6 or greater.** 26 | 27 | If you are running Docker locally as a socket, there is no setup to do. If you are not or you have changed the path of 28 | the socket, you will have to set the `DOCKER_URL` environment variable to point to your socket or local/remote port. 29 | For example: 30 | 31 | ``` 32 | DOCKER_URL=unix:///var/run/docker.sock 33 | DOCKER_URL=tcp://localhost:4243 34 | ``` 35 | 36 | Remember that if you are running this service broker as a Docker container and the Docker remote API is going to use 37 | the unix sockets, you must expose the container's directory `/var/run` to the host directory containing the Docker 38 | unix socket: 39 | 40 | ``` 41 | docker run -d --name cf-containers-broker \ 42 | --publish 80:80 \ 43 | --volume /var/run:/var/run \ 44 | frodenas/cf-containers-broker 45 | ``` 46 | 47 | ## Properties format 48 | 49 | Each service `plan` defined at the [settings](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/SETTINGS.md) file must contain the following properties: 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 123 | 124 | 125 | 126 | 127 | 128 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 |
FieldRequiredTypeDescription
containerYHashProperties of the container to deploy.
container.backendYStringContainer Backend. It must be `docker`.
container.imageYStringName of the image fo fetch and run. The image will be pre-fetched at broker startup.
container.tagNStringTag of the image. If not set, it will use `latest` by default.
container.commandNStringCommand to run the container (including arguments).
container.entrypointNArray of StringsEntrypoint for the container (only if you want to override the default entrypoint set by the image).
container.workdirNStringWorking directory inside the container.
container.restartNStringRestart policy to apply when a container exits (no, on-failure, always). If not set, 105 | it will use `always` by default. The restart policy will apply also in case the VM hosting the container is 106 | killed and CF/BOSH resurrects it. Might happen that the new VM gets a new IP address, and probably the containers 107 | will use a new random port. In order to make any application bound to a container work again, 108 | the user must unbind/bind the application to the service again in order to pick the new IP/port. If you want to preserve the bound host ports, you must set `allocate_docker_host_ports` setting [1].
container.environment[]NArray of StringsEnvironment variables to pass to the container.
container.expose_ports[]NArray of StringsNetwork ports to map from the container to random host ports (format: port</protocol>). If not set, 121 | the broker will inspect the Docker image and it will expose all declared container ports [2] to a random host 122 | port.
container.persistent_volumes[]NArray of StringsVolume mountpoints to bind from the container to a host directory. The broker will create automatically a 129 | host directory and it will bind it to the container volume mountpoint.
container.userNStringUsername or UID to run the first container process.
container.memoryNStringMemory limit to assign to the container (format: number<optional unit>, where unit = b, k, m or g).
container.memory_swapNStringMemory swap limit to assign to the container (format: number<optional unit>, where unit = b, k, m or g).
container.cpu_sharesNStringCPU shares to assign to the container (relative weight).
container.privilegedNBooleanEnable/disable extended privileges for this container.
container.cap_adds[]NArray of StringsLinux capabilities to add
container.cap_drops[]NArray of StringsLinux capabilities to drop
174 | 175 | [1] See [SETTINGS.md](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/SETTINGS.md) 176 | [2] See the Docker builder [EXPOSE](https://docs.docker.com/reference/builder/#expose) instruction 177 | 178 | ## Example 179 | 180 | This example will create a [MongoDB 2.6](http://www.mongodb.org/) service using the Docker image 181 | `frodenas/mongodb:2.6` ([Dockerfile](https://github.com/frodenas/docker-mongodb)). When the container is 182 | started, it will use the default entrypoint and the following command arguments `--smallfiles 183 | --httpinterface`. It will expose the container volume `/data` to a host directory created automatically by the 184 | service broker. 185 | 186 | ```yaml 187 | container: 188 | backend: 'docker' 189 | image: 'frodenas/mongodb' 190 | tag: '2.6' 191 | command: '--smallfiles --httpinterface' 192 | persistent_volumes: 193 | - '/data' 194 | ``` 195 | -------------------------------------------------------------------------------- /spec/controllers/manage/instances_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe Manage::InstancesController do 6 | let(:service_guid) { 'service-guid' } 7 | let(:plan_guid) { 'plan-guid' } 8 | let(:instance_guid) { 'instance-guid' } 9 | let(:ssl_enabled) { false } 10 | let(:session_expiry) { 5 } 11 | let(:uaa_session) { double('UaaSession', access_token: 'access-token', auth_header: 'auth-header') } 12 | let(:token_hash) { { 'scope' => %w(openid cloud_controller_service_permissions.read) } } 13 | let(:cc_client) { double('CloudControllerHttpClient') } 14 | let(:service) { double('Service', name: 'service', description: 'service', metadata: {}) } 15 | let(:plan) { double('Plan', name: 'plan', description: 'plan') } 16 | let(:container_manager) { double('ContainerManager', backend: 'docker') } 17 | let(:container) { double('Container') } 18 | 19 | describe '#show' do 20 | render_views 21 | 22 | let(:make_request) do 23 | get :show, { service_guid: service_guid, plan_guid: plan_guid, instance_guid: instance_guid } 24 | end 25 | 26 | before do 27 | allow(Settings).to receive(:ssl_enabled).and_return(ssl_enabled) 28 | allow(Settings).to receive(:session_expiry).and_return(session_expiry) 29 | end 30 | 31 | describe 'when ssl is enabled' do 32 | let(:ssl_enabled) { true } 33 | 34 | context 'and protocol is http' do 35 | it 'redirects to https' do 36 | @request.env['HTTPS'] = nil 37 | make_request 38 | 39 | expect(response.status).to eql(302) 40 | expect(response).to redirect_to(:protocol => 'https://') 41 | end 42 | end 43 | end 44 | 45 | describe 'when the user is not logged in' do 46 | context 'and there is no uaa session' do 47 | it 'redirects to auth' do 48 | make_request 49 | 50 | expect(response.status).to eql(302) 51 | expect(response).to redirect_to('/manage/auth/cloudfoundry') 52 | end 53 | end 54 | 55 | context 'and there is a uaa session' do 56 | before do 57 | session[:uaa_user_id] = 'uaa-user-id' 58 | session[:uaa_access_token] = 'uaa-access-token' 59 | end 60 | 61 | context 'but the last_seen has expired' do 62 | before do 63 | session[:last_seen] = Time.now - (session_expiry + 1) 64 | end 65 | 66 | it 'redirects to auth' do 67 | make_request 68 | 69 | expect(response.status).to eql(302) 70 | expect(response).to redirect_to('/manage/auth/cloudfoundry') 71 | end 72 | end 73 | end 74 | end 75 | 76 | describe 'when the user does not have the necessary scopes' do 77 | let(:token_hash) { { 'scope' => %w(scope) } } 78 | 79 | before do 80 | session[:uaa_user_id] = 'uaa-user-id' 81 | session[:uaa_access_token] = 'uaa-access-token' 82 | session[:uaa_refresh_token] = 'uaa-refresh-token' 83 | session[:last_seen] = Time.now 84 | expect(UaaSession).to receive(:build).with('uaa-access-token', 'uaa-refresh-token', service_guid).and_return(uaa_session) 85 | expect(CF::UAA::TokenCoder).to receive(:decode).with('access-token', verify: false).and_return(token_hash) 86 | allow(Configuration).to receive(:manage_user_profile_url).and_return('login.com/profile') 87 | end 88 | 89 | it 'redirects to auth' do 90 | make_request 91 | 92 | expect(response.status).to eql(302) 93 | expect(response).to redirect_to('/manage/auth/cloudfoundry') 94 | expect(session[:has_retried]).to eq('true') 95 | end 96 | 97 | context 'and it is a retry' do 98 | before do 99 | session[:has_retried] = 'true' 100 | end 101 | 102 | it 'renders the approvals_error template' do 103 | make_request 104 | 105 | expect(response.status).to eq(200) 106 | expect(response).to render_template('errors/approvals_error') 107 | expect(session[:has_retried]).to eq('false') 108 | end 109 | end 110 | end 111 | 112 | context 'when the user does not have permission to manage the instance' do 113 | before do 114 | session[:uaa_user_id] = 'uaa-user-id' 115 | session[:uaa_access_token] = 'uaa-access-token' 116 | session[:uaa_refresh_token] = 'uaa-refresh-token' 117 | session[:last_seen] = Time.now 118 | expect(UaaSession).to receive(:build).with('uaa-access-token', 'uaa-refresh-token', service_guid).and_return(uaa_session) 119 | expect(CF::UAA::TokenCoder).to receive(:decode).with('access-token', verify: false).and_return(token_hash) 120 | expect(CloudControllerHttpClient).to receive(:new).with('auth-header').and_return(cc_client) 121 | expect(cc_client).to receive(:get).with("/v2/service_instances/#{instance_guid}/permissions").and_return(nil) 122 | end 123 | 124 | it 'renders the not_authorized template' do 125 | make_request 126 | 127 | expect(response.status).to eql(200) 128 | expect(response).to render_template('errors/not_authorized') 129 | end 130 | end 131 | 132 | describe 'when the user is logged in, has the necessary scopes and permission to manage the instance' do 133 | before do 134 | session[:uaa_user_id] = 'uaa-user-id' 135 | session[:uaa_access_token] = 'uaa-access-token' 136 | session[:uaa_refresh_token] = 'uaa-refresh-token' 137 | session[:last_seen] = Time.now 138 | expect(UaaSession).to receive(:build).with('uaa-access-token', 'uaa-refresh-token', service_guid).and_return(uaa_session) 139 | expect(CF::UAA::TokenCoder).to receive(:decode).with('access-token', verify: false).and_return(token_hash) 140 | expect(CloudControllerHttpClient).to receive(:new).with('auth-header').and_return(cc_client) 141 | expect(cc_client).to receive(:get).with("/v2/service_instances/#{instance_guid}/permissions").and_return('manage') 142 | end 143 | 144 | it 'returns a 200' do 145 | expect(Catalog).to receive(:find_service_by_guid).with(service_guid).and_return(service) 146 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_guid).and_return(plan) 147 | expect(plan).to receive(:container_manager).exactly(6).and_return(container_manager) 148 | expect(container_manager).to receive(:find).with(instance_guid).and_return(container) 149 | expect(container_manager).to receive(:details).with(instance_guid) 150 | expect(container_manager).to receive(:processes).with(instance_guid) 151 | expect(container_manager).to receive(:stdout).with(instance_guid) 152 | expect(container_manager).to receive(:stderr).with(instance_guid) 153 | 154 | make_request 155 | 156 | expect(response.status).to eql(200) 157 | end 158 | 159 | context 'and the service does not exist' do 160 | before do 161 | expect(Catalog).to receive(:find_service_by_guid).with(service_guid).and_return(nil) 162 | end 163 | 164 | it 'returns a 404' do 165 | make_request 166 | 167 | expect(response.status).to eql(404) 168 | expect(JSON.parse(response.body)).to eq({ 169 | 'description' => "Cannot create a service instance. Service #{service_guid} was not found in the catalog" 170 | }) 171 | end 172 | end 173 | 174 | context 'and the service plan does not exist' do 175 | before do 176 | expect(Catalog).to receive(:find_service_by_guid).with(service_guid).and_return(service) 177 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_guid).and_return(nil) 178 | end 179 | 180 | it 'returns a 404' do 181 | make_request 182 | 183 | expect(response.status).to eql(404) 184 | expect(JSON.parse(response.body)).to eq({ 185 | 'description' => "Cannot create a service instance. Plan #{plan_guid} was not found in the catalog" 186 | }) 187 | end 188 | end 189 | 190 | context 'and the service instance does not exist' do 191 | before do 192 | expect(Catalog).to receive(:find_service_by_guid).with(service_guid).and_return(service) 193 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_guid).and_return(plan) 194 | expect(plan).to receive(:container_manager).and_return(container_manager) 195 | expect(container_manager).to receive(:find).with(instance_guid).and_return(nil) 196 | end 197 | 198 | it 'returns a 404' do 199 | make_request 200 | 201 | expect(response.status).to eql(404) 202 | expect(JSON.parse(response.body)).to eq({ 203 | 'description' => "Cannot create a service instance. Instance #{instance_guid} was not found" 204 | }) 205 | end 206 | end 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /spec/controllers/v2/service_instances_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe V2::ServiceInstancesController do 6 | let(:service_id) { 'service-id' } 7 | let(:plan_id) { 'plan-id' } 8 | let(:instance_id) { 'instance-id' } 9 | let(:organization_guid) { 'organization-guid' } 10 | let(:space_guid) { 'space_guid' } 11 | let(:plan) { double('Plan', max_containers: max_plan_containers) } 12 | let(:parameters) { { 'foo' => 'bar' } } 13 | let(:container_manager) { double('ContainerManager') } 14 | let(:container) { double('Container') } 15 | let(:can_allocate) { true } 16 | let(:max_containers) { 1 } 17 | let(:max_plan_containers) { 1 } 18 | let(:external_host) { 'my-host' } 19 | let(:ssl_enabled) { false } 20 | 21 | before do 22 | authenticate 23 | allow(Docker).to receive(:version).and_return({ 'ApiVersion' => DockerManager::MIN_SUPPORTED_DOCKER_API_VERSION }) 24 | end 25 | 26 | describe 'create - #update' do 27 | let(:make_request) do 28 | put :update, { id: instance_id, service_id: service_id, plan_id: plan_id, organization_guid: organization_guid, space_guid: space_guid, parameters: parameters } 29 | end 30 | 31 | before do 32 | allow(Settings).to receive(:max_containers).and_return(max_containers) 33 | allow(Settings).to receive(:ssl_enabled).and_return(ssl_enabled) 34 | end 35 | 36 | it_behaves_like 'a controller action that requires basic auth' 37 | 38 | it_behaves_like 'a controller action that logs its request and response headers and body' 39 | 40 | context 'when the service instance is created' do 41 | before do 42 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(plan) 43 | expect(plan).to receive(:container_manager).exactly(3).and_return(container_manager) 44 | expect(container_manager).to receive(:can_allocate?) 45 | .with(max_containers, max_plan_containers) 46 | .and_return(can_allocate) 47 | expect(container_manager).to receive(:find).with(instance_id).and_return(nil) 48 | expect(container_manager).to receive(:create).with(instance_id, parameters) 49 | expect(Settings).to receive(:external_host).and_return(external_host) 50 | end 51 | 52 | it 'returns a 201' do 53 | make_request 54 | 55 | expect(response.status).to eq(201) 56 | end 57 | 58 | it 'returns a hash with a dashboard_url' do 59 | make_request 60 | 61 | expect(JSON.parse(response.body)).to eq({ 62 | 'dashboard_url' => "http://#{external_host}/manage/instances/#{service_id}/#{plan_id}/#{instance_id}" 63 | }) 64 | end 65 | 66 | context 'and ssl is enabled' do 67 | let(:ssl_enabled) { true } 68 | 69 | it 'returns a hash with a dashboard_url using https' do 70 | make_request 71 | 72 | expect(JSON.parse(response.body)).to eq({ 73 | 'dashboard_url' => "https://#{external_host}/manage/instances/#{service_id}/#{plan_id}/#{instance_id}" 74 | }) 75 | end 76 | end 77 | end 78 | 79 | context 'when service capacity has been reached' do 80 | let(:can_allocate) { false } 81 | 82 | it 'returns a 507' do 83 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(plan) 84 | expect(plan).to receive(:container_manager).and_return(container_manager).twice 85 | expect(container_manager).to receive(:find) 86 | .with(instance_id) 87 | .and_return(nil) 88 | expect(container_manager).to receive(:can_allocate?) 89 | .with(max_containers, max_plan_containers) 90 | .and_return(can_allocate) 91 | 92 | make_request 93 | 94 | expect(response.status).to eq(507) 95 | expect(JSON.parse(response.body)).to eq({ 'description' => 'Service capacity has been reached' }) 96 | end 97 | end 98 | 99 | context 'when the service plan does not exist' do 100 | it 'returns a 404' do 101 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(nil) 102 | 103 | make_request 104 | 105 | expect(response.status).to eq(404) 106 | expect(JSON.parse(response.body)).to eq({ 107 | 'description' => "Cannot create a service instance. Plan #{plan_id} was not found in the catalog" 108 | }) 109 | end 110 | end 111 | end 112 | 113 | describe 'patch - #update' do 114 | let(:prev_plan_id) { 'prev-plan-id' } 115 | let(:make_request) do 116 | put :update, { 117 | id: instance_id, plan_id: plan_id, service_id: service_id, organization_guid: organization_guid, space_guid: space_guid, parameters: parameters, 118 | previous_values: {plan_id: prev_plan_id, service_id: service_id, organization_guid: organization_guid, space_guid: space_guid} 119 | } 120 | end 121 | 122 | before do 123 | allow(Settings).to receive(:max_containers).and_return(max_containers) 124 | allow(Settings).to receive(:ssl_enabled).and_return(ssl_enabled) 125 | end 126 | 127 | it_behaves_like 'a controller action that requires basic auth' 128 | 129 | it_behaves_like 'a controller action that logs its request and response headers and body' 130 | 131 | context 'when the service instance is update' do 132 | let(:instance) { double('ServiceInstance') } 133 | 134 | before do 135 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(plan) 136 | expect(plan).to receive(:container_manager).exactly(2).and_return(container_manager) 137 | expect(container_manager).to_not receive(:can_allocate?) 138 | expect(container_manager).to receive(:find).with(instance_id).and_return(instance) 139 | expect(container_manager).to receive(:update).with(instance_id, parameters) 140 | end 141 | 142 | it 'returns a 200' do 143 | make_request 144 | 145 | expect(response.status).to eq(200) 146 | end 147 | 148 | it 'returns an empty hash' do 149 | make_request 150 | 151 | expect(JSON.parse(response.body)).to eq({}) 152 | end 153 | end 154 | 155 | context 'when the service plan does not exist' do 156 | it 'returns a 404' do 157 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(nil) 158 | 159 | make_request 160 | 161 | expect(response.status).to eq(404) 162 | expect(JSON.parse(response.body)).to eq({ 163 | 'description' => "Cannot create a service instance. Plan #{plan_id} was not found in the catalog" 164 | }) 165 | end 166 | end 167 | end 168 | 169 | describe '#destroy' do 170 | let(:make_request) { delete :destroy, id: instance_id, service_id: service_id, plan_id: plan_id } 171 | 172 | it_behaves_like 'a controller action that requires basic auth' 173 | 174 | it_behaves_like 'a controller action that logs its request and response headers and body' 175 | 176 | context 'when the service instance exists' do 177 | before do 178 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(plan) 179 | expect(plan).to receive(:container_manager).twice.and_return(container_manager) 180 | expect(container_manager).to receive(:find).with(instance_id).and_return(true) 181 | end 182 | 183 | it 'destroys the service instance and returns a 200' do 184 | expect(container_manager).to receive(:destroy).with(instance_id) 185 | 186 | make_request 187 | 188 | expect(response.status).to eq(200) 189 | expect(JSON.parse(response.body)).to eq({}) 190 | end 191 | 192 | context 'then the service instance cannot be deleted' do 193 | it 'returns a 500' do 194 | expect(container_manager).to receive(:destroy) 195 | .with(instance_id) 196 | .and_raise(Exceptions::NotFound, 'Container not found') 197 | make_request 198 | 199 | expect(response.status).to eq(500) 200 | expect(JSON.parse(response.body)).to eq({ 'description' => 'Container not found' }) 201 | 202 | end 203 | end 204 | end 205 | 206 | context 'when the service instance does not exist' do 207 | it 'returns a 410' do 208 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(plan) 209 | expect(plan).to receive(:container_manager).and_return(container_manager) 210 | expect(container_manager).to receive(:find).with(instance_id).and_return(nil) 211 | make_request 212 | 213 | expect(response.status).to eq(410) 214 | expect(JSON.parse(response.body)).to eq({}) 215 | end 216 | end 217 | 218 | context 'when the service plan does not exist' do 219 | it 'returns a 404' do 220 | expect(Catalog).to receive(:find_plan_by_guid).with(plan_id).and_return(nil) 221 | 222 | make_request 223 | 224 | expect(response.status).to eq(404) 225 | expect(JSON.parse(response.body)).to eq({ 226 | 'description' => "Cannot delete a service instance. Plan #{plan_id} was not found in the catalog" 227 | }) 228 | end 229 | end 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Known Vulnerabilities](https://snyk.io/test/github/cloudfoundry-community/cf-containers-broker/badge.svg)](https://snyk.io/test/github/cloudfoundry-community/cf-containers-broker) 2 | 3 | # Containers Service Broker for Cloud Foundry 4 | 5 | This is a generic `Containers` broker for the Cloud Foundry [v2 services API](http://docs.cloudfoundry.org/services/api.html). 6 | 7 | This service broker allows users to provision services that runs inside a 8 | [compatible container backend](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/README.md#prerequisites) 9 | and bind applications to the service. The management tasks that the broker can perform are: 10 | 11 | * Provision a service container with random credentials and service arbitrary parameters 12 | * Bind a service container to an application: 13 | * Expose the credentials to access the provisioned service (see [CREDENTIALS.md](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/CREDENTIALS.md) for details) 14 | * Provide a syslog drain service for your application logs (see [SYSLOG_DRAIN.md](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/SYSLOG_DRAIN.md) for details) 15 | * Unbind a service container from an application 16 | * Unprovision a service container 17 | * Expose a service container management dashboard 18 | 19 | More details can be found at this [Pivotal P.O.V Blog post](https://content.pivotal.io/blog/docker-service-broker-for-cloud-foundry). 20 | 21 | ## Disclaimer 22 | 23 | This is not presently a production ready service broker. This is a work in progress. It is suitable for 24 | experimentation and may not become supported in the future. 25 | 26 | ## Usage 27 | 28 | ### Prerequisites 29 | 30 | This service broker does not include any container backend. Instead, it is meant to be deployed alongside any 31 | compatible container backend, which it manages: 32 | 33 | * [Docker](https://www.docker.com/): Instructions to configure the service broker with a Docker backend can be found 34 | at [DOCKER.md](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/DOCKER.md). 35 | 36 | ### Configuration 37 | 38 | Configure the application settings according to the instructions found at [SETTINGS.md](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/SETTINGS.md). 39 | 40 | ### Run 41 | 42 | #### Standalone 43 | 44 | Start the service broker: 45 | 46 | ``` 47 | bundle 48 | bundle exec rackup 49 | ``` 50 | 51 | The service broker will listen by default at port 9292. View the catalog API at [http://localhost:9292/v2/catalog](http://localhost:9292v2/catalog). The basic auth username is `containers` and secret is `secret`. 52 | 53 | #### As a Docker container 54 | 55 | ##### Build the image 56 | 57 | This step is optional, you can use the already built Docker image located at the 58 | [Docker Hub Registry](https://registry.hub.docker.com/u/frodenas/cf-containers-broker/). 59 | 60 | If you want to create locally the image `frodenas/cf-containers-broker` 61 | ([Dockerfile](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/Dockerfile)) execute the following 62 | command on a local cloned `cf-containers-broker` repository: 63 | 64 | ``` 65 | docker build -t frodenas/cf-containers-broker . 66 | ``` 67 | 68 | ##### Run the image 69 | 70 | To run the image and bind it to host port 80: 71 | 72 | ``` 73 | docker run -d --name cf-containers-broker \ 74 | --publish 80:80 \ 75 | --volume /var/run:/var/run \ 76 | frodenas/cf-containers-broker 77 | ``` 78 | 79 | Some aspects of configuration can be overridden with environment variables. See `config/settings.yml` for documentation and environment variables. 80 | 81 | ``` 82 | docker run -d --name cf-containers-broker \ 83 | --publish 80:80 \ 84 | --volume /var/run:/var/run \ 85 | -e BROKER_USERNAME=broker \ 86 | -e BROKER_PASSWORD=password \ 87 | -e EXTERNAL_HOST=localhost \ 88 | frodenas/cf-containers-broker 89 | ``` 90 | 91 | If you want to override the entire configuration, then create a directory with the configuration files, and mount this directory into the container's `/config` directory: 92 | 93 | ``` 94 | mkdir -p /tmp/cf-containers-broker/config 95 | cp config/settings.yml /tmp/cf-containers-broker/config 96 | cp config/unicorn.conf.rb /tmp/cf-containers-broker/config 97 | vi /tmp/cf-containers-broker/config/settings.yml 98 | docker run -d --name cf-containers-broker \ 99 | --publish 80:80 \ 100 | --volume /var/run:/var/run \ 101 | --volume /tmp/cf-containers-broker/config:/config \ 102 | frodenas/cf-containers-broker 103 | ``` 104 | 105 | If you want to expose the application logs, create a host directory and mount the container's directory `/app/log` 106 | into the previous host directory: 107 | 108 | ``` 109 | mkdir -p /tmp/cf-containers-broker/logs 110 | docker run -d --name cf-containers-broker \ 111 | --publish 80:80 \ 112 | --volume /var/run:/var/run \ 113 | --volume /tmp/cf-containers-broker/logs:/app/log \ 114 | frodenas/cf-containers-broker 115 | ``` 116 | 117 | 118 | #### Using CF/BOSH 119 | 120 | This service broker can be deployed alongside: 121 | 122 | * [Docker CF-BOSH release](https://github.com/cloudfoundry-community/docker-boshrelease) if you plan to use Docker as backend. 123 | 124 | ### Enable the service broker at your Cloud Foundry environment 125 | 126 | Add the service broker to Cloud Foundry as described by [the service broker documentation](http://docs.cloudfoundry.org/services/managing-service-brokers.html). 127 | 128 | A quick way to register the service broker and to enable all service offerings is running: 129 | 130 | ``` 131 | cf create-service-broker docker-broker containers secret http:// 132 | while read p __; do 133 | cf enable-service-access "$p"; 134 | done < <(cf service-access | awk '/orgs/{y=1;next}y && NF' | sort | uniq) 135 | ``` 136 | 137 | ### Bindings 138 | 139 | The way that each service is configured determines how binding credentials are generated. 140 | 141 | A service that exposes only a single port and has no other credentials configuration will include the minimal host and port in its credentials: 142 | 143 | ```json 144 | { "host": "10.11.12.13", "port": 61234, "ports": ["8080/tcp": 61234] } 145 | ``` 146 | 147 | In the example above, the container exposed an internal port `8080` and it was bound to port `61234` on the host machine `10.11.12.13`. 148 | 149 | If a service exposes more than a single port, then you must specify the port you want to bind using the `credentials.uri.port` property, 150 | otherwise the binding will not contain a port. 151 | 152 | ```json 153 | { "host": "10.11.12.13", "port": 61234, "ports": ["8080/tcp": 61234, "8081/tcp": 61235] } 154 | ``` 155 | 156 | In the example above, the container exposed internal ports `8080` and `8081`, and it was bound to port `61234` on the 157 | host machine `10.11.12.13` because the `credentials.uri.port` property was set to `8080/tcp`. 158 | 159 | For more details, see the [CREDENTIALS.md](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/CREDENTIALS.md) file. 160 | 161 | #### Self-discovery of host port bindings 162 | 163 | Optionally, each exposed host port for an instantiated container will passed into the container via environment variables, if you enable the `enable_host_port_envvar: true` setting. 164 | 165 | If a Docker image exposes an internal port `5432`, then each instantiated container will be provided a `DOCKER_HOST_PORT_5432` environment variable containing the host's port allocation. 166 | 167 | Implementation detail: In order to support this feature, provisioning new Docker containers requires two steps: 168 | 169 | 1. Instantiate a Docker container and allow Docker to allocate host ports. 170 | 2. Restart the Docker container with the additional `DOCKER_HOST_PORT_nnnn` environment variables. 171 | 172 | If you wish to enable this feature, provide `enable_host_port_envvar: true` in `config/settings.yml`. 173 | 174 | ### Updating Containers 175 | 176 | When new images become available or configuration of the plans change it can become desirable to restart the running containers to pick up the latest version of their image and/or update their configuration. 177 | 178 | `bin/update_all_containers` will attempt to find all running containers managed by the broker and restart them with the latest configuration. 179 | 180 | The mapping between running containers and configured plans is achieved by adding the labels `plan_id` and `instance_id` to the containers at create time. Containers that don't have these labels will be ignored by the update script. 181 | 182 | If you are updating cf-containers-broker from an older version that didn't add the required labels you can force the broker to recreate the containers via `cf update-service `. After this the labels will be available and the `bin/update_all_containers` script will be able to identify the containers for automatic updating. 183 | 184 | In order for `cf update-service ` to work the service must declare `plan_updateable: true`. 185 | 186 | 187 | ### Tests 188 | 189 | To run all specs: 190 | 191 | ``` 192 | bundle 193 | bundle exec rake spec 194 | ``` 195 | 196 | Be aware that this project does not yet provide a full set of tests. Contributions are welcomed! 197 | 198 | ## Contributing 199 | 200 | In the spirit of [free software](http://www.fsf.org/licensing/essays/free-sw.html), **everyone** is encouraged to help 201 | improve this project. 202 | 203 | Here are some ways *you* can contribute: 204 | 205 | * by using alpha, beta, and prerelease versions 206 | * by reporting bugs 207 | * by suggesting new features 208 | * by writing or editing documentation 209 | * by writing specifications 210 | * by writing code (**no patch is too small**: fix typos, add comments, clean up inconsistent whitespace) 211 | * by refactoring code 212 | * by closing [issues](https://github.com/cloudfoundry-community/cf-containers-broker/issues) 213 | * by reviewing patches 214 | 215 | 216 | ### Submitting an Issue 217 | 218 | We use the [GitHub issue tracker](https://github.com/cloudfoundry-community/cf-containers-broker/issues) to track bugs and 219 | features. Before submitting a bug report or feature request, check to make sure it hasn't already been submitted. You 220 | can indicate support for an existing issue by voting it up. When submitting a bug report, please include a 221 | [Gist](http://gist.github.com/) that includes a stack trace and any details that may be necessary to reproduce the bug, 222 | including your gem version, Ruby version, and operating system. Ideally, a bug report should include a pull request 223 | with failing specs. 224 | 225 | ### Submitting a Pull Request 226 | 227 | 1. Fork the project. 228 | 2. Create a topic branch. 229 | 3. Implement your feature or bug fix. 230 | 4. Commit and push your changes. 231 | 5. Submit a pull request. 232 | 233 | ## Copyright 234 | 235 | See [LICENSE](https://github.com/cloudfoundry-community/cf-containers-broker/blob/master/LICENSE) for details. 236 | Copyright (c) 2014 [Pivotal Software, Inc](http://www.gopivotal.com/). 237 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [2014] [Pivotal Software, Inc] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /spec/models/credentials_spec.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require 'spec_helper' 4 | 5 | describe Credentials do 6 | let(:subject) { described_class.new(attrs) } 7 | let(:attrs) { 8 | { 9 | 'username' => { 10 | 'key' => username_key, 11 | 'value' => username_value, 12 | }, 13 | 'password' => { 14 | 'key' => password_key, 15 | 'value' => password_value, 16 | }, 17 | 'dbname' => { 18 | 'key' => dbname_key, 19 | 'value' => dbname_value, 20 | }, 21 | 'uri' => { 22 | 'prefix' => uri_prefix, 23 | 'port' => uri_port, 24 | }, 25 | } 26 | } 27 | let(:username_key) { 'SERVICE_USERNAME' } 28 | let(:username_value) { 'MY-USERNAME' } 29 | let(:password_key) { 'SERVICE_PASSWORD' } 30 | let(:password_value) { 'MY-PASSWORD' } 31 | let(:dbname_key) { 'SERVICE_DBNAME' } 32 | let(:dbname_value) { 'MY-PASSWORD' } 33 | let(:uri_prefix) { 'myprefix' } 34 | let(:uri_port) { '1234/tcp' } 35 | let(:guid) { 'guid' } 36 | let(:md5_base64) { '0123456789ABCDEFGHIJK'} 37 | 38 | describe '#initialize' do 39 | it 'sets the attributes correctly' do 40 | expect(subject.credentials).to eq(attrs) 41 | end 42 | end 43 | 44 | describe '#username_key' do 45 | it 'returns the username.key' do 46 | expect(subject.username_key).to eq(username_key) 47 | end 48 | 49 | context 'when username.key is not set' do 50 | let(:username_key) { nil } 51 | 52 | it 'returns nil' do 53 | expect(subject.username_key).to be_nil 54 | end 55 | end 56 | end 57 | 58 | describe '#username_value' do 59 | it 'returns the username.value' do 60 | expect(subject.username_value(guid)).to eq(username_value) 61 | end 62 | 63 | context 'when username.value is not set' do 64 | let(:username_value) { nil } 65 | 66 | it 'returns the 1st 16 chars of the guid MD5 digest in lowercase' do 67 | expect(Digest::MD5).to receive(:base64digest).with("USER-#{guid}").and_return(md5_base64) 68 | expect(subject.username_value(guid)).to eql(md5_base64[0...16].downcase) 69 | end 70 | end 71 | end 72 | 73 | describe '#password_key' do 74 | it 'returns the password.key' do 75 | expect(subject.password_key).to eq(password_key) 76 | end 77 | 78 | context 'when username.key is not set' do 79 | let(:password_key) { nil } 80 | 81 | it 'returns nil' do 82 | expect(subject.password_key).to be_nil 83 | end 84 | end 85 | end 86 | 87 | describe '#password_value' do 88 | it 'returns the password.value' do 89 | expect(subject.password_value(guid)).to eq(password_value) 90 | end 91 | 92 | context 'when password.value is not set' do 93 | let(:password_value) { nil } 94 | 95 | it 'returns the 1st 16 chars of the guid MD5 digest in lowercase' do 96 | expect(Digest::MD5).to receive(:base64digest).with("PWD-#{guid}").and_return(md5_base64) 97 | expect(subject.password_value(guid)).to eql(md5_base64[0...16].downcase) 98 | end 99 | end 100 | end 101 | 102 | describe '#dbname_key' do 103 | it 'returns the dbname.key' do 104 | expect(subject.dbname_key).to eq(dbname_key) 105 | end 106 | 107 | context 'when dbname.key is not set' do 108 | let(:dbname_key) { nil } 109 | 110 | it 'returns nil' do 111 | expect(subject.dbname_key).to be_nil 112 | end 113 | end 114 | end 115 | 116 | describe '#dbname_value' do 117 | it 'returns the dbname.value' do 118 | expect(subject.dbname_value(guid)).to eq(dbname_value) 119 | end 120 | 121 | context 'when dbname.value is not set' do 122 | let(:dbname_value) { nil } 123 | 124 | it 'returns the 1st 16 chars of the guid MD5 digest in lowercase' do 125 | expect(Digest::MD5).to receive(:base64digest).with("DB-#{guid}").and_return(md5_base64) 126 | expect(subject.dbname_value(guid)).to eql(md5_base64[0...16].downcase) 127 | end 128 | end 129 | end 130 | 131 | describe '#uri_prefix' do 132 | it 'returns the uri.prefix' do 133 | expect(subject.uri_prefix).to eq(uri_prefix) 134 | end 135 | 136 | context 'when uri.prefix is not set' do 137 | let(:uri_prefix) { nil } 138 | 139 | it 'returns nil' do 140 | expect(subject.uri_prefix).to be_nil 141 | end 142 | end 143 | end 144 | 145 | describe '#uri_port' do 146 | it 'returns the uri.port' do 147 | expect(subject.uri_port).to eq(uri_port) 148 | end 149 | 150 | context 'when uri.port is not set' do 151 | let(:uri_port) { nil } 152 | 153 | it 'returns nil' do 154 | expect(subject.uri_port).to be_nil 155 | end 156 | end 157 | end 158 | 159 | describe '#to_hash' do 160 | let(:hostname) { 'hostname' } 161 | let(:host_port) { '5678' } 162 | let(:ports) { { '1234/tcp' => host_port } } 163 | let(:uri) { "#{uri_prefix}://#{username_value}:#{password_value}@#{hostname}:#{host_port}/#{dbname_value}" } 164 | let(:credentials_hash) { subject.to_hash(guid, hostname, ports) } 165 | 166 | it 'contains the correct values' do 167 | expect(credentials_hash.fetch('hostname')).to eq(hostname) 168 | expect(credentials_hash.fetch('host')).to eq(hostname) 169 | expect(credentials_hash.fetch('port')).to eq(host_port) 170 | expect(credentials_hash.fetch('ports')).to eq(ports) 171 | expect(credentials_hash.fetch('username')).to eq(username_value) 172 | expect(credentials_hash.fetch('password')).to eq(password_value) 173 | expect(credentials_hash.fetch('dbname')).to eq(dbname_value) 174 | expect(credentials_hash.fetch('uri')).to eq(uri) 175 | end 176 | 177 | context 'when uri.port is set' do 178 | let(:uri_port) { '1234/tcp' } 179 | 180 | context 'and there is no exposed ports' do 181 | let(:ports) { {} } 182 | let(:uri) { "#{uri_prefix}://#{username_value}:#{password_value}@#{hostname}/#{dbname_value}" } 183 | 184 | it 'should not return ports field' do 185 | expect(credentials_hash).to_not include('ports') 186 | end 187 | 188 | it 'should not return port field' do 189 | expect(credentials_hash).to_not include('port') 190 | end 191 | 192 | it 'uri should not contain a port' do 193 | expect(credentials_hash.fetch('uri')).to eq(uri) 194 | end 195 | end 196 | 197 | context 'and there is only one exposed port' do 198 | let(:ports) { { '1234/tcp' => host_port } } 199 | let(:uri) { "#{uri_prefix}://#{username_value}:#{password_value}@#{hostname}:#{host_port}/#{dbname_value}" } 200 | 201 | it 'should return ports field' do 202 | expect(credentials_hash.fetch('ports')).to eq(ports) 203 | end 204 | 205 | it 'should return port field' do 206 | expect(credentials_hash.fetch('port')).to eq(host_port) 207 | end 208 | 209 | it 'uri should not contain a port' do 210 | expect(credentials_hash.fetch('uri')).to eq(uri) 211 | end 212 | 213 | context 'but is not the same as uri.port' do 214 | let(:uri_port) { '9012/tcp' } 215 | let(:uri) { "#{uri_prefix}://#{username_value}:#{password_value}@#{hostname}/#{dbname_value}" } 216 | 217 | it 'should not return port field' do 218 | expect(credentials_hash).to_not include('port') 219 | end 220 | 221 | it 'uri should not contain a port' do 222 | expect(credentials_hash.fetch('uri')).to eq(uri) 223 | end 224 | end 225 | end 226 | 227 | context 'and there is more than one exposed port' do 228 | let(:ports) { 229 | { 230 | '1234/tcp' => host_port, 231 | '5678/tcp' => '99999', 232 | } 233 | } 234 | let(:uri) { "#{uri_prefix}://#{username_value}:#{password_value}@#{hostname}:#{host_port}/#{dbname_value}" } 235 | 236 | it 'should return ports field' do 237 | expect(credentials_hash.fetch('ports')).to eq(ports) 238 | end 239 | 240 | it 'should return port field' do 241 | expect(credentials_hash.fetch('port')).to eq(host_port) 242 | end 243 | 244 | it 'uri should contain a port' do 245 | expect(credentials_hash.fetch('uri')).to eq(uri) 246 | end 247 | 248 | context 'but none matches uri.port' do 249 | let(:uri_port) { '9012/tcp' } 250 | let(:uri) { "#{uri_prefix}://#{username_value}:#{password_value}@#{hostname}/#{dbname_value}" } 251 | 252 | it 'should not return port field' do 253 | expect(credentials_hash).to_not include('port') 254 | end 255 | 256 | it 'uri should not contain a port' do 257 | expect(credentials_hash.fetch('uri')).to eq(uri) 258 | end 259 | end 260 | end 261 | end 262 | 263 | context 'when uri.port is not set' do 264 | let(:uri_port) { nil } 265 | 266 | context 'and there is no exposed ports' do 267 | let(:ports) { {} } 268 | let(:uri) { "#{uri_prefix}://#{username_value}:#{password_value}@#{hostname}/#{dbname_value}" } 269 | 270 | it 'should not return ports field' do 271 | expect(credentials_hash).to_not include('ports') 272 | end 273 | 274 | it 'should not return port field' do 275 | expect(credentials_hash).to_not include('port') 276 | end 277 | 278 | it 'uri should not contain a port' do 279 | expect(credentials_hash.fetch('uri')).to eq(uri) 280 | end 281 | end 282 | 283 | context 'and there is only one exposed port' do 284 | let(:ports) { { '1234/tcp' => host_port } } 285 | let(:uri) { "#{uri_prefix}://#{username_value}:#{password_value}@#{hostname}:#{host_port}/#{dbname_value}" } 286 | 287 | it 'should return ports field' do 288 | expect(credentials_hash.fetch('ports')).to eq(ports) 289 | end 290 | 291 | it 'should return port field' do 292 | expect(credentials_hash.fetch('port')).to eq(host_port) 293 | end 294 | 295 | it 'uri should contain a port' do 296 | expect(credentials_hash.fetch('uri')).to eq(uri) 297 | end 298 | end 299 | 300 | context 'and there is more than one exposed port' do 301 | let(:ports) { 302 | { 303 | '1234/tcp' => host_port, 304 | '5678/tcp' => '99999', 305 | } 306 | } 307 | let(:uri) { "#{uri_prefix}://#{username_value}:#{password_value}@#{hostname}/#{dbname_value}" } 308 | 309 | it 'should return ports field' do 310 | expect(credentials_hash.fetch('ports')).to eq(ports) 311 | end 312 | 313 | it 'should not return port field' do 314 | expect(credentials_hash).to_not include('port') 315 | end 316 | 317 | it 'uri should not contain a port' do 318 | expect(credentials_hash.fetch('uri')).to eq(uri) 319 | end 320 | end 321 | end 322 | 323 | context 'when username.key is not set' do 324 | let(:username_key) { nil } 325 | let(:uri) { "#{uri_prefix}://#{hostname}:#{host_port}/#{dbname_value}" } 326 | 327 | it 'does not return username field' do 328 | expect(credentials_hash).to_not include('username') 329 | end 330 | 331 | it 'uri does not contain username:password' do 332 | expect(credentials_hash.fetch('uri')).to eq(uri) 333 | end 334 | end 335 | 336 | context 'when password.key is not set' do 337 | let(:password_key) { nil } 338 | let(:uri) { "#{uri_prefix}://#{username_value}@#{hostname}:#{host_port}/#{dbname_value}" } 339 | 340 | it 'does not return password field' do 341 | expect(credentials_hash).to_not include('password') 342 | end 343 | 344 | it 'uri does not contain password' do 345 | expect(credentials_hash.fetch('uri')).to eq(uri) 346 | end 347 | end 348 | 349 | context 'when dbname.key is not set' do 350 | let(:dbname_key) { nil } 351 | let(:uri) { "#{uri_prefix}://#{username_value}:#{password_value}@#{hostname}:#{host_port}" } 352 | 353 | it 'does not return dbname field' do 354 | expect(credentials_hash).to_not include('dbname') 355 | end 356 | 357 | it 'uri does not contain dbname' do 358 | expect(credentials_hash.fetch('uri')).to eq(uri) 359 | end 360 | end 361 | 362 | context 'when uri.prefix is not set' do 363 | let(:uri_prefix) { nil } 364 | 365 | it 'does not return dbname field' do 366 | expect(credentials_hash).to_not include('uri') 367 | end 368 | end 369 | end 370 | end 371 | -------------------------------------------------------------------------------- /app/models/docker_manager.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # Copyright (c) 2014 Pivotal Software, Inc. All Rights Reserved. 3 | require Rails.root.join('lib/exceptions') 4 | require Rails.root.join('lib/docker_host_port_allocator') 5 | require 'docker' 6 | require 'fileutils' 7 | 8 | class DockerManager < ContainerManager 9 | include ActionView::Helpers::DateHelper 10 | 11 | MIN_SUPPORTED_DOCKER_API_VERSION = '1.13' 12 | 13 | attr_reader :host_port_allocator, :plan_id, :image, :tag, :command, :entrypoint, :restart, :workdir, 14 | :environment, :expose_ports, :persistent_volumes, :user, :memory, :memory_swap, 15 | :cpu_shares, :privileged, :cap_adds, :cap_drops 16 | 17 | def initialize(attrs) 18 | super 19 | validate_docker_attrs(attrs) 20 | validate_docker_remote_api 21 | @host_port_allocator = DockerHostPortAllocator.instance 22 | 23 | @plan_id = attrs.fetch('plan_id') 24 | @image = attrs.fetch('image') 25 | @tag = attrs.fetch('tag', 'latest') || 'latest' 26 | @command = attrs.fetch('command', '') 27 | @entrypoint = attrs.fetch('entrypoint', nil) 28 | @restart = attrs.fetch('restart', 'always') || 'always' 29 | @workdir = attrs.fetch('workdir', nil) 30 | @environment = attrs.fetch('environment', []).compact || [] 31 | @expose_ports = attrs.fetch('expose_ports', []).compact || [] 32 | @persistent_volumes = attrs.fetch('persistent_volumes', []).compact || [] 33 | @user = attrs.fetch('user', '') || '' 34 | @memory = attrs.fetch('memory', 0) || 0 35 | @memory_swap = attrs.fetch('memory_swap', 0) || 0 36 | @cpu_shares = attrs.fetch('cpu_shares', nil) 37 | @privileged = attrs.fetch('privileged', false) 38 | @cap_adds = attrs.fetch('cap_adds', []) || [] 39 | @cap_drops = attrs.fetch('cap_drops', []) || [] 40 | end 41 | 42 | def find(guid) 43 | Docker::Container.get(container_name(guid)) 44 | rescue Docker::Error::NotFoundError 45 | nil 46 | end 47 | 48 | def can_allocate?(max_containers, max_plan_containers) 49 | unless max_containers.nil? || max_containers == 0 50 | containers = Docker::Container.all 51 | return false if containers.size >= max_containers 52 | end 53 | 54 | #unless max_plan_containers.nil? || max_plan_containers == 0 55 | # plan_containers = containers.select do |container| 56 | # # TODO: look up for plan guid? 57 | # container.json['Config']['Image'] == "#{image}:#{tag}" 58 | # end 59 | # return false if plan_containers.size >= max_plan_containers 60 | #end 61 | 62 | true 63 | end 64 | 65 | def create(guid, parameters = {}) 66 | Rails.logger.info("Creating Docker container `#{container_name(guid)}'...") 67 | container_create_opts = create_options(guid, parameters) 68 | Rails.logger.info("+-> Create options: #{container_create_opts.inspect}") 69 | container = Docker::Container.create(container_create_opts) 70 | 71 | container_start_opts = start_options(guid) 72 | Rails.logger.info("Starting Docker container `#{container_name(guid)}'...") 73 | Rails.logger.info("+-> Start options: #{container_start_opts.inspect}") 74 | container.start(container_start_opts) 75 | 76 | unless container_running?(container) 77 | container.remove(v: true, force: true) rescue nil #nop 78 | destroy_volumes(guid) 79 | raise Exceptions::BackendError, "Cannot start Docker container `#{container_name(guid)}'" 80 | end 81 | 82 | if Settings["enable_host_port_envvar"] 83 | # Now restart container so it gets port binding env vars 84 | parameters = append_port_binding_envvars(parameters, container_start_opts["PortBindings"]) 85 | update(guid, parameters) 86 | end 87 | end 88 | 89 | def update(guid, parameters = {}) 90 | Rails.logger.info("Updating Docker container `#{container_name(guid)}'...") 91 | unless container = find(guid) 92 | raise Exceptions::NotFound, "Docker container `#{container_name(guid)}' not found" 93 | end 94 | port_bindings = container.json['HostConfig']['PortBindings'] 95 | container.stop('timeout' => 10) 96 | container.remove(v: true, force: true) 97 | 98 | container_create_opts = create_options(guid, parameters) 99 | Rails.logger.info("+-> Create/update options: #{container_create_opts.inspect}") 100 | container = Docker::Container.create(container_create_opts) 101 | 102 | container_start_opts = start_options(guid) 103 | container_start_opts['PortBindings'] = port_bindings 104 | Rails.logger.info("Starting Docker container `#{container_name(guid)}'...") 105 | Rails.logger.info("+-> Start options: #{container_start_opts.inspect}") 106 | container.start(container_start_opts) 107 | 108 | unless container_running?(container) 109 | container.remove(v: true, force: true) rescue nil #nop 110 | raise Exceptions::BackendError, "Cannot start Docker container `#{container_name(guid)}', volumes not deleted" 111 | end 112 | end 113 | 114 | def destroy(guid) 115 | Rails.logger.info("Destroying Docker container `#{container_name(guid)}'...") 116 | if container = find(guid) 117 | container.stop('timeout' => 10) 118 | container.remove(v: true, force: true) 119 | destroy_volumes(guid) 120 | else 121 | raise Exceptions::NotFound, "Docker container `#{container_name(guid)}' not found" 122 | end 123 | end 124 | 125 | def fetch_image 126 | Rails.logger.info("Fetching Docker image `#{image}:#{tag}'...") 127 | begin 128 | Docker::Image.create('fromImage' => "#{image}:#{tag}") 129 | rescue Exception => e 130 | Rails.logger.error("+-> Cannot fetch Docker image `#{image}:#{tag}': #{e.inspect}") 131 | raise Exceptions::BackendError, "Cannot fetch Docker image `#{image}:#{tag}" 132 | end 133 | end 134 | 135 | def update_all_containers 136 | all_containers.each do |container| 137 | guid = container.info['Config']['Labels']['instance_id'] 138 | excluded_vars = env_vars(guid).map { |var| var.split('=').first } 139 | update(guid, envvars_from_container(container, excluded_vars)) 140 | end 141 | end 142 | 143 | def service_credentials(guid) 144 | Rails.logger.info("Building credentials hash for container `#{container_name(guid)}'...") 145 | if container = find(guid) 146 | network_info = network_info(container) 147 | service_credentials = credentials.to_hash(guid, network_info['ip'], network_info['ports']) 148 | Rails.logger.info("+-> Credentials: " + service_credentials.inspect) 149 | service_credentials 150 | else 151 | raise Exceptions::NotFound, "Docker Container `#{container_name(guid)}' not found" 152 | end 153 | end 154 | 155 | def syslog_drain_url(guid) 156 | return nil unless syslog_drain_port 157 | 158 | if container = find(guid) 159 | Rails.logger.info("Building syslog_drain_url for container `#{container_name(guid)}'...") 160 | network_info = network_info(container) 161 | if port = network_info['ports'].fetch(syslog_drain_port, nil) 162 | url = "#{syslog_drain_protocol}://#{network_info['ip']}:#{port}" 163 | Rails.logger.info("+-> syslog_drain_url: #{url}") 164 | else 165 | url = nil 166 | Rails.logger.info("+-> syslog drain port #{syslog_drain_port} is not exposed") 167 | end 168 | url 169 | else 170 | raise Exceptions::NotFound, "Docker Container `#{container_name(guid)}' not found" 171 | end 172 | end 173 | 174 | def details(guid) 175 | Rails.logger.info("Building details hash for container `#{container_name(guid)}'...") 176 | if container = find(guid) 177 | container_json = container.json 178 | container_config = container_json.fetch('Config', {}) 179 | container_state = container_json.fetch('State', {}) 180 | container_hostconfig = container_json.fetch('HostConfig', {}) 181 | container_network_settings = container_json.fetch('NetworkSettings', {}) 182 | 183 | details = { 184 | 'ID' => container_json['Id'], 185 | 'Name' => container_json['Name'], 186 | 'Image' => container_config['Image'], 187 | 'Entrypoint' => container_config.fetch('Entrypoint', []).join(' '), 188 | 'Command' => container_config.fetch('Cmd', []).join(' '), 189 | 'Work Directory' => container_config['WorkingDir'], 190 | 'Environment Variables' => container_config['Env'], 191 | 'CPU Shares' => container_config['CpuShares'], 192 | 'Memory' => container_config['Memory'], 193 | 'Memory Swap' => container_config['MemorySwap'], 194 | 'User' => container_config['User'], 195 | 'Created' => "#{time_ago_in_words(Time.parse(container_json['Created']))} ago", 196 | } 197 | 198 | if container_running?(container) 199 | paused = container_state['Paused'] ? ' (Paused)' : '' 200 | details['Status'] = "Up for #{time_ago_in_words(Time.parse(container_state['StartedAt']))}" + paused 201 | details['Privileged'] = container_hostconfig['Privileged'] 202 | details['IP Address'] = container_network_settings['IPAddress'] 203 | details['Exposed Ports'] = network_info(container)['ports'].map { |cb, hp| "#{cb} -> #{hp}" } 204 | details['Exposed Volumes'] = container_hostconfig.fetch('Binds', []) 205 | else 206 | if container_state['ExitCode'] == 0 207 | details['Status'] = 'Stopped' 208 | else 209 | details['Status'] = "Exited (#{container_state['ExitCode']}) #{time_ago_in_words(Time.parse(container_state['FinishedAt']))} ago" 210 | end 211 | end 212 | 213 | Rails.logger.info("+-> details: #{details.inspect}") 214 | { 'Container Info' => details } 215 | else 216 | raise Exceptions::NotFound, "Docker Container `#{container_name(guid)}' not found" 217 | end 218 | end 219 | 220 | def processes(guid) 221 | Rails.logger.info("Retrieving processes for Docker container `#{container_name(guid)}'...") 222 | if container = find(guid) 223 | return [] unless container_running?(container) 224 | container.top 225 | else 226 | raise Exceptions::NotFound, "Docker container `#{container_name(guid)}' not found" 227 | end 228 | end 229 | 230 | def stdout(guid) 231 | Rails.logger.info("Retrieving STDOUT for Docker container `#{container_name(guid)}'...") 232 | if container = find(guid) 233 | container.logs(stdout: 1, timestamps: 1) 234 | else 235 | raise Exceptions::NotFound, "Docker container `#{container_name(guid)}' not found" 236 | end 237 | end 238 | 239 | def stderr(guid) 240 | Rails.logger.info("Retrieving STDERR for Docker container `#{container_name(guid)}'...") 241 | if container = find(guid) 242 | container.logs(stderr: 1, timestamps: 1) 243 | else 244 | raise Exceptions::NotFound, "Docker container `#{container_name(guid)}' not found" 245 | end 246 | end 247 | 248 | private 249 | 250 | def validate_docker_attrs(attrs) 251 | required_keys = %w(image plan_id) 252 | missing_keys = [] 253 | 254 | required_keys.each do |key| 255 | missing_keys << "#{key}" unless attrs.key?(key) 256 | end 257 | 258 | unless missing_keys.empty? 259 | raise Exceptions::ArgumentError, "Missing Docker parameters: #{missing_keys.join(', ')}" 260 | end 261 | end 262 | 263 | def validate_docker_remote_api 264 | api_version = Docker.version.fetch('ApiVersion', '0') 265 | # Swarm returns API version with wrong key APIVersion instead of ApiVersion, so until 266 | # https://github.com/docker/swarm/issues/687 is solved and released, work around this 267 | if api_version == '0' 268 | api_version = Docker.version.fetch('APIVersion', '0') 269 | end 270 | unless api_version >= MIN_SUPPORTED_DOCKER_API_VERSION 271 | raise Exceptions::BackendError, "Docker Remote API version `#{api_version}' not supported" 272 | end 273 | rescue Excon::Errors::SocketError => e 274 | raise Exceptions::BackendError, "Unable to connect to the Docker Remote API `#{Docker.url}': #{e.message}" 275 | end 276 | 277 | def all_containers 278 | filters = {label: ["plan_id=#{plan_id}"]}.to_json 279 | Docker::Container.all(filters: filters).map do |container| 280 | Docker::Container.get(container.id) 281 | end 282 | end 283 | 284 | def container_running?(container) 285 | container.json.fetch('State', {}).fetch('Running', false) 286 | end 287 | 288 | def create_options(guid, parameters = {}) 289 | { 290 | 'name' => container_name(guid), 291 | 'Hostname' => '', 292 | 'Domainname' => '', 293 | 'User' => user, 294 | 'AttachStdin' => false, 295 | 'AttachStdout' => true, 296 | 'AttachStderr' => true, 297 | 'Tty' => false, 298 | 'OpenStdin' => false, 299 | 'StdinOnce' => false, 300 | 'Env' => env_vars(guid, parameters), 301 | 'Cmd' => command.split(' '), 302 | 'Entrypoint' => entrypoint, 303 | 'Image' => "#{image.strip}:#{tag.strip}", 304 | 'Labels' => {'plan_id' => plan_id, 'instance_id' => guid}, 305 | 'Volumes' => {}, 306 | 'WorkingDir' => workdir, 307 | 'NetworkDisabled' => false, 308 | 'ExposedPorts' => {}, 309 | 'HostConfig' => { 310 | 'Binds' => volume_bindings(guid), 311 | 'Memory' => convert_memory(memory), 312 | 'MemorySwap' => convert_memory(memory_swap), 313 | 'CpuShares' => cpu_shares, 314 | 'PublishAllPorts' => false, 315 | 'Privileged' => privileged, 316 | }, 317 | } 318 | end 319 | 320 | def convert_memory(memory) 321 | return nil if memory.nil? 322 | return memory if memory.is_a?(Integer) 323 | 324 | unit = memory[-1, 1] 325 | case unit 326 | when 'b' 327 | memory.chop.to_i 328 | when 'k' 329 | memory.chop.to_i * 1024 330 | when 'm' 331 | memory.chop.to_i * 1024 * 1024 332 | when 'g' 333 | memory.chop.to_i * 1014 * 1024 334 | else 335 | memory 336 | end 337 | end 338 | 339 | def env_vars(guid, parameters = {}) 340 | ev = build_custom_envvars 341 | ev << build_user_envvar(guid) 342 | ev << build_password_envvar(guid) 343 | ev << build_dbname_envvar(guid) 344 | ev << build_container_envvar(guid) 345 | ev << build_parameters_envvars(parameters) 346 | ev.flatten.compact 347 | end 348 | 349 | def build_custom_envvars 350 | environment.map do |env_var| 351 | ev = env_var.split('=') 352 | "#{ev.first.strip}=#{ev.last.strip}" unless ev.empty? 353 | end.compact 354 | end 355 | 356 | def append_port_binding_envvars(parameters, port_bindings) 357 | port_bindings.each do |binding| 358 | Rails.logger.info("Update container env var from binding #{binding.inspect}") 359 | container_port_tcp, host_port_hash = binding 360 | if container_port_tcp =~ /(\d+)\/tcp/ 361 | container_port = $1 362 | host_port = host_port_hash[0]["HostPort"] 363 | end 364 | if container_port && host_port 365 | parameters["DOCKER_HOST_PORT_#{container_port}"] = host_port 366 | end 367 | end 368 | parameters 369 | end 370 | 371 | def build_user_envvar(guid) 372 | if username_key = credentials.username_key 373 | "#{username_key}=#{credentials.username_value(guid)}" 374 | else 375 | nil 376 | end 377 | end 378 | 379 | def build_password_envvar(guid) 380 | if password_key = credentials.password_key 381 | "#{password_key}=#{credentials.password_value(guid)}" 382 | else 383 | nil 384 | end 385 | end 386 | 387 | def build_dbname_envvar(guid) 388 | if dbname_key = credentials.dbname_key 389 | "#{dbname_key}=#{credentials.dbname_value(guid)}" 390 | else 391 | nil 392 | end 393 | end 394 | 395 | def build_container_envvar(guid) 396 | base = ["NAME=#{container_name(guid)}"] 397 | Dir[File.join(Settings.container_env_var_dir, "*")].each do |env_var_file| 398 | env_var_name = File.basename(env_var_file) 399 | env_var_value = File.read(env_var_file).strip 400 | base << "#{env_var_name}=#{env_var_value}" 401 | end 402 | base 403 | end 404 | 405 | def build_parameters_envvars(parameters = {}) 406 | parameters.map do |key, value| 407 | "#{key.to_s}=#{value.to_s}" 408 | end.compact 409 | end 410 | 411 | def envvars_from_container(container, exclude = []) 412 | container.info['Config']['Env'].reduce({}) do |map, var| 413 | key, value = var.split('=') 414 | map[key] = value unless exclude.include? key 415 | map 416 | end 417 | end 418 | 419 | def start_options(guid) 420 | { 421 | 'Links' => [], 422 | 'LxcConf' => {}, 423 | 'Memory' => convert_memory(memory), 424 | 'MemorySwap' => convert_memory(memory_swap), 425 | 'CpuShares' => cpu_shares, 426 | 'PortBindings' => port_bindings(guid), 427 | 'PublishAllPorts' => false, 428 | 'Privileged' => privileged, 429 | 'ReadonlyRootfs' => false, 430 | 'VolumesFrom' => [], 431 | 'CapAdd' => cap_adds, 432 | 'CapDrop' => cap_drops, 433 | 'RestartPolicy' => restart_policy, 434 | 'Devices' => [], 435 | 'Ulimits' => [], 436 | } 437 | end 438 | 439 | def volume_bindings(guid) 440 | return [] if persistent_volumes.nil? || persistent_volumes.empty? 441 | 442 | volumes = [] 443 | persistent_volumes.each do |vol| 444 | directory = File.join(host_directory, container_name(guid), vol) 445 | FileUtils.mkdir_p(directory) 446 | FileUtils.chmod_R(0777, directory) 447 | volumes << "#{directory}:#{vol}" 448 | end 449 | volumes 450 | end 451 | 452 | def destroy_volumes(guid) 453 | return [] if persistent_volumes.nil? || persistent_volumes.empty? 454 | 455 | directory = File.join(host_directory, container_name(guid)) 456 | FileUtils.remove_entry_secure(directory, true) 457 | end 458 | 459 | def host_directory 460 | Settings.host_directory 461 | end 462 | 463 | def port_bindings(guid) 464 | if expose_ports.empty? 465 | if container = find(guid) 466 | image_expose_ports = container.json.fetch('Config', {}).fetch('ExposedPorts', {}) 467 | Hash[image_expose_ports.map { |ep, _| [ep, [ host_port_binding(ep) ]] }] 468 | else 469 | Rails.logger.info("No container found for '#{guid}'") 470 | {} 471 | end 472 | else 473 | Hash[expose_ports.map { |ep| [ep, [ host_port_binding(ep) ]] }] 474 | end 475 | end 476 | 477 | def host_port_binding(port) 478 | return {} unless Settings['allocate_docker_host_ports'] 479 | return {} unless port 480 | 481 | p = port.split('/') 482 | protocol = p.last || 'tcp' 483 | return { 'HostPort' => host_port_allocator.allocate_host_port(protocol).to_s } 484 | end 485 | 486 | def network_info(container) 487 | info = {'ports' => {}} 488 | container.json.fetch('NetworkSettings', {}).fetch('Ports', {}).each do |cp, hp| 489 | unless hp.nil? || hp.empty? 490 | info['ip'] = hp.first['HostIp'] 491 | info['ports'][cp] = hp.first['HostPort'] 492 | end 493 | end 494 | # if we talk to a plain docker daemon (no swarm manager) we will see 0.0.0.0 as IP address 495 | # and should use the docker daemon IP address for the client to connect 496 | info['ip'] = Settings.external_ip if info['ip'] == '0.0.0.0' 497 | 498 | info 499 | end 500 | 501 | def restart_policy 502 | restart_args = restart.split(':') 503 | 504 | policy = { 'Name' => restart_args[0] } 505 | policy['MaximumRetryCount'] = restart_args[1].to_i if restart_args.size > 1 506 | 507 | policy 508 | end 509 | end 510 | --------------------------------------------------------------------------------