diff --git a/bin/config/redis-server.conf b/bin/config/redis-server.conf new file mode 100644 index 000000000..435ccf477 --- /dev/null +++ b/bin/config/redis-server.conf @@ -0,0 +1,3 @@ +bind 0.0.0.0 +port 5454 +loglevel debug \ No newline at end of file diff --git a/bin/stager b/bin/stager new file mode 100755 index 000000000..45295aeb6 --- /dev/null +++ b/bin/stager @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +# Copyright (c) 2009-2011 VMware, Inc. + +exec(File.expand_path("../../stager/bin/stager", __FILE__), *ARGV) diff --git a/bin/vcap b/bin/vcap index 33330d3e2..1162b77c3 100755 --- a/bin/vcap +++ b/bin/vcap @@ -79,7 +79,7 @@ class Component end def pid_file - configuration["pid"] || raise("#{@configuration_path} does not specify location of pid file") + configuration["pid"] || configuration['pid_filename'] || raise("#{@configuration_path} does not specify location of pid file") end def log_file? @@ -244,10 +244,64 @@ class NatsServer end end +class RedisServer + DEFAULT_CONFIG_PATH = File.expand_path('../config/redis-server.conf', __FILE__) + + def initialize(pid_filename, config_path=DEFAULT_CONFIG_PATH) + @config_path = config_path + @pid_filename = pid_filename + @redis_path = `which redis-server`.chomp + unless $? == 0 + STDERR.puts "Could not find redis-server, exiting.".red + exit 1 + end + @pid = read_pidfile + end + + def start + return if running? + @pid = fork do + log_file = File.join(TMP, 'redis-server.log') + stdout = File.open(log_file, 'a') + STDOUT.reopen(stdout) + stderr = File.open(log_file, 'a') + STDERR.reopen(stderr) + exec("#{@redis_path} #{@config_path}") + end + Process.detach(@pid) + File.open(@pid_filename, 'w+') {|f| f.write(@pid) } + end + + def stop + return unless running? + Process.kill('TERM', @pid) + @pid = nil + end + + private + + def read_pidfile + if File.exist?(@pid_filename) + File.read(@pid_filename).chomp.to_i + else + nil + end + end + + def running? + return false unless @pid + File.exist?(File.join('/proc', @pid.to_s)) + end +end + + module Run + DEFAULT_REDIS_PIDFILE = File.join(TMP, 'redis-server.pid') + def self.start_init nats_server = NatsServer.new nats_server.start_server + RedisServer.new(DEFAULT_REDIS_PIDFILE).start end def self.start(args) @@ -259,6 +313,7 @@ module Run # Only process this if no one else running.. running_components = components([]).select {|c| c.running?}.map{|c| c.name } return unless running_components.empty? + RedisServer.new(DEFAULT_REDIS_PIDFILE).stop nats_server = NatsServer.new return unless nats_server.is_running? nats_server.kill_server @@ -378,7 +433,7 @@ module Run private def self.core - %w(router cloud_controller dea health_manager) + %w(router cloud_controller dea health_manager stager) end def self.services diff --git a/cloud_controller/Gemfile b/cloud_controller/Gemfile index e3aeee7e9..f092aca60 100644 --- a/cloud_controller/Gemfile +++ b/cloud_controller/Gemfile @@ -10,9 +10,11 @@ gem 'logging', '>= 1.5.0' # VCAP common components gem 'vcap_common', :require => ['vcap/common', 'vcap/component'], :path => '../common' gem 'vcap_logging', :require => ['vcap/logging'] +gem 'vcap_staging', '= 0.1.2' -# XXX - Vendor once working -gem 'vcap_staging' +# For queuing staging tasks +gem 'em-hiredis' +gem 'vcap_stager', '= 0.1.3' # Databases gem 'sqlite3' @@ -45,6 +47,7 @@ gem 'bcrypt-ruby', '>= 2.1.4' gem 'ruby-hmac', :require => 'hmac-sha1' gem 'SystemTimer', :platforms => :mri_18 gem 'uuidtools' +gem 'rest-client', '= 1.6.7' # rspec-rails is outside the 'test' group in order to consistently provide Rake tasks. gem 'rspec-rails', '>= 2.4.1' diff --git a/cloud_controller/Gemfile.lock b/cloud_controller/Gemfile.lock index e2c2cfbab..481cd28f4 100644 --- a/cloud_controller/Gemfile.lock +++ b/cloud_controller/Gemfile.lock @@ -48,6 +48,8 @@ GEM builder (>= 2.1.2) daemons (1.1.2) diff-lcs (1.1.2) + em-hiredis (0.1.0) + hiredis (~> 0.3.0) em-http-request (1.0.0.beta.3) addressable (>= 2.2.3) em-socksify @@ -60,6 +62,7 @@ GEM erubis (2.6.6) abstract (>= 1.0.0) eventmachine (0.12.10) + hiredis (0.3.2) http_parser.rb (0.5.1) i18n (0.5.0) json_pure (1.5.1) @@ -103,6 +106,8 @@ GEM thor (~> 0.14.4) rake (0.8.7) rcov (0.9.9) + rest-client (1.6.7) + mime-types (>= 1.16) rspec (2.5.0) rspec-core (~> 2.5.0) rspec-expectations (~> 2.5.0) @@ -132,7 +137,13 @@ GEM tzinfo (0.3.26) uuidtools (2.1.2) vcap_logging (0.1.0) - vcap_staging (0.1.0) + vcap_stager (0.1.3) + vcap_staging (0.1.2) + nokogiri (>= 1.4.4) + rake + rspec + vcap_common + yajl-ruby (>= 0.7.9) yajl-ruby (0.8.2) PLATFORMS @@ -142,6 +153,7 @@ DEPENDENCIES SystemTimer bcrypt-ruby (>= 2.1.4) ci_reporter + em-hiredis em-http-request (~> 1.0.0.beta.3) em-redis eventmachine (~> 0.12.10) @@ -154,6 +166,7 @@ DEPENDENCIES rack-fiber_pool rails (~> 3.0.5) rcov + rest-client (= 1.6.7) rspec (>= 2.4.0) rspec-rails (>= 2.4.1) ruby-hmac @@ -163,5 +176,6 @@ DEPENDENCIES uuidtools vcap_common! vcap_logging - vcap_staging + vcap_stager (= 0.1.3) + vcap_staging (= 0.1.2) yajl-ruby (>= 0.7.9) diff --git a/cloud_controller/app/controllers/apps_controller.rb b/cloud_controller/app/controllers/apps_controller.rb index 3aaae1103..4b4c397f1 100644 --- a/cloud_controller/app/controllers/apps_controller.rb +++ b/cloud_controller/app/controllers/apps_controller.rb @@ -1,3 +1,5 @@ +require 'staging_task_manager' + class AppsController < ApplicationController before_filter :require_user, :except => [:download_staged] before_filter :find_app_by_name, :except => [:create, :list, :download_staged] @@ -83,7 +85,7 @@ def upload end def download - path = @app.package_path + path = @app.unstaged_package_path if path && File.exists?(path) send_file path else @@ -124,7 +126,13 @@ def start_update error_on_lock_mismatch(@app) @app.lock_version += 1 manager = AppManager.new(@app) - manager.stage if @app.needs_staging? + if @app.needs_staging? + if user.uses_new_stager? + stage_app(@app) + else + manager.stage + end + end manager.stop_all manager.started render :nothing => true, :status => 204 @@ -164,6 +172,18 @@ def check_update # GET /apps/:name/instances/:instance_id/files/:path' def files + # XXX - Yuck. This will have to do until we update VMC with a real + # way to fetch staging logs. + if user.uses_new_stager? && (params[:path] == 'logs/staging.log') + log = StagingTaskLog.fetch_fibered(@app.id) + if log + render :text => log.task_log + else + render :nothing => true, :status => 404 + end + return + end + # will Fiber.yield url, auth = AppManager.new(@app).get_file_url(params[:instance_id], params[:path]) raise CloudError.new(CloudError::APP_FILE_ERROR, params[:path] || '/') unless url @@ -191,6 +211,43 @@ def files private + def stage_app(app) + task_mgr = StagingTaskManager.new(:logger => CloudController.logger, + :timeout => AppConfig[:staging][:max_staging_runtime]) + dl_uri = StagingController.download_app_uri(app) + ul_hdl = StagingController.create_upload(app) + + result = task_mgr.run_staging_task(app, dl_uri, ul_hdl.upload_uri) + + # Update run count to be consistent with previous staging code + if result.was_success? + CloudController.logger.debug("Staging task for app_id=#{app.id} succeded.", :tags => [:staging]) + CloudController.logger.debug1("Details: #{result.task_log}", :tags => [:staging]) + app.update_staged_package(ul_hdl.upload_path) + app.package_state = 'STAGED' + app.update_run_count() + else + CloudController.logger.warn("Staging task for app_id=#{app.id} failed: #{result.error}", + :tags => [:staging]) + CloudController.logger.debug1("Details: #{result.task_log}", :tags => [:staging]) + raise CloudError.new(CloudError::APP_STAGING_ERROR, result.error.to_s) + end + + rescue => e + # It may be the case that upload from the stager will happen sometime in the future. + # Mark the upload as completed so that any upload that occurs in the future will fail. + if ul_hdl + StagingController.complete_upload(ul_hdl) + FileUtils.rm_f(ul_hdl.upload_path) + end + app.package_state = 'FAILED' + app.update_run_count() + raise e + + ensure + app.save! + end + def find_app_by_name # XXX - What do we want semantics to be like for multiple apps w/ same name (possible w/ contribs) @app = user.apps_owned.find_by_name(params[:name]) @@ -208,7 +265,6 @@ def find_app_by_name # App from the request params and makes the necessary AppManager calls. def update_app_from_params(app) CloudController.logger.debug "app: #{app.id || "nil"} update_from_parms" - error_on_lock_mismatch(app) app.lock_version += 1 @@ -247,7 +303,14 @@ def update_app_from_params(app) # Process any changes that require action on out part here. manager = AppManager.new(app) - manager.stage if app.needs_staging? + + if app.needs_staging? + if user.uses_new_stager? + stage_app(app) + else + manager.stage + end + end if changed.include?('state') if app.stopped? @@ -405,4 +468,6 @@ def check_has_capacity_for?(app, previous_state) raise CloudError.new(CloudError::ACCOUNT_NOT_ENOUGH_MEMORY, "#{mem_quota}M") end end + + end diff --git a/cloud_controller/app/controllers/staging_controller.rb b/cloud_controller/app/controllers/staging_controller.rb new file mode 100644 index 000000000..ea102c43b --- /dev/null +++ b/cloud_controller/app/controllers/staging_controller.rb @@ -0,0 +1,137 @@ +require 'uri' + +# Handles app downloads and droplet uploads from the stagers. +# +class StagingController < ApplicationController + skip_before_filter :fetch_user_from_token + before_filter :authenticate_stager + + class DropletUploadHandle + attr_reader :upload_id, :upload_path, :upload_uri, :app + + def initialize(app) + @app = app + @upload_id = VCAP.secure_uuid + @upload_path = File.join(AppConfig[:directories][:tmpdir], + "staged_upload_#{app.id}_#{@upload_id}.tgz") + @upload_uri = StagingController.upload_droplet_uri(app, @upload_id) + end + end + + class << self + def upload_droplet_uri(app, upload_id) + staging_uri("/staging/droplet/#{app.id}/#{upload_id}") + end + + def download_app_uri(app) + staging_uri("/staging/app/#{app.id}") + end + + def create_upload(app) + @uploads ||= {} + ret = DropletUploadHandle.new(app) + @uploads[ret.upload_id] = ret + ret + end + + def lookup_upload(upload_id) + @uploads ||= {} + @uploads[upload_id] + end + + def complete_upload(handle) + return unless @uploads + @uploads.delete(handle.upload_id) + end + + private + + def staging_uri(path) + uri = URI::HTTP.build( + :host => CloudController.bind_address, + :port => CloudController.external_port, + :userinfo => [AppConfig[:staging][:auth][:user], AppConfig[:staging][:auth][:password]], + :path => path + ) + uri.to_s + end + end + + # Handles a droplet upload from a stager + def upload_droplet + upload = nil + src_path = nil + app = App.find_by_id(params[:id]) + raise CloudError.new(CloudError::APP_NOT_FOUND) unless app + + upload = self.class.lookup_upload(params[:upload_id]) + unless upload + CloudController.logger.error("No upload set for upload_id=#{params[:upload_id]}") + raise CloudError.new(CloudError::BAD_REQUEST) + end + + if CloudController.use_nginx + src_path = params[:droplet_path] + else + src_path = params[:upload][:droplet].path + end + unless src_path && File.exist?(src_path) + CloudController.logger.error("Uploaded droplet not found at '#{src_path}'") + raise CloudError.new(CloudError::BAD_REQUEST) + end + + begin + CloudController.logger.debug("Renaming staged droplet from '#{src_path}' to '#{upload.upload_path}'") + File.rename(src_path, upload.upload_path) + rescue => e + CloudController.logger.error("Failed uploading staged droplet: #{e}", :tags => [:staging]) + CloudController.logger.error(e) + FileUtils.rm_f(upload.upload_path) + raise e + end + CloudController.logger.debug("Stager (#{request.remote_ip}) uploaded droplet to #{upload.upload_path}", + :tags => [:staging]) + render :nothing => true, :status => 200 + ensure + FileUtils.rm_f(src_path) if src_path + self.class.complete_upload(upload) if upload + end + + # Handles an app download from a stager + def download_app + app = App.find_by_id(params[:id]) + raise CloudError.new(CloudError::APP_NOT_FOUND) unless app + + path = app.unstaged_package_path + unless path && File.exists?(path) + CloudController.logger.error("Couldn't find package path for app_id=#{app.id} (stager=#{request.remote_ip})", :tags => [:staging]) + raise CloudError.new(CloudError::APP_NOT_FOUND) + end + CloudController.logger.debug("Stager (#{request.remote_ip}) requested app_id=#{app.id} @ path=#{path}", :tags => [:staging]) + + if path && File.exists?(path) + if CloudController.use_nginx + response.headers['X-Accel-Redirect'] = '/droplets/' + File.basename(path) + render :nothing => true, :status => 200 + else + send_file path + end + else + raise CloudError.new(CloudError::APP_NOT_FOUND) + end + end + + private + + def authenticate_stager + authenticate_or_request_with_http_basic do |user, pass| + if (user == AppConfig[:staging][:auth][:user]) && (pass == AppConfig[:staging][:auth][:password]) + true + else + CloudController.logger.error("Stager auth failed (user=#{user}, pass=#{pass} from #{request.remote_ip}", :tags => [:auth_failure, :staging]) + false + end + end + end + +end diff --git a/cloud_controller/app/models/app.rb b/cloud_controller/app/models/app.rb index c53980808..d0f8446f4 100644 --- a/cloud_controller/app/models/app.rb +++ b/cloud_controller/app/models/app.rb @@ -123,6 +123,15 @@ def staging_environment_data :resources => resource_requirements } end + def staging_task_properties + services = service_bindings(true).map {|sb| sb.for_staging} + { :services => services, + :framework => framework, + :runtime => runtime, + :resources => resource_requirements, + :environment => environment} + end + # Returns an array of the URLs that point to this application def mapped_urls routes.active.map {|r| r.url}.sort @@ -509,6 +518,19 @@ def remove_collaborator(user) collab.destroy if collab end + def update_staged_package(upload_path) + self.staged_package_hash = Digest::SHA1.file(upload_path).hexdigest + FileUtils.mv(upload_path, self.staged_package_path) + end + + def update_run_count + if self..staged_package_hash_changed? + self.run_count = 0 # reset + else + self.run_count += 1 + end + end + private # TODO - Remove this when the VMC client has been updated to match our new strings. diff --git a/cloud_controller/app/models/app_manager.rb b/cloud_controller/app/models/app_manager.rb index bbfbee520..69bbe43bb 100644 --- a/cloud_controller/app/models/app_manager.rb +++ b/cloud_controller/app/models/app_manager.rb @@ -1,3 +1,5 @@ +require 'vcap/stager' + class AppManager attr_reader :app diff --git a/cloud_controller/app/models/staging_task_log.rb b/cloud_controller/app/models/staging_task_log.rb new file mode 100644 index 000000000..17ed3e12f --- /dev/null +++ b/cloud_controller/app/models/staging_task_log.rb @@ -0,0 +1,59 @@ +class StagingTaskLog + class << self + attr_accessor :redis + + def key_for_id(app_id) + "staging_task_log:#{app_id}" + end + + def fetch(app_id, redis=nil) + redis ||= @redis + key = key_for_id(app_id) + result = redis.get(key) + result ? StagingTaskLog.new(app_id, result) : nil + end + + def fetch_fibered(app_id, timeout=5, redis=nil) + redis ||= @redis + f = Fiber.current + key = key_for_id(app_id) + logger = VCAP::Logging.logger('vcap.stager.task_result.fetch_fibered') + + logger.debug("Fetching result for key '#{key}' from redis") + + get_def = redis.get(key) + get_def.timeout(timeout) + get_def.errback do |e| + e = VCAP::Stager::TaskResultTimeoutError.new("Timed out fetching result") if e == nil + logger.error("Failed fetching result for key '#{key}': #{e}") + logger.error(e) + f.resume([false, e]) + end + get_def.callback do |result| + logger.debug("Fetched result for key '#{key}' => '#{result}'") + f.resume([true, result]) + end + + was_success, result = Fiber.yield + + if was_success + result ? StagingTaskLog.new(app_id, result) : nil + else + raise result + end + end + end + + attr_reader :app_id, :task_log + + def initialize(app_id, task_log) + @app_id = app_id + @task_log = task_log + end + + def save(redis=nil) + redis ||= self.class.redis + key = self.class.key_for_id(@app_id) + redis.set(key, @task_log) + end +end diff --git a/cloud_controller/app/models/user.rb b/cloud_controller/app/models/user.rb index 3f9978150..68e4c0c76 100644 --- a/cloud_controller/app/models/user.rb +++ b/cloud_controller/app/models/user.rb @@ -106,4 +106,14 @@ def no_more_apps? count end end + + + def uses_new_stager?(cfg=AppConfig) + if cfg[:staging][:new_stager_percent] \ + && ((self.id % 100) < cfg[:staging][:new_stager_percent]) + true + else + false + end + end end diff --git a/cloud_controller/config/appconfig.rb b/cloud_controller/config/appconfig.rb index 1cb6d5bba..e0e53df39 100644 --- a/cloud_controller/config/appconfig.rb +++ b/cloud_controller/config/appconfig.rb @@ -157,3 +157,8 @@ $stderr.puts "The supplied password is too short (#{pw_len} bytes), must be at least #{c.key_len} bytes. (Though only the first #{c.key_len} will be used.)" exit 1 end + +if AppConfig[:staging][:new_stager_percent] && !AppConfig[:redis] + $stderr.puts "You must supply a redis config to use the new stager" + exit 1 +end diff --git a/cloud_controller/config/cloud_controller.yml b/cloud_controller/config/cloud_controller.yml index 0d4990132..e25e49c8a 100644 --- a/cloud_controller/config/cloud_controller.yml +++ b/cloud_controller/config/cloud_controller.yml @@ -30,7 +30,7 @@ nginx: # if log_file is set, it must be a fully-qualified path, # because the bin/vcap script reads it directly from the file. logging: - level: debug + level: debug2 # file: # Settings for the rails logger @@ -38,6 +38,11 @@ rails_logging: level: debug # file: +redis: + host: 127.0.0.1 + port: 5454 +# password: + directories: droplets: /var/vcap/shared/droplets resources: /var/vcap/shared/resources @@ -76,8 +81,12 @@ database_environment: # replaces database.yml staging: max_concurrent_stagers: 10 - max_staging_runtime: 120 # secs + max_staging_runtime: 30 # secs secure: false + new_stager_percent: 0 + auth: + user: zxsfhgjg + password: ZNVfdase9 allow_debug: false diff --git a/cloud_controller/config/final_stage/activate.rb b/cloud_controller/config/final_stage/activate.rb index 9ee73f858..278465e77 100644 --- a/cloud_controller/config/final_stage/activate.rb +++ b/cloud_controller/config/final_stage/activate.rb @@ -5,5 +5,6 @@ require dir.join('event_log') require dir.join('check_database') require dir.join('message_bus') +require dir.join('redis') require dir.join('log_boot_completion') diff --git a/cloud_controller/config/final_stage/redis.rb b/cloud_controller/config/final_stage/redis.rb new file mode 100644 index 000000000..d66acba15 --- /dev/null +++ b/cloud_controller/config/final_stage/redis.rb @@ -0,0 +1,12 @@ +if AppConfig[:redis] + EM::Hiredis.logger = CloudController.logger + + # This will be run once the event loop has started + EM.next_tick do + redis_client = EM::Hiredis::Client.new(AppConfig[:redis][:host], + AppConfig[:redis][:port], + AppConfig[:redis][:password]) + redis_client.connect + StagingTaskLog.redis = redis_client + end +end diff --git a/cloud_controller/config/routes.rb b/cloud_controller/config/routes.rb index 7686996e7..242ba069f 100644 --- a/cloud_controller/config/routes.rb +++ b/cloud_controller/config/routes.rb @@ -27,6 +27,10 @@ get 'apps/:name/update' => 'apps#check_update' put 'apps/:name/update' => 'apps#start_update' + # Stagers interact with the CC via these urls + post 'staging/droplet/:id/:upload_id' => 'staging#upload_droplet', :as => :upload_droplet + get 'staging/app/:id' => 'staging#download_app', :as => :download_unstaged_app + post 'services/v1/offerings' => 'services#create', :as => :service_create delete 'services/v1/offerings/:label' => 'services#delete', :as => :service_delete, :label => /[^\/]+/ get 'services/v1/offerings/:label/handles' => 'services#list_handles', :as => :service_list_handles, :label => /[^\/]+/ diff --git a/cloud_controller/lib/cloud_error.rb b/cloud_controller/lib/cloud_error.rb index 3db34d3a8..47ecf4324 100644 --- a/cloud_controller/lib/cloud_error.rb +++ b/cloud_controller/lib/cloud_error.rb @@ -40,6 +40,7 @@ def to_json(options = nil) APP_INVALID_RUNTIME = [307, HTTP_BAD_REQUEST, "Invalid runtime specification [%s] for framework: '%s'"] APP_INVALID_FRAMEWORK = [308, HTTP_BAD_REQUEST, "Invalid framework description: '%s'"] APP_DEBUG_DISALLOWED = [309, HTTP_BAD_REQUEST, "Cloud controller has disallowed debugging."] + APP_STAGING_ERROR = [310, HTTP_INTERNAL_SERVER_ERROR, "Staging failed: '%s'"] # Bits RESOURCES_UNKNOWN_PACKAGE_TYPE = [400, HTTP_BAD_REQUEST, "Unknown package type requested: \"%\""] @@ -62,4 +63,8 @@ def to_json(options = nil) URI_ALREADY_TAKEN = [701, HTTP_BAD_REQUEST, "The URI: \"%s\" has already been taken or reserved"] URI_NOT_ALLOWED = [702, HTTP_FORBIDDEN, "External URIs are not enabled for this account"] + # Staging + STAGING_TIMED_OUT = [800, HTTP_INTERNAL_SERVER_ERROR, "Timed out waiting for staging to complete"] + STAGING_FAILED = [801, HTTP_INTERNAL_SERVER_ERROR, "Staging failed"] + end diff --git a/cloud_controller/lib/staging_task_manager.rb b/cloud_controller/lib/staging_task_manager.rb new file mode 100644 index 000000000..b4e469372 --- /dev/null +++ b/cloud_controller/lib/staging_task_manager.rb @@ -0,0 +1,58 @@ +require 'nats/client' + +require 'vcap/stager/task' +require 'vcap/stager/task_result' + +class StagingTaskManager + DEFAULT_TASK_TIMEOUT = 120 + + def initialize(opts={}) + @logger = opts[:logger] || VCAP::Logging.logger('vcap.cc.staging_manager') + @nats_conn = opts[:nats_conn] || NATS.client + @timeout = opts[:timeout] || DEFAULT_TASK_TIMEOUT + end + + # Enqueues a staging task in redis, blocks until task completes or a timeout occurs. + # + # @param app App The app to be staged + # @param dl_uri String URI that the stager can download the app from + # @param ul_uri String URI that the stager should upload the staged droplet to + # + # @return VCAP::Stager::TaskResult + def run_staging_task(app, dl_uri, ul_uri) + inbox = "cc.staging." + VCAP.secure_uuid + nonce = VCAP.secure_uuid + f = Fiber.current + + # Wait for notification from the stager + exp_timer = nil + sid = @nats_conn.subscribe(inbox) do |msg| + @logger.debug("Received result from stager on '#{inbox}' : '#{msg}'") + @nats_conn.unsubscribe(sid) + EM.cancel_timer(exp_timer) + f.resume(msg) + end + + # Setup timer to expire our request if we don't hear a response from the stager in time + exp_timer = EM.add_timer(@timeout) do + @logger.warn("Staging timed out for app_id=#{app.id} (timeout=#{@timeout}, unsubscribing from '#{inbox}'", + :tags => [:staging]) + @nats_conn.unsubscribe(sid) + f.resume(nil) + end + + task = VCAP::Stager::Task.new(app.id, app.staging_task_properties, dl_uri, ul_uri, inbox) + task.enqueue('staging') + @logger.debug("Enqeued staging task for app_id=#{app.id}.", :tags => [:staging]) + + reply = Fiber.yield + if reply + result = VCAP::Stager::TaskResult.decode(reply) + StagingTaskLog.new(app.id, result.task_log).save + else + result = VCAP::Stager::TaskResult.new(nil, nil, "Timed out waiting for stager's reply.") + end + + result + end +end diff --git a/cloud_controller/public/favicon.ico b/cloud_controller/public/favicon.ico deleted file mode 100644 index e69de29bb..000000000 diff --git a/cloud_controller/spec/controllers/staging_controller_spec.rb b/cloud_controller/spec/controllers/staging_controller_spec.rb new file mode 100644 index 000000000..e748a5125 --- /dev/null +++ b/cloud_controller/spec/controllers/staging_controller_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe StagingController do + before :all do + VCAP::Logging.setup_from_config({'level' => 'debug2'}) + AppConfig[:staging][:auth] = { + :user => 'test', + :password => 'test', + } + @auth = ActionController::HttpAuthentication::Basic.encode_credentials('test', 'test') + end + + describe '#upload_droplet' do + + before :each do + request.env["HTTP_AUTHORIZATION"] = @auth + end + + it 'should return 401 for incorrect credentials' do + request.env["HTTP_AUTHORIZATION"] = nil + post :upload_droplet, {:id => 1, :upload_id => 'foo'} + response.status.should == 401 + end + + it 'should return 404 for unknown apps' do + post :upload_droplet, {:id => 1, :upload_id => 'foo'} + response.status.should == 404 + end + + it 'should return 400 for unknown uploads' do + App.stubs(:find_by_id).with(1).returns('test') + post :upload_droplet, {:id => 1, :upload_id => 'foo'} + response.status.should == 400 + end + + it 'should rename the uploaded file correctly' do + tmpfile = Tempfile.new('test') + app, droplet, upload = stub_test_upload(tmpfile) + File.expects(:rename).with(droplet.path, upload.upload_path) + params = { + :id => app.id, + :upload_id => upload.upload_id, + :upload => {:droplet => droplet} + } + post :upload_droplet, params + response.status.should == 200 + end + + it 'should clean up the temporary upload' do + tmpfile = Tempfile.new('test') + app, droplet, upload = stub_test_upload(tmpfile) + File.expects(:rename).with(droplet.path, upload.upload_path) + FileUtils.expects(:rm_f).with(droplet.path) + params = { + :id => app.id, + :upload_id => upload.upload_id, + :upload => {:droplet => droplet} + } + post :upload_droplet, params + response.status.should == 200 + end + end + + def stub_test_upload(file) + app = App.new + app.id = 1 + droplet = Rack::Test::UploadedFile.new(file.path) + App.stubs(:find_by_id).with(app.id).returns(app) + upload = StagingController::DropletUploadHandle.new(app) + CloudController.stubs(:use_nginx).returns(false) + StagingController.stubs(:lookup_upload).with(upload.upload_id).returns(upload) + [app, droplet, upload] + end +end diff --git a/cloud_controller/spec/models/staging_task_log_spec.rb b/cloud_controller/spec/models/staging_task_log_spec.rb new file mode 100644 index 000000000..daf8ff5c0 --- /dev/null +++ b/cloud_controller/spec/models/staging_task_log_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe StagingTaskLog do + before :all do + @task_id = 'test_task' + @task_log = StagingTaskLog.new(@task_id, 'Hello') + @task_key = StagingTaskLog.key_for_id(@task_id) + end + + describe '#save' do + it 'should set a json encoded blob in redis' do + redis_mock = mock() + redis_mock.expects(:set).with(@task_key, @task_log.task_log) + @task_log.save(redis_mock) + end + + it 'should use the static instance of redis if none is provided' do + redis_mock = mock() + redis_mock.expects(:set).with(@task_key, @task_log.task_log) + StagingTaskLog.redis = redis_mock + @task_log.save + end + end + + describe '#fetch' do + it 'should fetch and decode an existing task result' do + redis_mock = mock() + redis_mock.expects(:get).with(@task_key).returns(@task_log.task_log) + res = StagingTaskLog.fetch(@task_id, redis_mock) + res.should be_instance_of(StagingTaskLog) + end + + it 'should return nil if no key exists' do + redis_mock = mock() + redis_mock.expects(:get).with(@task_key).returns(nil) + res = StagingTaskLog.fetch(@task_id, redis_mock) + res.should be_nil + end + + it 'should use the static instance of redis if none is provided' do + redis_mock = mock() + redis_mock.expects(:get).with(@task_key).returns(nil) + StagingTaskLog.redis = redis_mock + res = StagingTaskLog.fetch(@task_id, redis_mock) + end + end +end diff --git a/cloud_controller/spec/models/user_spec.rb b/cloud_controller/spec/models/user_spec.rb index c4273bcd2..eda0825aa 100644 --- a/cloud_controller/spec/models/user_spec.rb +++ b/cloud_controller/spec/models/user_spec.rb @@ -94,6 +94,30 @@ end end + describe "#uses_new_stager?" do + it 'should return false if no percent is configured in the config' do + u = User.new(:email => 'foo@bar.com') + u.uses_new_stager?({:staging => {}}).should be_false + end + + it 'should correctly identify which users should have the new stager enabled' do + u = User.new(:email => 'foo@bar.com') + cfg = {:staging => {:new_stager_percent => 2}} + + u.id = 2 + u.uses_new_stager?(cfg).should be_false + + u.id = 250 + u.uses_new_stager?(cfg).should be_false + + u.id = 1 + u.uses_new_stager?(cfg).should be_true + + u.id = 101 + u.uses_new_stager?(cfg).should be_true + end + end + def create_user(email, pw) u = User.new(:email => email) u.set_and_encrypt_password(pw) diff --git a/cloud_controller/spec/staging/php_spec.rb b/cloud_controller/spec/staging/php_spec.rb deleted file mode 100644 index 44ea84938..000000000 --- a/cloud_controller/spec/staging/php_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -require 'spec_helper' - -describe "A PHP application being staged" do - before do - app_fixture :phpinfo - end - - it "is packaged with a startup script" do - stage :php do |staged_dir| - executable = '%VCAP_LOCAL_RUNTIME%' - start_script = File.join(staged_dir, 'startup') - start_script.should be_executable_file - webapp_root = staged_dir.join('app') - webapp_root.should be_directory - script_body = File.read(start_script) - script_body.should == <<-EXPECTED -#!/bin/bash -env > env.log -ruby resources/generate_apache_conf $VCAP_APP_PORT $HOME $VCAP_SERVICES 512m -cd apache -bash ./start.sh > ../logs/stdout.log 2> ../logs/stderr.log & -STARTED=$! -echo "$STARTED" >> ../run.pid -echo "#!/bin/bash" >> ../stop -echo "kill -9 $STARTED" >> ../stop -echo "kill -9 $PPID" >> ../stop -chmod 755 ../stop -wait $STARTED - EXPECTED - end - end - - it "requests the specified amount of memory from PHP" do - environment = { :resources => {:memory => 256} } - stage(:php, environment) do |staged_dir| - start_script = File.join(staged_dir, 'startup') - start_script.should be_executable_file - script_body = File.read(start_script) - script_body.should == <<-EXPECTED -#!/bin/bash -env > env.log -ruby resources/generate_apache_conf $VCAP_APP_PORT $HOME $VCAP_SERVICES 256m -cd apache -bash ./start.sh > ../logs/stdout.log 2> ../logs/stderr.log & -STARTED=$! -echo "$STARTED" >> ../run.pid -echo "#!/bin/bash" >> ../stop -echo "kill -9 $STARTED" >> ../stop -echo "kill -9 $PPID" >> ../stop -chmod 755 ../stop -wait $STARTED - EXPECTED - end - end -end diff --git a/cloud_controller/spec/staging/staging_task_manager_spec.rb b/cloud_controller/spec/staging/staging_task_manager_spec.rb new file mode 100644 index 000000000..3ac31f29d --- /dev/null +++ b/cloud_controller/spec/staging/staging_task_manager_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe StagingTaskManager do + before :all do + # Prevent EM/NATS related initializers from running + EM.instance_variable_set(:@next_tick_queue, []) + end + + describe '#run_staging_task' do + it 'should expire long running tasks' do + nats_conn = mock() + logger = stub_everything(:logger) + + nats_conn.expects(:subscribe).with(any_parameters()) + nats_conn.expects(:unsubscribe).with(any_parameters()) + + task = stub_everything(:task) + VCAP::Stager::Task.expects(:new).with(any_parameters()).returns(task) + + app = create_stub_app(12345) + stm = StagingTaskManager.new( + :logger => logger, + :nats_conn => nats_conn, + :timeout => 1 + ) + + res = nil + EM.run do + Fiber.new do + EM.add_timer(2) { EM.stop } + res = stm.run_staging_task(app, nil, nil) + end.resume + end + + res.should be_instance_of(VCAP::Stager::TaskResult) + res.was_success?.should be_false + end + end + + def create_stub_app(id, props={}) + ret = mock("app_#{id}") + ret.stubs(:id).returns(id) + ret.stubs(:staging_task_properties).returns(props) + ret + end +end diff --git a/cloud_controller/vendor/cache/em-hiredis-0.1.0.gem b/cloud_controller/vendor/cache/em-hiredis-0.1.0.gem new file mode 100644 index 000000000..3678bff06 Binary files /dev/null and b/cloud_controller/vendor/cache/em-hiredis-0.1.0.gem differ diff --git a/cloud_controller/vendor/cache/hiredis-0.3.2.gem b/cloud_controller/vendor/cache/hiredis-0.3.2.gem new file mode 100644 index 000000000..37d0c5fed Binary files /dev/null and b/cloud_controller/vendor/cache/hiredis-0.3.2.gem differ diff --git a/cloud_controller/vendor/cache/rest-client-1.6.7.gem b/cloud_controller/vendor/cache/rest-client-1.6.7.gem new file mode 100644 index 000000000..0cba1056a Binary files /dev/null and b/cloud_controller/vendor/cache/rest-client-1.6.7.gem differ diff --git a/cloud_controller/vendor/cache/vcap_common-0.99.gem b/cloud_controller/vendor/cache/vcap_common-0.99.gem new file mode 100644 index 000000000..4f1555861 Binary files /dev/null and b/cloud_controller/vendor/cache/vcap_common-0.99.gem differ diff --git a/cloud_controller/vendor/cache/vcap_stager-0.1.3.gem b/cloud_controller/vendor/cache/vcap_stager-0.1.3.gem new file mode 100644 index 000000000..35968c0e7 Binary files /dev/null and b/cloud_controller/vendor/cache/vcap_stager-0.1.3.gem differ diff --git a/stager/vendor/cache/vcap_staging-0.1.0.gem b/cloud_controller/vendor/cache/vcap_staging-0.1.2.gem similarity index 90% rename from stager/vendor/cache/vcap_staging-0.1.0.gem rename to cloud_controller/vendor/cache/vcap_staging-0.1.2.gem index c56fd0fdb..aa1f8821c 100644 Binary files a/stager/vendor/cache/vcap_staging-0.1.0.gem and b/cloud_controller/vendor/cache/vcap_staging-0.1.2.gem differ diff --git a/common/Gemfile.lock b/common/Gemfile.lock index d041a4492..d554416cc 100644 --- a/common/Gemfile.lock +++ b/common/Gemfile.lock @@ -13,7 +13,7 @@ GEM remote: http://rubygems.org/ specs: addressable (2.2.4) - daemons (1.1.2) + daemons (1.1.4) diff-lcs (1.1.2) em-http-request (1.0.0.beta.3) addressable (>= 2.2.3) @@ -26,7 +26,7 @@ GEM http_parser.rb (0.5.1) json_pure (1.5.3) little-plugger (1.1.2) - logging (1.5.2) + logging (1.6.0) little-plugger (>= 1.1.2) nats (0.4.10) daemons (>= 1.1.0) @@ -46,7 +46,7 @@ GEM daemons (>= 1.0.9) eventmachine (>= 0.12.6) rack (>= 1.0.0) - yajl-ruby (0.8.2) + yajl-ruby (0.8.3) PLATFORMS ruby diff --git a/common/lib/vcap/json_schema.rb b/common/lib/vcap/json_schema.rb index c1cbd7ee3..963878c15 100644 --- a/common/lib/vcap/json_schema.rb +++ b/common/lib/vcap/json_schema.rb @@ -60,6 +60,14 @@ def validate(dec_json) end end + class BoolSchema < BaseSchema + def validate(dec_json) + unless dec_json.kind_of?(TrueClass) || dec_json.kind_of?(FalseClass) + raise TypeError, "Expected instance of TrueClass or FalseClass, got #{dec_json.class}" + end + end + end + # Checks that supplied value is an instance of a given class class TypeSchema < BaseSchema def initialize(klass) @@ -169,6 +177,8 @@ def build(&blk) def parse(schema_def) case schema_def + when VCAP::JsonSchema::BaseSchema + schema_def when Hash schema = VCAP::JsonSchema::HashSchema.new for k, v in schema_def diff --git a/common/lib/vcap/spec/forked_component/nats_server.rb b/common/lib/vcap/spec/forked_component/nats_server.rb index 0fb468990..124e4d071 100644 --- a/common/lib/vcap/spec/forked_component/nats_server.rb +++ b/common/lib/vcap/spec/forked_component/nats_server.rb @@ -15,7 +15,7 @@ class VCAP::Spec::ForkedComponent::NatsServer < VCAP::Spec::ForkedComponent::Bas attr_reader :uri, :port, :parsed_uri def initialize(pid_filename, port, output_basedir='tmp') - cmd = "ruby -S bundle exec nats-server -p #{port} -P #{pid_filename} -V" + cmd = "ruby -S bundle exec nats-server -p #{port} -P #{pid_filename} -V -D" super(cmd, 'nats', output_basedir, pid_filename) @port = port @uri = "nats://127.0.0.1:#{@port}" diff --git a/common/spec/unit/json_schema_spec.rb b/common/spec/unit/json_schema_spec.rb index 004026cb0..1a13fe68a 100644 --- a/common/spec/unit/json_schema_spec.rb +++ b/common/spec/unit/json_schema_spec.rb @@ -1,6 +1,26 @@ # Copyright (c) 2009-2011 VMware, Inc. require 'spec_helper' +describe VCAP::JsonSchema::BoolSchema do + describe '#validate' do + before :all do + @schema = VCAP::JsonSchema::BoolSchema.new + end + + it 'should not raise an error when supplied instances of TrueClass' do + expect { @schema.validate(true) }.to_not raise_error + end + + it 'should not raise an error when supplied instances of FalseClass' do + expect { @schema.validate(false) }.to_not raise_error + end + + it 'should raise an error when supplied instances not of TrueClass or FalseClass' do + expect { @schema.validate('zazzle') }.to raise_error(VCAP::JsonSchema::TypeError) + end + end +end + describe VCAP::JsonSchema::TypeSchema do describe '#initialize' do it 'should raise an exception if supplied with non class instance' do diff --git a/common/vcap_common-0.99.gem b/common/vcap_common-0.99.gem index 4f1555861..47b0d007e 100644 Binary files a/common/vcap_common-0.99.gem and b/common/vcap_common-0.99.gem differ diff --git a/setup/cc_proxy.nginx.conf b/setup/cc_proxy.nginx.conf index 92d5fae0f..19bb14c5a 100644 --- a/setup/cc_proxy.nginx.conf +++ b/setup/cc_proxy.nginx.conf @@ -86,6 +86,31 @@ http { upload_cleanup 400-505; } + # Droplet uploads from the stager should be authenticated + location ~ /staging/droplet/ { + auth_basic "staging"; + auth_basic_user_file /var/vcap/data/cloud_controller/staging.htpasswd; + + # Pass along auth header + set $auth_header $upstream_http_x_auth; + proxy_set_header Authorization $auth_header; + + # Pass altered request body to this location + upload_pass @cc_uploads; + + # Store files to this directory + upload_store /var/vcap/data/cloud_controller/tmp/staged_droplet_uploads; + + # Allow uploaded files to be read only by user + upload_store_access user:r; + + # Set specified fields in request body + upload_set_form_field "droplet_path" $upload_tmp_path; + + #on any error, delete uploaded files. + upload_cleanup 400-505; + } + # Pass altered request body to a backend location @cc_uploads { proxy_pass http://localhost:9025; diff --git a/setup/mime.types b/setup/mime.types new file mode 100644 index 000000000..9fafe7093 --- /dev/null +++ b/setup/mime.types @@ -0,0 +1,73 @@ +types { + text/html html htm shtml; + text/css css; + text/xml xml; + image/gif gif; + image/jpeg jpeg jpg; + application/x-javascript js; + application/atom+xml atom; + application/rss+xml rss; + + text/mathml mml; + text/plain txt; + text/vnd.sun.j2me.app-descriptor jad; + text/vnd.wap.wml wml; + text/x-component htc; + + image/png png; + image/tiff tif tiff; + image/vnd.wap.wbmp wbmp; + image/x-icon ico; + image/x-jng jng; + image/x-ms-bmp bmp; + image/svg+xml svg; + + application/java-archive jar war ear; + application/mac-binhex40 hqx; + application/msword doc; + application/pdf pdf; + application/postscript ps eps ai; + application/rtf rtf; + application/vnd.ms-excel xls; + application/vnd.ms-powerpoint ppt; + application/vnd.wap.wmlc wmlc; + application/vnd.wap.xhtml+xml xhtml; + application/vnd.google-earth.kml+xml kml; + application/vnd.google-earth.kmz kmz; + application/x-7z-compressed 7z; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/zip zip; + + application/octet-stream bin exe dll; + application/octet-stream deb; + application/octet-stream dmg; + application/octet-stream eot; + application/octet-stream iso img; + application/octet-stream msi msp msm; + + audio/midi mid midi kar; + audio/mpeg mp3; + audio/x-realaudio ra; + + video/3gpp 3gpp 3gp; + video/mpeg mpeg mpg; + video/quicktime mov; + video/x-flv flv; + video/x-mng mng; + video/x-ms-asf asx asf; + video/x-ms-wmv wmv; + video/x-msvideo avi; +} diff --git a/stager/Gemfile b/stager/Gemfile index 83cf4bd2a..9356b2d0b 100644 --- a/stager/Gemfile +++ b/stager/Gemfile @@ -1,14 +1,15 @@ source :rubygems +gem 'eventmachine', '=0.12.10' gem 'nats' gem 'rake' -gem 'redis' -gem 'resque' +gem 'logging', '= 1.5.2' +gem 'rest-client', '= 1.6.7' gem 'yajl-ruby', '>= 0.7.9' -gem 'vcap_common', :path => '../common' +gem 'vcap_common' gem 'vcap_logging', '>= 0.1.1' -gem 'vcap_staging' +gem 'vcap_staging', '>= 0.1.2' group :test do gem 'rspec' diff --git a/stager/Gemfile.lock b/stager/Gemfile.lock index 85d6decab..bad167bc7 100644 --- a/stager/Gemfile.lock +++ b/stager/Gemfile.lock @@ -1,14 +1,3 @@ -PATH - remote: ../common - specs: - vcap_common (0.99) - eventmachine (~> 0.12.10) - logging (>= 1.5.0) - nats - posix-spawn - thin - yajl-ruby - GEM remote: http://rubygems.org/ specs: @@ -17,26 +6,21 @@ GEM daemons (1.1.4) diff-lcs (1.1.2) eventmachine (0.12.10) - json (1.5.3) json_pure (1.5.3) little-plugger (1.1.2) logging (1.5.2) little-plugger (>= 1.1.2) + mime-types (1.16) nats (0.4.10) daemons (>= 1.1.0) eventmachine (>= 0.12.10) json_pure (>= 1.5.1) + nokogiri (1.5.0) posix-spawn (0.3.6) rack (1.3.2) rake (0.9.2) - redis (2.2.1) - redis-namespace (1.0.3) - redis (< 3.0.0) - resque (1.17.1) - json (>= 1.4.6, < 1.6) - redis-namespace (~> 1.0.2) - sinatra (>= 0.9.2) - vegas (~> 0.1.2) + rest-client (1.6.7) + mime-types (>= 1.16) rspec (2.6.0) rspec-core (~> 2.6.0) rspec-expectations (~> 2.6.0) @@ -53,27 +37,38 @@ GEM eventmachine (>= 0.12.6) rack (>= 1.0.0) tilt (1.3.2) + vcap_common (0.99) + eventmachine (~> 0.12.10) + logging (>= 1.5.0) + nats + posix-spawn + thin + yajl-ruby vcap_logging (0.1.1) - vcap_staging (0.1.0) - vegas (0.1.8) - rack (>= 1.0.0) - webmock (1.6.4) + vcap_staging (0.1.2) + nokogiri (>= 1.4.4) + rake + rspec + vcap_common + yajl-ruby (>= 0.7.9) + webmock (1.7.4) addressable (~> 2.2, > 2.2.5) crack (>= 0.1.7) - yajl-ruby (0.8.2) + yajl-ruby (0.8.3) PLATFORMS ruby DEPENDENCIES + eventmachine (= 0.12.10) + logging (= 1.5.2) nats rake - redis - resque + rest-client (= 1.6.7) rspec sinatra - vcap_common! + vcap_common vcap_logging (>= 0.1.1) - vcap_staging + vcap_staging (>= 0.1.2) webmock yajl-ruby (>= 0.7.9) diff --git a/stager/Rakefile b/stager/Rakefile index 69c06e2c0..637c81c39 100644 --- a/stager/Rakefile +++ b/stager/Rakefile @@ -21,7 +21,7 @@ gemspec = Gem::Specification.new do |s| s.executables = [] s.bindir = 'bin' s.require_path = 'lib' - s.files = %w(Rakefile) + Dir.glob("{lib,spec,vendor}/**/*") + s.files = %w(Rakefile Gemfile) + Dir.glob("{lib,spec,vendor}/**/*") end Rake::GemPackageTask.new(gemspec) do |pkg| diff --git a/stager/bin/create_secure_users.rb b/stager/bin/create_secure_users.rb new file mode 100755 index 000000000..26db46cf1 --- /dev/null +++ b/stager/bin/create_secure_users.rb @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) + +require 'rubygems' +require 'bundler/setup' + +$LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__)) + +require 'vcap/stager/secure_user_manager' + +VCAP::Stager::SecureUserManager.instance.create_secure_users diff --git a/stager/bin/download_app b/stager/bin/download_app new file mode 100755 index 000000000..4c5f5edf9 --- /dev/null +++ b/stager/bin/download_app @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) + + +require 'rubygems' +require 'bundler/setup' + +$LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__)) + +require 'vcap/stager/util' + +# Utility script to download an app package to a known location on disk. +# Used to keep long running ruby code (the stager process) out of the data path. +# +# NB: If this script exits with a non-zero status, the download failed +# + +unless ARGV.length == 2 + puts "Usage: download_app [url_file] [dst_path]" + exit 1 +end + +uri_file = ARGV[0] +dst_path = ARGV[1] +app_uri = File.read(uri_file) + +VCAP::Stager::Util.fetch_zipped_app(app_uri, dst_path) + +exit 0 diff --git a/stager/bin/stager b/stager/bin/stager index e08e520b0..148c7b6a7 100755 --- a/stager/bin/stager +++ b/stager/bin/stager @@ -6,14 +6,11 @@ require 'bundler/setup' $LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__)) -require 'eventmachine' -require 'resque' - require 'vcap/common' require 'vcap/component' require 'vcap/stager' -sleep_interval = 5 +sleep_interval = 1 config_file = VCAP::Stager::Config::DEFAULT_CONFIG_PATH OptionParser.new do |op| @@ -44,29 +41,14 @@ rescue => e exit 1 end -VCAP::Stager.init(config) +ENV['TMPDIR'] = config[:dirs][:tmp] if config[:dirs] && config[:dirs][:tmp] -worker = Resque::Worker.new(*config[:queues]) -Thread.new do - EM.error_handler do |e| - logger = VCAP::Logging.logger('vcap.stager.component') - logger.error("EventMachine error: #{e}") - logger.error(e) - raise e - end - - NATS.on_error do |e| - logger = VCAP::Logging.logger('vcap.stager.component') - logger.error("NATS error: #{e}") - logger.error(e) - raise e - end - - NATS.start(:uri => config[:nats_uri]) do - VCAP::Component.register(:type => 'stager', - :local_ip => VCAP.local_ip(config[:local_route]), - :config => config, - :index => config[:index]) - end -end.abort_on_exception = true -worker.work(sleep_interval) +VCAP::Stager.init(config) +user_mgr=nil +if config[:secure] + VCAP::Stager::SecureUserManager.instance.setup + user_mgr = VCAP::Stager::SecureUserManager.instance +end +task_mgr = VCAP::Stager::TaskManager.new(config[:max_active_tasks], user_mgr) +server = VCAP::Stager::Server.new(config[:nats_uri], task_mgr, config) +server.run diff --git a/stager/bin/upload_droplet b/stager/bin/upload_droplet new file mode 100755 index 000000000..c4a3f1f24 --- /dev/null +++ b/stager/bin/upload_droplet @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) + + +require 'rubygems' +require 'bundler/setup' + +$LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__)) + +require 'vcap/stager/util' + +# Utility script to upload a droplet from a known location on disk. +# Used to keep long running ruby code (the stager process) out of the data path. +# +# NB: If this script exits with a non-zero status, the upload failed +# + +unless ARGV.length == 2 + puts "Usage: upload_droplet [url_file] [dst_path]" + exit 1 +end + +uri_file = ARGV[0] +src_path = ARGV[1] +droplet_uri = File.read(uri_file) + +VCAP::Stager::Util.upload_droplet(droplet_uri, src_path) + +exit 0 diff --git a/stager/config/dev.yml b/stager/config/dev.yml index f69152790..dfd5be7b8 100644 --- a/stager/config/dev.yml +++ b/stager/config/dev.yml @@ -1,9 +1,9 @@ --- logging: level: debug2 -redis: - host: 127.0.0.1 -pid_file: /var/vcap/sys/run/stager.pid +pid_filename: /var/vcap/sys/run/stager.pid nats_uri: nats://127.0.0.1:4222 max_staging_duration: 120 -queues: ['staging'] \ No newline at end of file +max_active_tasks: 10 +queues: ['staging'] +secure: false \ No newline at end of file diff --git a/stager/config/stager.yml b/stager/config/stager.yml new file mode 120000 index 000000000..92f6b368a --- /dev/null +++ b/stager/config/stager.yml @@ -0,0 +1 @@ +dev.yml \ No newline at end of file diff --git a/stager/lib/vcap/stager.rb b/stager/lib/vcap/stager.rb index 218332096..49858d126 100644 --- a/stager/lib/vcap/stager.rb +++ b/stager/lib/vcap/stager.rb @@ -1,10 +1,10 @@ -require 'eventmachine' -require 'resque' - -require 'vcap/stager/errors' +require 'vcap/stager/constants' require 'vcap/stager/config' +require 'vcap/stager/server' require 'vcap/stager/task' +require 'vcap/stager/task_error' require 'vcap/stager/task_logger' +require 'vcap/stager/task_manager' require 'vcap/stager/task_result' require 'vcap/stager/util' require 'vcap/stager/version' @@ -20,18 +20,8 @@ def init(config) StagingPlugin.manifest_root = config[:dirs][:manifests] StagingPlugin.load_all_manifests StagingPlugin.validate_configuration! - - Resque.redis = Redis.new(config[:redis]) - Resque.redis.namespace = config[:redis][:namespace] if config[:redis][:namespace] - # Prevents EM from getting into a blind/deaf state after forking. - # See: https://github.com/eventmachine/eventmachine/issues/213 - Resque.after_fork do |job| - if EM.reactor_running? - EM.stop_event_loop - EM.release_machine - EM.instance_variable_set('@reactor_running', false) - end - end + VCAP::Stager::Task.set_defaults(config) + VCAP::Stager::Task.set_defaults({:manifest_dir => config[:dirs][:manifests]}) end end end diff --git a/stager/lib/vcap/stager/config.rb b/stager/lib/vcap/stager/config.rb index b47c9375d..57965c35e 100644 --- a/stager/lib/vcap/stager/config.rb +++ b/stager/lib/vcap/stager/config.rb @@ -1,4 +1,6 @@ require 'vcap/config' +require 'vcap/json_schema' +require 'vcap/staging/plugin/common' module VCAP module Stager @@ -16,15 +18,9 @@ class VCAP::Stager::Config < VCAP::Config optional(:syslog) => String, # Name to associate with syslog messages (should start with 'vcap.') }, - :redis => { - :host => String, - optional(:port) => Integer, - optional(:password) => String, - optional(:namespace) => String, - }, - :nats_uri => String, # NATS uri of the form nats://:@: :max_staging_duration => Integer, # Maximum number of seconds a staging can run + :max_active_tasks => Integer, # Maximum number of tasks executing concurrently :queues => [String], # List of queues to pull tasks from :pid_filename => String, # Pid filename to use optional(:dirs) => { @@ -32,11 +28,7 @@ class VCAP::Stager::Config < VCAP::Config optional(:tmp) => String, # Default is /tmp }, - - optional(:secure_user) => { # Drop privs during staging to this user - :uid => Integer, - optional(:gid) => Integer, - }, + :secure => VCAP::JsonSchema::BoolSchema.new, optional(:index) => Integer, # Component index (stager-0, stager-1, etc) optional(:ruby_path) => String, # Full path to the ruby executable that should execute the run plugin script @@ -49,7 +41,7 @@ def self.from_file(*args) config = super(*args) config[:dirs] ||= {} - config[:dirs][:manifests] ||= File.expand_path('../plugin/manifests', __FILE__) + config[:dirs][:manifests] ||= StagingPlugin::DEFAULT_MANIFEST_ROOT config[:run_plugin_path] ||= File.expand_path('../../../../bin/run_plugin', __FILE__) config[:ruby_path] ||= `which ruby`.chomp diff --git a/stager/lib/vcap/stager/constants.rb b/stager/lib/vcap/stager/constants.rb new file mode 100644 index 000000000..a7fcf75f2 --- /dev/null +++ b/stager/lib/vcap/stager/constants.rb @@ -0,0 +1,6 @@ +module VCAP + module Stager + BIN_DIR = File.expand_path("../../../../bin", __FILE__) + CONFIG_DIR = File.expand_path("../../../../config", __FILE__) + end +end diff --git a/stager/lib/vcap/stager/errors.rb b/stager/lib/vcap/stager/errors.rb deleted file mode 100644 index dcdd2ab0b..000000000 --- a/stager/lib/vcap/stager/errors.rb +++ /dev/null @@ -1,8 +0,0 @@ -module VCAP - module Stager - class StagingError < StandardError; end - class AppDownloadError < StagingError; end - class DropletUploadError < StagingError; end - class ResultPublishingError < StagingError; end - end -end diff --git a/stager/lib/vcap/stager/secure_user_manager.rb b/stager/lib/vcap/stager/secure_user_manager.rb new file mode 100644 index 000000000..478f416bf --- /dev/null +++ b/stager/lib/vcap/stager/secure_user_manager.rb @@ -0,0 +1,119 @@ +# = XXX = +# It's multiplying! This file needs to die in a fire. It is a duplicate of the DEA secure user code. +# Soon we will have a more robust implementation of this, and a better container implementation +# to go with it. + +require 'singleton' + +require 'vcap/logging' + +module VCAP + module Stager + end +end + +class VCAP::Stager::SecureUserManager + include Singleton + + SECURE_USER_STRING = 'vcap-stager-user-' + SECURE_USER_GREP = "#{SECURE_USER_STRING}[0-9]\\{1,3\\}" + SECURE_USER_PATTERN = /(vcap-stager-user-\d+):[^:]+:(\d+):(\d+)/ + DEFAULT_SECURE_GROUP = 'vcap-stager' + SECURE_UID_BASE = 23000 + DEFAULT_NUM_SECURE_USERS = 32 + + def initialize + @logger = VCAP::Logging.logger('vcap.stager.secure_user_manager') + unless RUBY_PLATFORM =~ /linux/ + @logger.fatal("ERROR: Secure mode not supported on this platform.") + exit + end + end + + def setup(logger=nil) + @logger ||= logger + @logger.info("Grabbing secure users") + File.umask(0077) + grab_existing_users + unless @secure_users.size >= DEFAULT_NUM_SECURE_USERS + raise "Don't have enough secure users (#{@secure_users.size}), did you forget to set them up? " + end + @secure_mode_initialized = true + end + + def create_secure_users + if Process.uid != 0 + @logger.fatal "ERROR: Creating secure users requires root priviliges." + exit 1 + end + + @logger.info "Creating initial #{DEFAULT_NUM_SECURE_USERS} secure users" + create_default_group + (1..DEFAULT_NUM_SECURE_USERS).each do |i| + create_secure_user("#{SECURE_USER_STRING + i.to_s}", SECURE_UID_BASE+i) + end + end + + def checkout_user + raise "Did you forget to call setup_secure_mode()?" unless @secure_mode_initialized + + if @secure_users.length > 0 + ret = @secure_users.pop + @logger.debug("Checked out #{ret}") + ret + else + raise "All secure users are currently in use." + end + end + + def return_user(user) + raise "Did you forget to call setup_secure_mode()?" unless @secure_mode_initialized + @logger.debug("Returned #{user}") + @secure_users << user + end + + protected + + def create_default_group + # Add in default group + system("addgroup --system #{DEFAULT_SECURE_GROUP} > /dev/null 2>&1") + end + + def create_secure_user(username, uid = 0) + @logger.info("Creating user:#{username} (#{uid})") + system("adduser --system --quiet --no-create-home --home '/nonexistent' --uid #{uid} #{username} > /tmp/foo 2>&1") + system("usermod -g #{DEFAULT_SECURE_GROUP} #{username} > /dev/null 2>&1") + + info = get_user_info(username) + + { :user => username, + :gid => info[:gid].to_i, + :uid => info[:uid].to_i, + :group => DEFAULT_SECURE_GROUP, + } + end + + def check_existing_users + return `grep -H "#{SECURE_USER_GREP}" /etc/passwd` + end + + def grab_existing_users + @secure_users = [] + File.open('/etc/passwd') do |f| + while (line = f.gets) + if line =~ SECURE_USER_PATTERN + @secure_users << { :user => $1, :uid => $2.to_i, :gid => $3.to_i, :group => DEFAULT_SECURE_GROUP} + end + end + end + @secure_users + end + + def get_user_info(username) + info = `id #{username}` + ret = {} + ret[:uid] = $1 if info =~ /uid=(\d+)/ + ret[:gid] = $1 if info =~ /gid=(\d+)/ + ret + end +end diff --git a/stager/lib/vcap/stager/server.rb b/stager/lib/vcap/stager/server.rb new file mode 100644 index 000000000..69a3aeebd --- /dev/null +++ b/stager/lib/vcap/stager/server.rb @@ -0,0 +1,111 @@ +require 'nats/client' +require 'yajl' + +require 'vcap/component' +require 'vcap/json_schema' +require 'vcap/logging' + +require 'vcap/stager/task' +require 'vcap/stager/task_manager' + +module VCAP + module Stager + end +end + +class VCAP::Stager::Server + class Channel + def initialize(nats_conn, subject, &blk) + @nats_conn = nats_conn + @subject = subject + @sid = nil + @receiver = blk + end + + def open + @sid = @nats_conn.subscribe(@subject, :queue => @subject) {|msg| @receiver.call(msg) } + end + + def close + @nats_conn.unsubscribe(@sid) + end + end + + def initialize(nats_uri, task_mgr, config={}) + @nats_uri = nats_uri + @nats_conn = nil + @task_mgr = nil + @channels = [] + @config = config + @task_mgr = task_mgr + @logger = VCAP::Logging.logger('vcap.stager.server') + end + + def run + install_error_handlers() + install_signal_handlers() + EM.run do + @nats_conn = NATS.connect(:uri => @nats_uri) do + VCAP::Stager::Task.set_defaults(:nats => @nats_conn) + VCAP::Component.register(:type => 'Stager', + :index => @config[:index], + :host => VCAP.local_ip(@config[:local_route]), + :config => @config, + :nats => @nats_conn) + setup_channels() + @logger.info("Server running") + end + end + end + + # Stops receiving new tasks, waits for existing tasks to finish, then stops. + def shutdown + @logger.info("Shutdown initiated, waiting for remaining #{@task_mgr.num_tasks} task(s) to finish") + @channels.each {|c| c.close } + @task_mgr.on_idle do + @logger.info("All tasks completed. Exiting!") + EM.stop + end + end + + private + + def install_error_handlers + EM.error_handler do |e| + @logger.error("EventMachine error: #{e}") + @logger.error(e) + raise e + end + + NATS.on_error do |e| + @logger.error("NATS error: #{e}") + @logger.error(e) + raise e + end + end + + def install_signal_handlers + trap('USR2') { shutdown() } + trap('INT') { shutdown() } + end + + def setup_channels + for qn in @config[:queues] + channel = Channel.new(@nats_conn, "vcap.stager.#{qn}") {|msg| add_task(msg) } + channel.open + @channels << channel + end + end + + def add_task(task_msg) + begin + @logger.debug("Decoding task '#{task_msg}'") + task = VCAP::Stager::Task.decode(task_msg) + rescue => e + @logger.warn("Failed decoding '#{task_msg}': #{e}") + @logger.warn(e) + return + end + @task_mgr.add_task(task) + end +end diff --git a/stager/lib/vcap/stager/task.rb b/stager/lib/vcap/stager/task.rb index c0c17814a..2a1cb93ac 100644 --- a/stager/lib/vcap/stager/task.rb +++ b/stager/lib/vcap/stager/task.rb @@ -1,13 +1,16 @@ +require 'fiber' require 'fileutils' -require 'redis' -require 'redis-namespace' +require 'nats/client' require 'tmpdir' require 'uri' +require 'yajl' +require 'vcap/common' require 'vcap/logging' require 'vcap/staging/plugin/common' -require 'vcap/subprocess' +require 'vcap/stager/constants' +require 'vcap/stager/task_error' require 'vcap/stager/task_result' module VCAP @@ -15,31 +18,32 @@ module Stager end end -# TODO - Need VCAP::Stager::Task.enqueue(args) w/ validation - -# NB: This code is run after the parent worker process forks class VCAP::Stager::Task - @queue = :staging + DEFAULTS = { + :nats => NATS, + :manifest_dir => StagingPlugin::DEFAULT_MANIFEST_ROOT, + :max_staging_duration => 120, # 2 min + :download_app_helper_path => File.join(VCAP::Stager::BIN_DIR, 'download_app'), + :upload_droplet_helper_path => File.join(VCAP::Stager::BIN_DIR, 'upload_droplet'), + } class << self - def perform(*args) - task = self.new(*args) - task.perform + def decode(msg) + dec_msg = Yajl::Parser.parse(msg) + VCAP::Stager::Task.new(dec_msg['app_id'], + dec_msg['properties'], + dec_msg['download_uri'], + dec_msg['upload_uri'], + dec_msg['notify_subj']) end - end - - attr_reader :app_id - attr_reader :result - attr_accessor :tmpdir_base - attr_accessor :max_staging_duration - attr_accessor :run_plugin_path - attr_accessor :manifest_dir - attr_accessor :ruby_path - attr_accessor :secure_user + def set_defaults(defaults={}) + DEFAULTS.update(defaults) + end + end - attr_accessor :redis_opts - attr_accessor :nats_uri + attr_reader :task_id, :app_id, :result + attr_accessor :user # @param app_id Integer Globally unique id for app # @param props Hash App properties. Keys are @@ -52,97 +56,108 @@ def perform(*args) # @param download_uri String Where the stager can fetch the zipped application from. # @param upload_uri String Where the stager should PUT the gzipped droplet # @param notify_subj String NATS subject that the stager will publish the result to. - def initialize(app_id, props, download_uri, upload_uri, notify_subj) + def initialize(app_id, props, download_uri, upload_uri, notify_subj, opts={}) + @task_id = VCAP.secure_uuid @app_id = app_id @app_props = props @download_uri = download_uri @upload_uri = upload_uri @notify_subj = notify_subj - @tmpdir_base = nil # Temporary directories are created under this path - @vcap_logger = VCAP::Logging.logger('vcap.stager.task') - - # XXX - Not super happy about this, but I'm not sure of a better way to do this - # given that resque forks after reserving work - @max_staging_duration = VCAP::Stager.config[:max_staging_duration] - @run_plugin_path = VCAP::Stager.config[:run_plugin_path] - @ruby_path = VCAP::Stager.config[:ruby_path] - @redis_opts = VCAP::Stager.config[:redis] - @nats_uri = VCAP::Stager.config[:nats_uri] - @secure_user = VCAP::Stager.config[:secure_user] - @manifest_dir = VCAP::Stager.config[:dirs][:manifests] + + @vcap_logger = VCAP::Logging.logger('vcap.stager.task') + @nats = option(opts, :nats) + @max_staging_duration = option(opts, :max_staging_duration) + @run_plugin_path = option(opts, :run_plugin_path) + @ruby_path = option(opts, :ruby_path) + @manifest_dir = option(opts, :manifest_dir) + @tmpdir_base = opts[:tmpdir] + @user = opts[:user] + @download_app_helper_path = option(opts, :download_app_helper_path) + @upload_droplet_helper_path = option(opts, :upload_droplet_helper_path) end - def perform - task_logger = VCAP::Stager::TaskLogger.new(@vcap_logger) - begin - task_logger.info("Starting staging operation") - @vcap_logger.debug("App id: #{@app_id}, Properties: #{@app_props}") - @vcap_logger.debug("download_uri=#{@download_uri} upload_uri=#{@upload_uri} notify_sub=#{@notify_subj}") - - task_logger.info("Setting up temporary directories") - staging_dirs = create_staging_dirs(@tmpdir_base) - - task_logger.info("Fetching application bits from the Cloud Controller") - zipped_app_path = File.join(staging_dirs[:base], 'app.zip') - VCAP::Stager::Util.fetch_zipped_app(@download_uri, zipped_app_path) - - task_logger.info("Unzipping application") - VCAP::Subprocess.run("unzip -q #{zipped_app_path} -d #{staging_dirs[:unstaged]}") - - task_logger.info("Staging application") - run_staging_plugin(staging_dirs) - - task_logger.info("Creating droplet") - zipped_droplet_path = File.join(staging_dirs[:base], 'droplet.tgz') - VCAP::Subprocess.run("cd #{staging_dirs[:staged]}; COPYFILE_DISABLE=true tar -czf #{zipped_droplet_path} *") - - task_logger.info("Uploading droplet") - VCAP::Stager::Util.upload_droplet(@upload_uri, zipped_droplet_path) - - @result = VCAP::Stager::TaskResult.new(@app_id, - VCAP::Stager::TaskResult::ST_SUCCESS, - task_logger.public_log) - task_logger.info("Notifying Cloud Controller") - save_result - publish_result - task_logger.info("Done!") - - rescue VCAP::Stager::ResultPublishingError => e - # Don't try to publish to nats again if it failed the first time - task_logger.error("Staging FAILED") - @result = VCAP::Stager::TaskResult.new(@app_id, - VCAP::Stager::TaskResult::ST_FAILED, - task_logger.public_log) - save_result - raise e - - rescue => e - task_logger.error("Staging FAILED") - @vcap_logger.error("Caught exception: #{e}") - @vcap_logger.error(e) - @result = VCAP::Stager::TaskResult.new(@app_id, - VCAP::Stager::TaskResult::ST_FAILED, - task_logger.public_log) - save_result + # Performs the staging task, calls the supplied callback upon completion. + # + # NB: We use a fiber internally to avoid descending into callback hell. This + # method could easily end up looking like the following: + # create_staging_dirs do + # download_app do + # unzip_app do + # etc... + # + # @param callback Block Block to be called when the task completes (upon both success + # and failure). This will be called with an instance of VCAP::Stager::TaskResult + def perform(&callback) + Fiber.new do begin - publish_result + task_logger = VCAP::Stager::TaskLogger.new(@vcap_logger) + task_logger.info("Starting staging operation") + @vcap_logger.debug("app_id=#{@app_id}, properties=#{@app_props}") + @vcap_logger.debug("download_uri=#{@download_uri} upload_uri=#{@upload_uri} notify_sub=#{@notify_subj}") + + task_logger.info("Setting up temporary directories") + dirs = create_staging_dirs(@tmpdir_base) + + task_logger.info("Fetching application bits from the Cloud Controller") + download_app(dirs[:unstaged], dirs[:base]) + + task_logger.info("Staging application") + run_staging_plugin(dirs[:unstaged], dirs[:staged], dirs[:base]) + + task_logger.info("Uploading droplet") + upload_droplet(dirs[:staged], dirs[:base]) + + task_logger.info("Done!") + @result = VCAP::Stager::TaskResult.new(@task_id, task_logger.public_log) + @nats.publish(@notify_subj, @result.encode) + callback.call(@result) + + rescue VCAP::Stager::TaskError => te + task_logger.error("Error: #{te}") + @result = VCAP::Stager::TaskResult.new(@task_id, task_logger.public_log, te) + @nats.publish(@notify_subj, @result.encode) + callback.call(@result) + rescue => e - # Let the original exception stay as the cause of failure - @vcap_logger.error("Failed publishing error to NATS: #{e}") + @vcap_logger.error("Unrecoverable error: #{e}") @vcap_logger.error(e) + err = VCAP::Stager::InternalError.new + @result = VCAP::Stager::TaskResult.new(@task_id, task_logger.public_log, err) + @nats.publish(@notify_subj, @result.encode) + raise e + + ensure + EM.system("rm -rf #{dirs[:base]}") if dirs end - # Let resque catch and log the exception too - raise e - ensure - FileUtils.rm_rf(staging_dirs[:base]) if staging_dirs - end + end.resume + end + def encode + h = { + :app_id => @app_id, + :properties => @app_props, + :download_uri => @download_uri, + :upload_uri => @upload_uri, + :notify_subj => @notify_subj, + } + Yajl::Encoder.encode(h) + end + def enqueue(queue) + @nats.publish("vcap.stager.#{queue}", encode()) end private + def option(hash, key) + if hash.has_key?(key) + hash[key] + else + DEFAULTS[key] + end + end + # Creates a temporary directory with needed layout for staging, along # with the correct permissions # @@ -167,52 +182,109 @@ def create_staging_dirs(tmpdir_base=nil) ret end - # Stages our app into _staged_dir_ looking for the source in _unstaged_dir_ - def run_staging_plugin(staging_dirs) + # Downloads the zipped application at @download_uri, unzips it, and stores it + # in dst_dir. + # + # NB: We write the url to a file that only we can read in order to avoid + # exposing auth information. This actually shells out to a helper script in + # order to avoid putting long running code (the stager) on the data + # path. We are sacrificing performance for reliability here... + # + # @param dst_dir String Where to store the downloaded app + # @param tmp_dir String + def download_app(dst_dir, tmp_dir) + uri_path = File.join(tmp_dir, 'stager_dl_uri') + zipped_app_path = File.join(tmp_dir, 'app.zip') + + File.open(uri_path, 'w+') {|f| f.write(@download_uri) } + cmd = "#{@download_app_helper_path} #{uri_path} #{zipped_app_path}" + res = run_logged(cmd) + unless res[:success] + @vcap_logger.error("Failed downloading app from '#{@download_uri}'") + raise VCAP::Stager::AppDownloadError + end + + res = run_logged("unzip -q #{zipped_app_path} -d #{dst_dir}") + unless res[:success] + raise VCAP::Stager::AppUnzipError + end + + ensure + FileUtils.rm_f(uri_path) + FileUtils.rm_f(zipped_app_path) + end + + # Stages our app into dst_dir, looking for the app source in src_dir + # + # @param src_dir String Location of the unstaged app + # @param dst_dir String Where to place the staged app + # @param work_dir String Directory to use to place scratch files + def run_staging_plugin(src_dir, dst_dir, work_dir) plugin_config = { - 'source_dir' => staging_dirs[:unstaged], - 'dest_dir' => staging_dirs[:staged], + 'source_dir' => src_dir, + 'dest_dir' => dst_dir, 'environment' => @app_props, } - plugin_config['secure_user'] = @secure_user if @secure_user + plugin_config['secure_user'] = {'uid' => @user[:uid], 'gid' => @user[:gid]} if @user plugin_config['manifest_dir'] = @manifest_dir if @manifest_dir - - plugin_config_path = File.join(staging_dirs[:base], 'plugin_config.yaml') + plugin_config_path = File.join(work_dir, 'plugin_config.yaml') StagingPlugin::Config.to_file(plugin_config, plugin_config_path) cmd = "#{@ruby_path} #{@run_plugin_path} #{@app_props['framework']} #{plugin_config_path}" + @vcap_logger.debug("Running staging command: '#{cmd}'") - res = VCAP::Subprocess.run(cmd, 0, @max_staging_duration) - @vcap_logger.debug("Staging command exited with status: #{res[0]}") - @vcap_logger.debug("STDOUT: #{res[1]}") - @vcap_logger.debug("STDERR: #{res[2]}") - res + res = run_logged(cmd, 0, @max_staging_duration) + + if res[:timed_out] + @vcap_logger.error("Staging timed out") + raise VCAP::Stager::StagingTimeoutError + elsif !res[:success] + @vcap_logger.error("Staging plugin exited with status '#{res[:status]}'") + raise VCAP::Stager::StagingPluginError, "#{res[:stderr]}" + end + ensure + FileUtils.rm_f(plugin_config_path) if plugin_config_path end - def publish_result - begin - EM.run do - nats = NATS.connect(:uri => @nats_uri) do - nats.publish(@notify_subj, @result.encode) { EM.stop } - end - end - rescue => e - @vcap_logger.error("Failed publishing to NATS (uri=#{@nats_uri}). Error: #{e}") - @vcap_logger.error(e) - raise VCAP::Stager::ResultPublishingError, "Error while publishing to #{@nats_uri}" + # Packages and uploads the droplet in staged_dir + # + # NB: See download_app for an explanation of why we shell out here... + # + # @param staged_dir String + def upload_droplet(staged_dir, tmp_dir) + droplet_path = File.join(tmp_dir, 'droplet.tgz') + cmd = "cd #{staged_dir} && COPYFILE_DISABLE=true tar -czf #{droplet_path} *" + res = run_logged(cmd) + unless res[:success] + raise VCAP::Stager::DropletCreationError end - end - # Failure to save our result to redis shouldn't impact whether or not - # the staging operation succeeds. Consequently, this doesn't throw exceptions. - def save_result - begin - redis = Redis.new(@redis_opts) - redis = Redis::Namespace.new(@redis_opts[:namespace], :redis => redis) - @result.save(redis) - rescue => e - @vcap_logger.error("Failed saving result to redis: #{e}") - @vcap_logger.error(e) + uri_path = File.join(tmp_dir, 'stager_ul_uri') + File.open(uri_path, 'w+') {|f| f.write(@upload_uri); f.path } + + cmd = "#{@upload_droplet_helper_path} #{uri_path} #{droplet_path}" + res = run_logged(cmd) + unless res[:success] + @vcap_logger.error("Failed uploading app to '#{@upload_uri}'") + raise VCAP::Stager::DropletUploadError end + ensure + FileUtils.rm_f(droplet_path) if droplet_path + FileUtils.rm_f(uri_path) if uri_path + end + + # Runs a command, logging the result at the debug level on success, or error level + # on failure. See VCAP::Stager::Util for a description of the arguments. + def run_logged(command, expected_exitstatus=0, timeout=nil) + f = Fiber.current + VCAP::Stager::Util.run_command(command, expected_exitstatus, timeout) {|res| f.resume(res) } + ret = Fiber.yield + + level = ret[:success] ? :debug : :error + @vcap_logger.send(level, "Command '#{command}' exited with status='#{ret[:status]}', timed_out=#{ret[:timed_out]}") + @vcap_logger.send(level, "stdout: #{ret[:stdout]}") if ret[:stdout] != '' + @vcap_logger.send(level, "stderr: #{ret[:stderr]}") if ret[:stderr] != '' + + ret end end diff --git a/stager/lib/vcap/stager/task_error.rb b/stager/lib/vcap/stager/task_error.rb new file mode 100644 index 000000000..bd59de0d6 --- /dev/null +++ b/stager/lib/vcap/stager/task_error.rb @@ -0,0 +1,59 @@ +require 'yajl' + +require 'vcap/json_schema' + +module VCAP + module Stager + + # A TaskError is a recoverable error that indicates any further task + # processing should be aborted and the task should be completed in a failed + # state. All other errors thrown during VCAP::Stager::Task#perform will be + # logged and re-raised (probably causing the program to crash). + class TaskError < StandardError + SCHEMA = VCAP::JsonSchema.build do + { :class => String, + optional(:details) => String, + } + end + + class << self + attr_reader :desc + + def set_desc(desc) + @desc = desc + end + + def decode(enc_err) + dec_err = Yajl::Parser.parse(enc_err) + SCHEMA.validate(dec_err) + err_class = dec_err['class'].split('::').last + VCAP::Stager.const_get(err_class.to_sym).new(dec_err['details']) + end + end + + attr_reader :details + + def initialize(details=nil) + @details = details + end + + def to_s + @details ? "#{self.class.desc}:\n #{@details}" : self.class.desc + end + + def encode + h = {:class => self.class.to_s} + h[:details] = @details if @details + Yajl::Encoder.encode(h) + end + end + + class AppDownloadError < TaskError; set_desc "Failed downloading application from the Cloud Controller"; end + class AppUnzipError < TaskError; set_desc "Failed unzipping application"; end + class StagingPluginError < TaskError; set_desc "Staging plugin failed staging application"; end + class StagingTimeoutError < TaskError; set_desc "Staging operation timed out"; end + class DropletCreationError < TaskError; set_desc "Failed creating droplet"; end + class DropletUploadError < TaskError; set_desc "Failed uploading droplet to the Cloud Controller"; end + class InternalError < TaskError; set_desc "Unexpected internal error encountered (possibly a bug)."; end + end +end diff --git a/stager/lib/vcap/stager/task_manager.rb b/stager/lib/vcap/stager/task_manager.rb new file mode 100644 index 000000000..eecf9a5c5 --- /dev/null +++ b/stager/lib/vcap/stager/task_manager.rb @@ -0,0 +1,88 @@ +require 'vcap/logging' + +require 'vcap/stager/secure_user_manager' +require 'vcap/stager/task' + +module VCAP + module Stager + end +end + +class VCAP::Stager::TaskManager + attr_accessor :max_active_tasks, :user_mgr + + def initialize(max_active_tasks, user_mgr=nil) + @max_active_tasks = max_active_tasks + @event_callbacks = {} + @queued_tasks = [] + @active_tasks = {} + @user_mgr = user_mgr + @logger = VCAP::Logging.logger('vcap.stager.task_manager') + end + + def num_tasks + @queued_tasks.length + @active_tasks.size + end + + # Adds a task to be performed at some point in the future. + # + # @param task VCAP::Stager::Task + def add_task(task) + @logger.info("Queueing task, task_id=#{task.task_id}") + @queued_tasks << task + start_tasks + end + + # @param blk Block Invoked when there are no active or queued tasks. Should have arity 0. + def on_idle(&blk) + @event_callbacks[:idle] = blk + event(:idle) if num_tasks == 0 + end + + private + + def start_tasks + while (@queued_tasks.length > 0) && (@active_tasks.size < @max_active_tasks) + task = @queued_tasks.shift + task.user = @user_mgr.checkout_user if @user_mgr + @active_tasks[task.task_id] = task + @logger.info("Starting task, task_id=#{task.task_id}") + task.perform {|result| task_completed(task, result) } + end + end + + def task_completed(task, result) + @logger.info("Task, id=#{task.task_id} completed, result='#{result}'") + + @active_tasks.delete(task.task_id) + event(:task_completed, task, result) + event(:idle) if num_tasks == 0 + + if task.user + cmd = "sudo -u '##{task.user[:uid]}' pkill -9 -U #{task.user[:uid]}" + VCAP::Stager::Util.run_command(cmd) do |res| + if res[:status].exitstatus < 2 + @user_mgr.return_user(task.user) + else + # Don't return the user to the pool. We have possibly violated the invariant + # that no process belonging to the user is running when it is returned to + # the pool. + @logger.warn("Failed killing child processes for user #{task.user[:uid]}") + @logger.warn("Command '#{cmd}' exited with status #{res[:status]}") + @logger.warn("stdout = #{res[:stdout]}") + @logger.warn("stderr = #{res[:stderr]}") + end + start_tasks + end + else + start_tasks + end + end + + def event(name, *args) + cb = @event_callbacks[name] + return unless cb + cb.call(*args) + end + +end diff --git a/stager/lib/vcap/stager/task_result.rb b/stager/lib/vcap/stager/task_result.rb index 4928f7f74..de02248a0 100644 --- a/stager/lib/vcap/stager/task_result.rb +++ b/stager/lib/vcap/stager/task_result.rb @@ -1,59 +1,48 @@ require 'yajl' +require 'vcap/json_schema' + module VCAP module Stager end end class VCAP::Stager::TaskResult - ST_SUCCESS = 0 - ST_FAILED = 1 - - attr_reader :task_id, :status, :details + SCHEMA = VCAP::JsonSchema.build do + { :task_id => String, + :task_log => String, + optional(:error) => String, + } + end class << self - attr_accessor :redis - - def fetch(task_id, redis=nil) - redis ||= @redis - key = key_for_id(task_id) - result = redis.get(key) - result ? decode(result) : nil - end - def decode(enc_res) dec_res = Yajl::Parser.parse(enc_res) - VCAP::Stager::TaskResult.new(dec_res['task_id'], dec_res['status'], dec_res['details']) - end - - def key_for_id(task_id) - "staging_task_result:#{task_id}" + SCHEMA.validate(dec_res) + dec_res['error'] = VCAP::Stager::TaskError.decode(dec_res['error']) if dec_res['error'] + VCAP::Stager::TaskResult.new(dec_res['task_id'], dec_res['task_log'], dec_res['error']) end end - def initialize(task_id, status, details) - @task_id = task_id - @status = status - @details = details - end + attr_reader :task_id, :task_log, :error - def was_success? - @status == ST_SUCCESS + def initialize(task_id, task_log, error=nil) + @task_id = task_id + @task_log = task_log + @error = error end def encode h = { - :task_id => @task_id, - :status => @status, - :details => @details, + :task_id => @task_id, + :task_log => @task_log, } + h[:error] = @error.encode if @error + Yajl::Encoder.encode(h) end - def save(redis=nil) - redis ||= self.class.redis - key = self.class.key_for_id(@task_id) - val = encode() - redis.set(key, val) + def was_success? + @error == nil end end diff --git a/stager/lib/vcap/stager/util.rb b/stager/lib/vcap/stager/util.rb index 4dc0e7131..5282cc613 100644 --- a/stager/lib/vcap/stager/util.rb +++ b/stager/lib/vcap/stager/util.rb @@ -1,5 +1,5 @@ require 'fileutils' -require 'net/http' +require 'rest_client' require 'uri' @@ -20,30 +20,30 @@ class << self # # @return Net::HTTPResponse def fetch_zipped_app(app_uri, dest_path) - uri = URI.parse(app_uri) - req = make_request(Net::HTTP::Get, uri) - resp = nil - - Net::HTTP.start(uri.host, uri.port) do |http| - http.request(req) do |resp| - unless resp.kind_of?(Net::HTTPSuccess) - raise VCAP::Stager::AppDownloadError, "Non 200 status code (#{resp.code})" - end + save_app = proc do |resp| + unless resp.kind_of?(Net::HTTPSuccess) + raise VCAP::Stager::AppDownloadError, + "Non 200 status code (#{resp.code})" + end - begin - File.open(dest_path, 'w+') do |f| - resp.read_body do |chunk| - f.write(chunk) - end + begin + File.open(dest_path, 'w+') do |f| + resp.read_body do |chunk| + f.write(chunk) end - rescue => e - FileUtils.rm_f(dest_path) - raise e end - + rescue => e + FileUtils.rm_f(dest_path) + raise e end + + resp end - resp + + req = RestClient::Request.new(:url => app_uri, + :method => :get, + :block_response => save_app) + req.execute end # Uploads the file living at _droplet_path_ to the uri using a PUT request. @@ -55,34 +55,67 @@ def fetch_zipped_app(app_uri, dest_path) # # @return Net::HTTPResponse def upload_droplet(droplet_uri, droplet_path) - uri = URI.parse(droplet_uri) - req = make_request(Net::HTTP::Put, uri) - resp = nil + RestClient.post(droplet_uri, + :upload => { + :droplet => File.new(droplet_path, 'rb') + }) + end - File.open(droplet_path, 'r') do |f| - req.body_stream = f - req.content_type = 'application/octet-stream' - req.content_length = f.size - Net::HTTP.start(uri.host, uri.port) do |http| - resp = http.request(req) - unless resp.kind_of?(Net::HTTPSuccess) - raise VCAP::Stager::DropletUploadError, "Non 200 status code (#{resp.code})" - end + # Runs a command in a subprocess with an optional timeout. Captures stdout, stderr. + # Not the prettiest implementation, but neither EM.popen nor EM.system exposes + # a way to capture stderr.. + # + # NB: Must be called with the EM reactor running. + # + # @param command String Command to execute + # @param expected_exitstatus Integer + # @param timeout Integer How long the command can execute for + # @param blk Block Callback to execute when the command completes. It will + # be called with a hash of: + # :success Bool True if the command exited with expected status + # :stdout String + # :stderr String + # :status Integer + # :timed_out Bool + def run_command(command, expected_exitstatus=0, timeout=nil, &blk) + expire_timer = nil + timed_out = false + stderr_tmpfile = Tempfile.new('stager_stderr') + stderr_path = stderr_tmpfile.path + stderr_tmpfile.close + + pid = EM.system('sh', '-c', "#{command} 2> #{stderr_path}") do |stdout, status| + EM.cancel_timer(expire_timer) if expire_timer + + begin + stderr = File.read(stderr_path) + stderr_tmpfile.unlink + rescue => e + logger = VCAP::Logging.logger('vcap.stager.task.run_command') + logger.error("Failed reading stderr from '#{stderr_path}' for command '#{command}': #{e}") + logger.error(e) end - end - resp - end + res = { + :success => status.exitstatus == expected_exitstatus, + :stdout => stdout, + :stderr => stderr, + :status => status, + :timed_out => timed_out, + } - private + blk.call(res) + end - def make_request(klass, uri) - req = klass.new(uri.path) - if uri.user && uri.password - req.basic_auth(uri.user, uri.password) + if timeout + expire_timer = EM.add_timer(timeout) do + logger = VCAP::Logging.logger('vcap.stager.task.expire_command') + logger.warn("Killing command '#{command}', pid=#{pid}, timeout=#{timeout}") + timed_out = true + EM.system('sh', '-c', "ps --ppid #{pid} -o pid= | xargs kill -9") + end end - req end end diff --git a/stager/lib/vcap/stager/version.rb b/stager/lib/vcap/stager/version.rb index 49d0cd9ec..bbc7e2c37 100644 --- a/stager/lib/vcap/stager/version.rb +++ b/stager/lib/vcap/stager/version.rb @@ -1,5 +1,5 @@ module VCAP module Stager - VERSION = '0.1.0' + VERSION = '0.1.3' end end diff --git a/stager/spec/fixtures/apps/rails3_gitgems/source/config/initializers/backtrace_silencers.rb b/stager/spec/fixtures/apps/rails3_gitgems/source/config/initializers/backtrace_silencers.rb deleted file mode 100644 index 59385cdf3..000000000 --- a/stager/spec/fixtures/apps/rails3_gitgems/source/config/initializers/backtrace_silencers.rb +++ /dev/null @@ -1,7 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. -# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } - -# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. -# Rails.backtrace_cleaner.remove_silencers! diff --git a/stager/spec/fixtures/stager_config.yml.erb b/stager/spec/fixtures/stager_config.yml.erb index 2199c4ee5..057d7daef 100644 --- a/stager/spec/fixtures/stager_config.yml.erb +++ b/stager/spec/fixtures/stager_config.yml.erb @@ -1,12 +1,11 @@ --- logging: level: debug2 -redis: - host: 127.0.0.1 - port: <%= redis_port %> nats_uri: nats://127.0.0.1:<%= nats_port %> dirs: manifests: <%= manifest_dir %> max_staging_duration: 120 +max_active_tasks: 10 +secure: false queues: ['staging'] pid_filename: <%= pid_filename %> diff --git a/stager/spec/functional/stager_spec.rb b/stager/spec/functional/stager_spec.rb index 2dee4b8f0..5bb868d7c 100644 --- a/stager/spec/functional/stager_spec.rb +++ b/stager/spec/functional/stager_spec.rb @@ -1,4 +1,3 @@ -require 'resque' require 'sinatra/base' require 'spec_helper' @@ -20,9 +19,9 @@ class DummyHandler < Sinatra::Base end end - put '/droplets/:name' do + post '/droplets/:name' do dest_path = File.join(settings.upload_path, params[:name] + '.tgz') - File.open(dest_path, 'w+') {|f| f.write(request.body.read) } + File.open(dest_path, 'w+') {|f| f.write(params[:upload][:droplet]) } [200, "Success!"] end @@ -34,7 +33,7 @@ class DummyHandler < Sinatra::Base describe VCAP::Stager do before :all do # Set this to true if you want to save the output of each component - @save_logs = false + @save_logs = ENV['VCAP_TEST_LOG'] == 'true' @app_props = { 'framework' => 'sinatra', 'runtime' => 'ruby18', @@ -52,17 +51,14 @@ class DummyHandler < Sinatra::Base @uploads = {} @http_server = start_http_server(@tmp_dirs[:upload], @tmp_dirs[:download], @tmp_dirs[:http]) @http_port = @http_server.port - @redis_server = start_redis(@tmp_dirs[:redis]) @nats_server = start_nats(@tmp_dirs[:nats]) - @stager = start_stager(@redis_server.port, - @nats_server.port, + @stager = start_stager(@nats_server.port, StagingPlugin.manifest_root, @tmp_dirs[:stager]) end after :each do @stager.stop - @redis_server.stop @http_server.stop @nats_server.stop if @save_logs @@ -83,22 +79,16 @@ class DummyHandler < Sinatra::Base # Wait for the stager to tell us it is done task_result = wait_for_task_result(@nats_server.uri, - @redis_server.port, subj, [app_id, @app_props, dl_uri, ul_uri, subj]) task_result.should_not be_nil task_result.was_success?.should be_true - - # Check result in redis - result = VCAP::Stager::TaskResult.fetch(app_id, Redis.new(:host => '127.0.0.1', :port => @redis_server.port)) - result.should_not be_nil - result.was_success?.should be_true end end def create_tmp_dirs tmp_dirs = {:base => Dir.mktmpdir} - for d in [:upload, :download, :redis, :nats, :stager, :http] + for d in [:upload, :download, :nats, :stager, :http] tmp_dirs[d] = File.join(tmp_dirs[:base], d.to_s) Dir.mkdir(tmp_dirs[d]) end @@ -121,16 +111,6 @@ def start_http_server(upload_path, download_path, http_dir) http_server end - def start_redis(redis_dir) - # XXX - Should this come from a config? - redis_path = `which redis-server`.chomp - redis_path.should_not == '' - port = VCAP.grab_ephemeral_port - redis = VCAP::Stager::Spec::ForkedRedisServer.new(redis_path, port, redis_dir) - redis.start.wait_ready.should be_true - redis - end - def start_nats(nats_dir) port = VCAP.grab_ephemeral_port pid_file = File.join(nats_dir, 'nats.pid') @@ -139,22 +119,31 @@ def start_nats(nats_dir) nats end - def start_stager(redis_port, nats_port, manifest_dir, stager_dir) - stager = VCAP::Stager::Spec::ForkedStager.new(redis_port, nats_port, manifest_dir, stager_dir) - stager.start.wait_ready.should be_true + def start_stager(nats_port, manifest_dir, stager_dir) + stager = VCAP::Stager::Spec::ForkedStager.new(nats_port, manifest_dir, stager_dir) + ready = false + NATS.start(:uri => "nats://127.0.0.1:#{nats_port}") do + EM.add_timer(30) { EM.stop } + NATS.subscribe('vcap.component.announce') do + ready = true + EM.stop + end + NATS.publish('zazzle', "BLAH") + stager.start + end + ready.should be_true stager end - def wait_for_task_result(nats_uri, redis_port, subj, task_args) + def wait_for_task_result(nats_uri, subj, task_args) task_result = nil NATS.start(:uri => nats_uri) do - EM.add_timer(30) { NATS.stop } + EM.add_timer(5) { NATS.stop } NATS.subscribe(subj) do |msg| task_result = VCAP::Stager::TaskResult.decode(msg) NATS.stop end - Resque.redis = Redis.new(:host => '127.0.0.1', :port => redis_port) - Resque.enqueue(VCAP::Stager::Task, *task_args) + VCAP::Stager::Task.new(*task_args).enqueue('staging') end task_result end diff --git a/stager/spec/spec_helper.rb b/stager/spec/spec_helper.rb index 5f202cf40..3c0b5b5fb 100644 --- a/stager/spec/spec_helper.rb +++ b/stager/spec/spec_helper.rb @@ -4,11 +4,10 @@ $LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__)) require 'vcap/common' +require 'vcap/logging' require 'vcap/subprocess' require 'vcap/stager' -# XXX - Move gem cache out of home dir... - # Requires supporting ruby files with custom matchers and macros, etc, # in spec/support/ and its subdirectories. Dir[File.expand_path('../support/**/*.rb', __FILE__)].each {|f| require f} @@ -18,6 +17,8 @@ STAGING_TEMP = Dir.mktmpdir StagingPlugin.manifest_root = STAGING_TEMP +VCAP::Logging.setup_from_config({:level => :debug2}) if ENV['VCAP_TEST_LOG'] == 'true' + RSpec.configure do |config| config.before(:all) do begin diff --git a/stager/spec/support/forked_redis_server.rb b/stager/spec/support/forked_redis_server.rb deleted file mode 100644 index 737a08181..000000000 --- a/stager/spec/support/forked_redis_server.rb +++ /dev/null @@ -1,47 +0,0 @@ -require 'vcap/spec/forked_component/base' - -require 'erb' - -module VCAP - module Stager - module Spec - end - end -end - -class VCAP::Stager::Spec::ForkedRedisServer < VCAP::Spec::ForkedComponent::Base - CONF_TEMPLATE = File.expand_path('../../fixtures/redis.conf.erb', __FILE__) - - attr_reader :log_dir, :port - - def initialize(redis_path, port, log_dir='/tmp', keep_logs=false) - @port = port - @log_dir = log_dir - @redis_conf = File.join(@log_dir, 'redis.conf') - @pid_file = File.join(@log_dir, 'redis.pid') - write_conf(@redis_conf) - - super("#{redis_path} #{@redis_conf}" , 'redis-server', @log_dir) - end - - def ready? - begin - s = TCPSocket.new('127.0.0.1', @port) - s.close - true - rescue => e - false - end - end - - private - - def write_conf(filename) - template = ERB.new(File.read(CONF_TEMPLATE)) - # Wish I could pass these to ERB instead of it reaching into the current scope - # Investigate using something like liquid templates... - port = @port - conf = template.result(binding()) - File.open(filename, 'w+') {|f| f.write(conf) } - end -end diff --git a/stager/spec/support/forked_stager.rb b/stager/spec/support/forked_stager.rb index 020b31ef8..60d426372 100644 --- a/stager/spec/support/forked_stager.rb +++ b/stager/spec/support/forked_stager.rb @@ -15,10 +15,9 @@ class VCAP::Stager::Spec::ForkedStager < VCAP::Spec::ForkedComponent::Base CONF_TEMPLATE = File.expand_path('../../fixtures/stager_config.yml.erb', __FILE__) STAGER_PATH = File.expand_path('../../../bin/stager', __FILE__) - attr_reader :log_dir, :redis_port, :nats_port, :manifest_dir, :pid_filename + attr_reader :log_dir, :nats_port, :manifest_dir, :pid_filename - def initialize(redis_port, nats_port, manifest_dir, log_dir='/tmp', keep_logs=false) - @redis_port = redis_port + def initialize(nats_port, manifest_dir, log_dir='/tmp', keep_logs=false) @nats_port = nats_port @manifest_dir = manifest_dir @log_dir = log_dir @@ -29,10 +28,6 @@ def initialize(redis_port, nats_port, manifest_dir, log_dir='/tmp', keep_logs=fa super("#{STAGER_PATH} -c #{@conf_path} -s 1", 'stager', @log_dir) end - def ready? - VCAP.process_running?(@pid) - end - def stop return unless @pid && VCAP.process_running?(@pid) Process.kill('KILL', @pid) @@ -48,7 +43,6 @@ def write_conf(filename) template = ERB.new(File.read(CONF_TEMPLATE)) # Wish I could pass these to ERB instead of it reaching into the current scope # Investigate using something like liquid templates... - redis_port = @redis_port nats_port = @nats_port manifest_dir = @manifest_dir pid_filename = @pid_filename diff --git a/stager/spec/unit/task_error_spec.rb b/stager/spec/unit/task_error_spec.rb new file mode 100644 index 000000000..2ceb09711 --- /dev/null +++ b/stager/spec/unit/task_error_spec.rb @@ -0,0 +1,37 @@ +require File.join(File.dirname(__FILE__), 'spec_helper') + +describe VCAP::Stager::TaskError do + describe '#encode' do + it 'should encode the error as json' do + se = VCAP::Stager::StagingPluginError.new('xxx') + dec_se = Yajl::Parser.parse(se.encode) + dec_se['class'].should == 'VCAP::Stager::StagingPluginError' + dec_se['details'].should == 'xxx' + end + end + + describe '#decode' do + it 'should decode errors and return an instance of the appropriate error' do + se = VCAP::Stager::DropletCreationError.new + dec_se = VCAP::Stager::TaskError.decode(se.encode) + dec_se.class.should == VCAP::Stager::DropletCreationError + dec_se.details.should be_nil + end + + it 'should decode details if set' do + dce = VCAP::Stager::DropletCreationError.new('xxx') + dec_dce = VCAP::Stager::TaskError.decode(dce.encode) + dec_dce.details.should == 'xxx' + end + end + + describe '#to_s' do + it 'should include the error boilerplate along with any details' do + dce = VCAP::Stager::DropletCreationError.new + dce.to_s.should == 'Failed creating droplet' + + dce = VCAP::Stager::DropletCreationError.new('xxx') + dce.to_s.should == "Failed creating droplet:\n xxx" + end + end +end diff --git a/stager/spec/unit/task_manager_spec.rb b/stager/spec/unit/task_manager_spec.rb new file mode 100644 index 000000000..ebfef090b --- /dev/null +++ b/stager/spec/unit/task_manager_spec.rb @@ -0,0 +1,53 @@ +require File.join(File.dirname(__FILE__), 'spec_helper') + +describe VCAP::Stager::TaskManager do + describe '#start_tasks'do + it 'should start as many tasks as allowed' do + tm = VCAP::Stager::TaskManager.new(3) + tasks = [] + for ii in 0..2 + t = make_mock_task(ii) + t.should_receive(:perform).with(any_args()) + tasks << t + end + t = make_mock_task(3) + t.should_not_receive(:perform) + tasks << t + # XXX :( + tm.instance_variable_set(:@queued_tasks, tasks) + tm.send(:start_tasks) + end + + it 'should checkout a secure user per task' do + um = mock(:secure_user_manager) + um.should_receive(:checkout_user).twice() + tm = VCAP::Stager::TaskManager.new(3, um) + tasks = [] + for ii in 0..1 + t = make_mock_task(ii) + t.should_receive(:perform).with(any_args()) + t.should_receive(:user=).with(nil) + tasks << t + end + tm.instance_variable_set(:@queued_tasks, tasks) + tm.send(:start_tasks) + end + end + + describe '#task_completed' do + it 'should emit a task_completed event' do + tm = VCAP::Stager::TaskManager.new(3) + task = make_mock_task(1) + task.stub(:user).and_return(nil) + tm.should_receive(:event).with(:task_completed, task, 'test') + tm.should_receive(:event).with(:idle) + tm.send(:task_completed, task, 'test') + end + end + + def make_mock_task(id) + t = mock("task_#{id}") + t.stub(:task_id).and_return(id) + t + end +end diff --git a/stager/spec/unit/task_result_spec.rb b/stager/spec/unit/task_result_spec.rb index 7fcf6b721..9f2b4f092 100644 --- a/stager/spec/unit/task_result_spec.rb +++ b/stager/spec/unit/task_result_spec.rb @@ -1,47 +1,29 @@ require File.join(File.dirname(__FILE__), 'spec_helper') describe VCAP::Stager::TaskResult do - before :all do - @task_id = 'test_task' - @task_result = VCAP::Stager::TaskResult.new(@task_id, 0, 'Hello') - @task_key = VCAP::Stager::TaskResult.key_for_id(@task_id) - end - - describe '#save' do - it 'should set a json encoded blob in redis' do - redis_mock = mock(:redis) - redis_mock.should_receive(:set).with(@task_key, @task_result.encode) - @task_result.save(redis_mock) + describe '#encode' do + it 'should encode the task result as json' do + tr = VCAP::Stager::TaskResult.new('xxx', 'yyy') + dec_tr = Yajl::Parser.parse(tr.encode) + dec_tr['task_id'].should == tr.task_id + dec_tr['task_log'].should == tr.task_log + dec_tr['error'].should == nil end - it 'should use the static instance of redis if none is provided' do - redis_mock = mock(:redis) - redis_mock.should_receive(:set).with(@task_key, @task_result.encode) - VCAP::Stager::TaskResult.redis = redis_mock - @task_result.save + it 'should encode the associated error if supplied' do + err = mock(:task_error) + err.should_receive(:encode) + VCAP::Stager::TaskResult.new('xxx', 'yyy', err).encode end end - describe '#fetch' do - it 'should fetch and decode an existing task result' do - redis_mock = mock(:redis) - redis_mock.should_receive(:get).with(@task_key).and_return(@task_result.encode) - res = VCAP::Stager::TaskResult.fetch(@task_id, redis_mock) - res.should be_instance_of(VCAP::Stager::TaskResult) - end - - it 'should return nil if no key exists' do - redis_mock = mock(:redis) - redis_mock.should_receive(:get).with(@task_key).and_return(nil) - res = VCAP::Stager::TaskResult.fetch(@task_id, redis_mock) - res.should be_nil - end - - it 'should use the static instance of redis if none is provided' do - redis_mock = mock(:redis) - redis_mock.should_receive(:get).with(@task_key).and_return(nil) - VCAP::Stager::TaskResult.redis = redis_mock - res = VCAP::Stager::TaskResult.fetch(@task_id, redis_mock) + describe '.decode' do + it 'should decode encoded task results' do + tr = VCAP::Stager::TaskResult.new('xxx', 'yyy', VCAP::Stager::AppDownloadError.new) + dec_tr = VCAP::Stager::TaskResult.decode(tr.encode) + dec_tr.task_id.should == tr.task_id + dec_tr.task_log.should == tr.task_log + dec_tr.error.class.should == VCAP::Stager::AppDownloadError end end end diff --git a/stager/spec/unit/task_spec.rb b/stager/spec/unit/task_spec.rb index 993330897..a0a51d9ad 100644 --- a/stager/spec/unit/task_spec.rb +++ b/stager/spec/unit/task_spec.rb @@ -1,107 +1,117 @@ require File.join(File.dirname(__FILE__), 'spec_helper') +require 'tmpdir' + describe VCAP::Stager::Task do - describe '#perform' do + describe '#creating_staging_dirs' do + it 'should create the basic directory structure needed for staging' do + task = VCAP::Stager::Task.new(nil, nil, nil, nil, nil) + dirs = task.send(:create_staging_dirs) + File.directory?(dirs[:base]).should be_true + File.directory?(dirs[:unstaged]).should be_true + File.directory?(dirs[:staged]).should be_true + FileUtils.rm_rf(dirs[:base]) if dirs[:base] + end + end + + describe '#download_app' do + before :each do + @tmp_dir = Dir.mktmpdir + @task = VCAP::Stager::Task.new(1, nil, nil, nil, nil) + end + + after :each do + FileUtils.rm_rf(@tmp_dir) + end + + it 'should raise an instance of VCAP::Stager::AppDownloadError if the download fails' do + @task.stub(:run_logged).and_return({:success => false}) + expect { @task.send(:download_app, @tmp_dir, @tmp_dir) }.to raise_error(VCAP::Stager::AppDownloadError) + end + + it 'should raise an instance of VCAP::Stager::AppUnzipError if the unzip fails' do + @task.stub(:run_logged).and_return({:success => true}, {:success => false}) + expect { @task.send(:download_app, @tmp_dir, @tmp_dir) }.to raise_error(VCAP::Stager::AppUnzipError) + end + + it 'should leave the temporary working dir as it found it' do + glob_exp = File.join(@tmp_dir, '*') + pre_files = Dir.glob(glob_exp) + @task.stub(:run_logged).and_return({:success => true}, {:success => true}) + @task.send(:download_app, @tmp_dir, @tmp_dir) + Dir.glob(glob_exp).should == pre_files + end + end + + describe '#run_staging_plugin' do before :each do @tmp_dir = Dir.mktmpdir - @zipped_app_path = File.join(@tmp_dir, 'app.zip') - @unstaged_dir = File.join(@tmp_dir, 'unstaged') - @staged_dir = File.join(@tmp_dir, 'staged') - @droplet_path = File.join(@tmp_dir, 'droplet.tgz') - VCAP::Stager.config = {:dirs => {:manifests => @tmp_dir}} - end + @props = { + 'runtime' => 'ruby', + 'framework' => 'sinatra', + 'services' => [{}], + 'resources' => { + 'memory' => 128, + 'disk' => 1024, + 'fds' => 64, + }, + } + @task = VCAP::Stager::Task.new(1, @props, nil, nil, nil) + end after :each do FileUtils.rm_rf(@tmp_dir) end - it 'should result in failure if fetching the app bits fails' do - task = make_task - VCAP::Stager::Util.should_receive(:fetch_zipped_app).with(any_args()).and_raise("Download failed") - expect { task.perform }.to raise_error "Download failed" - task.result.was_success?.should be_false - end - - it 'should result in failure if unzipping the app bits fails' do - task = make_task - VCAP::Stager::Util.should_receive(:fetch_zipped_app).with(any_args()).and_return(nil) - # We could mock out the call, but this is more fun - File.open(File.join(@tmp_dir, 'app.zip'), 'w+') {|f| f.write("GARBAGE") } - expect { task.perform }.to raise_error(VCAP::SubprocessStatusError) - task.result.was_success?.should be_false - end - - it 'should result in failure if staging the app fails' do - task = make_task - nullify_method(VCAP::Stager::Util, :fetch_zipped_app) - nullify_method(VCAP::Subprocess, :run, "unzip -q #{@zipped_app_path} -d #{@unstaged_dir}") - task.should_receive(:run_staging_plugin).and_raise("Staging failed") - expect { task.perform }.to raise_error("Staging failed") - task.result.was_success?.should be_false - end - - it 'should result in failure if creating the droplet fails' do - task = make_task - nullify_method(VCAP::Stager::Util, :fetch_zipped_app) - nullify_method(VCAP::Subprocess, :run, "unzip -q #{@zipped_app_path} -d #{@unstaged_dir}") - nullify_method(task, :run_staging_plugin) - VCAP::Subprocess.should_receive(:run).with("cd #{@staged_dir}; COPYFILE_DISABLE=true tar -czf #{@droplet_path} *").and_raise("Creating droplet failed") - expect { task.perform }.to raise_error("Creating droplet failed") - task.result.was_success?.should be_false - end - - it 'should result in failure if uploading the droplet fails' do - task = make_task - nullify_method(VCAP::Stager::Util, :fetch_zipped_app) - nullify_method(VCAP::Subprocess, :run, "unzip -q #{@zipped_app_path} -d #{@unstaged_dir}") - nullify_method(task, :run_staging_plugin) - nullify_method(VCAP::Subprocess, :run, "cd #{@staged_dir}; COPYFILE_DISABLE=true tar -czf #{@droplet_path} *") - VCAP::Stager::Util.should_receive(:upload_droplet).with(any_args()).and_raise("Upload failed") - expect { task.perform }.to raise_error("Upload failed") - task.result.was_success?.should be_false - end - - it 'should result in failure if publishing the result fails' do - task = make_task([]) - task.should_receive(:save_result).twice().and_return(nil) - nullify_method(VCAP::Stager::Util, :fetch_zipped_app) - nullify_method(VCAP::Subprocess, :run, "unzip -q #{@zipped_app_path} -d #{@unstaged_dir}") - nullify_method(task, :run_staging_plugin) - nullify_method(VCAP::Subprocess, :run, "cd #{@staged_dir}; COPYFILE_DISABLE=true tar -czf #{@droplet_path} *") - nullify_method(VCAP::Stager::Util, :upload_droplet) - task.should_receive(:publish_result).and_raise(VCAP::Stager::ResultPublishingError) - expect { task.perform }.to raise_error(VCAP::Stager::ResultPublishingError) - task.result.was_success?.should be_false - end - - it 'should clean up its temporary directory' do - task = make_task - VCAP::Stager::Util.should_receive(:fetch_zipped_app).with(any_args()).and_raise("Download failed") - FileUtils.should_receive(:rm_rf).with(@tmp_dir).twice - expect { task.perform }.to raise_error + it 'should raise an instance of VCAP::Stager::StagingTimeoutError on plugin timeout' do + @task.stub(:run_logged).and_return({:success => false, :timed_out => true}) + expect { @task.send(:run_staging_plugin, @tmp_dir, @tmp_dir, @tmp_dir) }.to raise_error(VCAP::Stager::StagingTimeoutError) end + it 'should raise an instance of VCAP::Stager::StagingPlugin on plugin failure' do + @task.stub(:run_logged).and_return({:success => false}) + expect { @task.send(:run_staging_plugin, @tmp_dir, @tmp_dir, @tmp_dir) }.to raise_error(VCAP::Stager::StagingPluginError) + end + + it 'should leave the temporary working dir as it found it' do + glob_exp = File.join(@tmp_dir, '*') + pre_files = Dir.glob(glob_exp) + @task.stub(:run_logged).and_return({:success => true}) + @task.send(:run_staging_plugin, @tmp_dir, @tmp_dir, @tmp_dir) + Dir.glob(glob_exp).should == pre_files + end end - def nullify_method(instance, method, *args) - if args.length > 0 - instance.should_receive(method).with(*args).and_return(nil) - else - instance.should_receive(method).with(any_args()).and_return(nil) + describe '#upload_app' do + before :each do + @tmp_dir = Dir.mktmpdir + @task = VCAP::Stager::Task.new(1, nil, nil, nil, nil) + end + + after :each do + FileUtils.rm_rf(@tmp_dir) + end + + it 'should raise an instance of VCAP::Stager::DropletCreationError if the gzip fails' do + @task.stub(:run_logged).and_return({:success => false}) + expect { @task.send(:upload_droplet, @tmp_dir, @tmp_dir) }.to raise_error(VCAP::Stager::DropletCreationError) + end + + it 'should raise an instance of VCAP::Stager::DropletUploadError if the upload fails' do + @task.stub(:run_logged).and_return({:success => true}, {:success => false}) + expect { @task.send(:upload_droplet, @tmp_dir, @tmp_dir) }.to raise_error(VCAP::Stager::DropletUploadError) + end + + it 'should leave the temporary working dir as it found it' do + glob_exp = File.join(@tmp_dir, '*') + pre_files = Dir.glob(glob_exp) + @task.stub(:run_logged).and_return({:success => true}, {:success => true}) + @task.send(:upload_droplet, @tmp_dir, @tmp_dir) + Dir.glob(glob_exp).should == pre_files end end - def make_task(null_methods=[:save_result, :publish_result]) - task = VCAP::Stager::Task.new('test', nil, nil, nil, nil) - dirs = { - :base => @tmp_dir, - :unstaged => @unstaged_dir, - :staged => @staged_dir, - } - task.should_receive(:create_staging_dirs).and_return(dirs) - for meth in null_methods - task.should_receive(meth).and_return(nil) - end - task + + describe '#perform' do end end diff --git a/stager/spec/unit/util_spec.rb b/stager/spec/unit/util_spec.rb index 28eb34852..3b214c817 100644 --- a/stager/spec/unit/util_spec.rb +++ b/stager/spec/unit/util_spec.rb @@ -1,7 +1,6 @@ -require 'tmpdir' - -require 'spec_helper' +require File.join(File.dirname(__FILE__), 'spec_helper') +require 'tmpdir' describe VCAP::Stager::Util do describe '.fetch_zipped_app' do @@ -51,7 +50,7 @@ before :each do @body = 'hello world' @tmpdir = Dir.mktmpdir - @put_uri = 'http://user:pass@www.foobar.com/droplet.zip' + @post_uri = 'http://user:pass@www.foobar.com/droplet.zip' @droplet_file = File.join(@tmpdir, 'droplet.zip') File.open(@droplet_file, 'w+') {|f| f.write(@body) } end @@ -61,24 +60,71 @@ end it 'should pass along credentials when supplied' do - stub_request(:put, @put_uri).to_return(:status => 200) - VCAP::Stager::Util.upload_droplet(@put_uri, @droplet_file) - a_request(:put, @put_uri).should have_been_made - end - - it 'pass the file contents as the body' do - stub_request(:put, @put_uri).to_return(:status => 200) - VCAP::Stager::Util.upload_droplet(@put_uri, @droplet_file) - a_request(:put, @put_uri).with(:body => @body).should have_been_made + stub_request(:post, @post_uri).to_return(:status => 200) + VCAP::Stager::Util.upload_droplet(@post_uri, @droplet_file) + a_request(:post, @post_uri).should have_been_made end it 'should raise an exception on non-200 status codes' do - stub_request(:put, @put_uri).to_return(:status => 404) + stub_request(:post, @post_uri).to_return(:status => 404) expect do - VCAP::Stager::Util.upload_droplet(@put_uri, @droplet_file) - end.to raise_error(VCAP::Stager::DropletUploadError) + VCAP::Stager::Util.upload_droplet(@post_uri, @droplet_file) + end.to raise_error + end + end + + describe '.run_command' do + it 'should correctly capture exit status' do + status = nil + EM.run do + VCAP::Stager::Util.run_command('exit 10') do |res| + status = res[:status] + EM.stop + end + end + status.exitstatus.should == 10 + end + + it 'should correctly capture stdout' do + stdout = nil + EM.run do + VCAP::Stager::Util.run_command('echo hello world') do |res| + stdout = res[:stdout] + EM.stop + end + end + stdout.should == "hello world\n" end + it 'should correctly capture stderr' do + stderr = nil + EM.run do + VCAP::Stager::Util.run_command('ruby -e \'$stderr.puts "hello world"\'') do |res| + stderr = res[:stderr] + EM.stop + end + end + stderr.should == "hello world\n" + end + + it 'should correctly time out commands' do + timed_out = nil + EM.run do + VCAP::Stager::Util.run_command('echo hello world', 0, 5) do |res| + timed_out = res[:timed_out] + EM.stop + end + end + timed_out.should == false + + EM.run do + VCAP::Stager::Util.run_command('sleep 5', 0, 1) do |res| + timed_out = res[:timed_out] + EM.stop + end + end + timed_out.should == true + end end end diff --git a/stager/vendor/cache/json-1.5.3.gem b/stager/vendor/cache/json-1.5.3.gem deleted file mode 100644 index d6b466737..000000000 Binary files a/stager/vendor/cache/json-1.5.3.gem and /dev/null differ diff --git a/stager/vendor/cache/mime-types-1.16.gem b/stager/vendor/cache/mime-types-1.16.gem new file mode 100644 index 000000000..49f1ef203 Binary files /dev/null and b/stager/vendor/cache/mime-types-1.16.gem differ diff --git a/stager/vendor/cache/nokogiri-1.5.0.gem b/stager/vendor/cache/nokogiri-1.5.0.gem new file mode 100644 index 000000000..47c37a67a Binary files /dev/null and b/stager/vendor/cache/nokogiri-1.5.0.gem differ diff --git a/stager/vendor/cache/redis-2.2.1.gem b/stager/vendor/cache/redis-2.2.1.gem deleted file mode 100644 index b8a06e0a2..000000000 Binary files a/stager/vendor/cache/redis-2.2.1.gem and /dev/null differ diff --git a/stager/vendor/cache/redis-namespace-1.0.3.gem b/stager/vendor/cache/redis-namespace-1.0.3.gem deleted file mode 100644 index 9cf8471fb..000000000 Binary files a/stager/vendor/cache/redis-namespace-1.0.3.gem and /dev/null differ diff --git a/stager/vendor/cache/resque-1.17.1.gem b/stager/vendor/cache/resque-1.17.1.gem deleted file mode 100644 index 16944bdf7..000000000 Binary files a/stager/vendor/cache/resque-1.17.1.gem and /dev/null differ diff --git a/stager/vendor/cache/rest-client-1.6.7.gem b/stager/vendor/cache/rest-client-1.6.7.gem new file mode 100644 index 000000000..0cba1056a Binary files /dev/null and b/stager/vendor/cache/rest-client-1.6.7.gem differ diff --git a/stager/vendor/cache/vcap_common-0.99.gem b/stager/vendor/cache/vcap_common-0.99.gem index 4f1555861..6c69c7b21 100644 Binary files a/stager/vendor/cache/vcap_common-0.99.gem and b/stager/vendor/cache/vcap_common-0.99.gem differ diff --git a/cloud_controller/vendor/cache/vcap_staging-0.1.0.gem b/stager/vendor/cache/vcap_staging-0.1.2.gem similarity index 90% rename from cloud_controller/vendor/cache/vcap_staging-0.1.0.gem rename to stager/vendor/cache/vcap_staging-0.1.2.gem index c56fd0fdb..aa1f8821c 100644 Binary files a/cloud_controller/vendor/cache/vcap_staging-0.1.0.gem and b/stager/vendor/cache/vcap_staging-0.1.2.gem differ diff --git a/stager/vendor/cache/vegas-0.1.8.gem b/stager/vendor/cache/vegas-0.1.8.gem deleted file mode 100644 index ea0aeec1f..000000000 Binary files a/stager/vendor/cache/vegas-0.1.8.gem and /dev/null differ diff --git a/stager/vendor/cache/webmock-1.6.4.gem b/stager/vendor/cache/webmock-1.6.4.gem deleted file mode 100644 index 2ae5ba6b6..000000000 Binary files a/stager/vendor/cache/webmock-1.6.4.gem and /dev/null differ diff --git a/stager/vendor/cache/webmock-1.7.4.gem b/stager/vendor/cache/webmock-1.7.4.gem new file mode 100644 index 000000000..877bfcd94 Binary files /dev/null and b/stager/vendor/cache/webmock-1.7.4.gem differ diff --git a/stager/vendor/cache/yajl-ruby-0.8.2.gem b/stager/vendor/cache/yajl-ruby-0.8.2.gem deleted file mode 100644 index f423463a3..000000000 Binary files a/stager/vendor/cache/yajl-ruby-0.8.2.gem and /dev/null differ diff --git a/stager/vendor/cache/yajl-ruby-0.8.3.gem b/stager/vendor/cache/yajl-ruby-0.8.3.gem new file mode 100644 index 000000000..e3bdf3ebc Binary files /dev/null and b/stager/vendor/cache/yajl-ruby-0.8.3.gem differ diff --git a/staging/Gemfile b/staging/Gemfile index 2435e7da0..a1b93f3ed 100644 --- a/staging/Gemfile +++ b/staging/Gemfile @@ -1,11 +1,3 @@ source :rubygems -gem 'nokogiri', '>= 1.4.4' -gem 'rake' -gem 'yajl-ruby', '>= 0.7.9' - -gem 'vcap_common' - -group :test do - gem 'rspec' -end +gemspec diff --git a/staging/Gemfile.lock b/staging/Gemfile.lock index 49fded7de..ab04d84bb 100644 --- a/staging/Gemfile.lock +++ b/staging/Gemfile.lock @@ -1,3 +1,13 @@ +PATH + remote: . + specs: + vcap_staging (0.1.2) + nokogiri (>= 1.4.4) + rake + rspec + vcap_common + yajl-ruby (>= 0.7.9) + GEM remote: http://rubygems.org/ specs: @@ -6,7 +16,7 @@ GEM eventmachine (0.12.10) json_pure (1.5.3) little-plugger (1.1.2) - logging (1.5.2) + logging (1.6.0) little-plugger (>= 1.1.2) nats (0.4.10) daemons (>= 1.1.0) @@ -35,14 +45,10 @@ GEM posix-spawn thin yajl-ruby - yajl-ruby (0.8.2) + yajl-ruby (0.8.3) PLATFORMS ruby DEPENDENCIES - nokogiri (>= 1.4.4) - rake - rspec - vcap_common - yajl-ruby (>= 0.7.9) + vcap_staging! diff --git a/staging/Rakefile b/staging/Rakefile index 40315af3d..b7004e62a 100644 --- a/staging/Rakefile +++ b/staging/Rakefile @@ -1,6 +1,4 @@ -require 'rubygems' require 'rake' -require 'rake/gempackagetask' $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'lib')) require 'vcap/staging/version' @@ -8,27 +6,8 @@ require 'vcap/staging/version' GEM_NAME = 'vcap_staging' GEM_VERSION = VCAP::Staging::VERSION -gemspec = Gem::Specification.new do |s| - s.name = GEM_NAME - s.version = GEM_VERSION - s.platform = Gem::Platform::RUBY - s.summary = 'Plugins responsible for creating executable droplets' - s.description = s.summary - s.authors = [] - s.email = '' - s.homepage = 'http://www.cloudfoundry.com' - s.executables = [] - s.bindir = 'bin' - s.require_path = 'lib' - s.files = %w(Rakefile) + Dir.glob("{lib,spec,vendor}/**/*") -end - -Rake::GemPackageTask.new(gemspec) do |pkg| - pkg.gem_spec = gemspec -end - -task :install => [:package] do - sh "gem install --no-ri --no-rdoc pkg/#{GEM_NAME}-#{GEM_VERSION}" +task :build do + sh "gem build vcap_staging.gemspec" end task :spec => ['bundler:install:test'] do diff --git a/staging/lib/vcap/staging/plugin/config.rb b/staging/lib/vcap/staging/plugin/config.rb index e4738aea3..175647848 100644 --- a/staging/lib/vcap/staging/plugin/config.rb +++ b/staging/lib/vcap/staging/plugin/config.rb @@ -26,4 +26,14 @@ class StagingPlugin::Config < VCAP::Config }, } end + + def self.from_file(*args) + config = super(*args) + + # Support code expects symbolized keys for service information + config[:environment][:services] = config[:environment][:services].map {|svc| VCAP.symbolize_keys(svc) } if config[:environment] + + + config + end end diff --git a/staging/lib/vcap/staging/plugin/node/stage b/staging/lib/vcap/staging/plugin/node/stage deleted file mode 100644 index ebf599951..000000000 --- a/staging/lib/vcap/staging/plugin/node/stage +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env ruby -require File.expand_path('../../common', __FILE__) -plugin_class = StagingPlugin.load_plugin_for('node') -plugin_class.validate_arguments! -plugin_class.new(*ARGV).stage_application diff --git a/staging/lib/vcap/staging/plugin/php/stage b/staging/lib/vcap/staging/plugin/php/stage deleted file mode 100644 index 35689245b..000000000 --- a/staging/lib/vcap/staging/plugin/php/stage +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env ruby -require File.expand_path('../../common', __FILE__) -plugin_class = StagingPlugin.load_plugin_for('php') -plugin_class.validate_arguments! -plugin_class.new(*ARGV).stage_application - -# vim: ts=2 sw=2 filetype=ruby diff --git a/staging/lib/vcap/staging/plugin/wsgi/stage b/staging/lib/vcap/staging/plugin/wsgi/stage deleted file mode 100644 index 8c353b8fa..000000000 --- a/staging/lib/vcap/staging/plugin/wsgi/stage +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env ruby -require File.expand_path('../../common', __FILE__) -plugin_class = StagingPlugin.load_plugin_for('wsgi') -plugin_class.validate_arguments! -plugin_class.new(*ARGV).stage_application diff --git a/staging/lib/vcap/staging/version.rb b/staging/lib/vcap/staging/version.rb index 994f27a97..d4d788cc8 100644 --- a/staging/lib/vcap/staging/version.rb +++ b/staging/lib/vcap/staging/version.rb @@ -1,5 +1,5 @@ module VCAP module Staging - VERSION = '0.1.0' + VERSION = '0.1.2' end end diff --git a/staging/spec/unit/config_spec.rb b/staging/spec/unit/config_spec.rb new file mode 100644 index 000000000..73728c9a7 --- /dev/null +++ b/staging/spec/unit/config_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe StagingPlugin::Config do + describe '#from_file' do + it 'should symbolize keys for service bindings' do + tf = Tempfile.new('test_config') + svc = { + :label => 'hello', + :tags => ['tag1', 'tag2'], + :name => 'my_test_svc', + :credentials => { + :hostname => 'localhost', + :port => 12345, + :password => 'sekret', + :name => 'test', + }, + :options => {}, + :plan => 'free', + :plan_option => 'zazzle', + } + + config = { + 'source_dir' => 'test', + 'dest_dir' => 'test', + 'environment' => { + 'framework' => 'sinatra', + 'runtime' => 'ruby', + 'resources' => { + 'memory' => 128, + 'disk' => 2048, + 'fds' => 1024, + }, + 'services' => [svc], + } + } + + StagingPlugin::Config.to_file(config, tf.path) + parsed_cfg = StagingPlugin::Config.from_file(tf.path) + parsed_cfg[:environment][:services][0].should == svc + end + end +end diff --git a/staging/vcap_staging-0.1.2.gem b/staging/vcap_staging-0.1.2.gem new file mode 100644 index 000000000..aa1f8821c Binary files /dev/null and b/staging/vcap_staging-0.1.2.gem differ diff --git a/staging/vcap_staging.gemspec b/staging/vcap_staging.gemspec new file mode 100644 index 000000000..76841807e --- /dev/null +++ b/staging/vcap_staging.gemspec @@ -0,0 +1,27 @@ +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'lib')) +require 'vcap/staging/version' + +gemspec = Gem::Specification.new do |s| + s.name = 'vcap_staging' + s.version = VCAP::Staging::VERSION + s.platform = Gem::Platform::RUBY + s.summary = 'Plugins responsible for creating executable droplets' + s.description = s.summary + s.authors = [] + s.email = '' + s.homepage = 'http://www.cloudfoundry.com' + + s.add_dependency('nokogiri', '>= 1.4.4') + s.add_dependency('rake') + s.add_dependency('yajl-ruby', '>= 0.7.9') + + s.add_dependency('rspec') + + s.add_dependency('vcap_common') + + s.executables = [] + s.bindir = 'bin' + s.require_path = 'lib' + + s.files = %w(Rakefile) + Dir.glob("{lib,spec,vendor}/**/*") +end diff --git a/staging/vendor/cache/logging-1.5.2.gem b/staging/vendor/cache/logging-1.5.2.gem deleted file mode 100644 index 555648b70..000000000 Binary files a/staging/vendor/cache/logging-1.5.2.gem and /dev/null differ diff --git a/staging/vendor/cache/logging-1.6.0.gem b/staging/vendor/cache/logging-1.6.0.gem new file mode 100644 index 000000000..900ed3077 Binary files /dev/null and b/staging/vendor/cache/logging-1.6.0.gem differ diff --git a/staging/vendor/cache/yajl-ruby-0.8.2.gem b/staging/vendor/cache/yajl-ruby-0.8.2.gem deleted file mode 100644 index f423463a3..000000000 Binary files a/staging/vendor/cache/yajl-ruby-0.8.2.gem and /dev/null differ diff --git a/staging/vendor/cache/yajl-ruby-0.8.3.gem b/staging/vendor/cache/yajl-ruby-0.8.3.gem new file mode 100644 index 000000000..e3bdf3ebc Binary files /dev/null and b/staging/vendor/cache/yajl-ruby-0.8.3.gem differ