diff --git a/app/actions/deployment_continue.rb b/app/actions/deployment_continue.rb new file mode 100644 index 00000000000..1978103d16f --- /dev/null +++ b/app/actions/deployment_continue.rb @@ -0,0 +1,42 @@ +module VCAP::CloudController + class DeploymentContinue + class Error < StandardError + end + class InvalidStatus < Error + end + + class << self + def continue(deployment:, user_audit_info:) + deployment.db.transaction do + deployment.lock! + reject_invalid_state!(deployment) unless deployment.continuable? + + record_audit_event(deployment, user_audit_info) + deployment.update( + state: DeploymentModel::DEPLOYING_STATE, + status_value: DeploymentModel::ACTIVE_STATUS_VALUE, + status_reason: DeploymentModel::DEPLOYING_STATUS_REASON + ) + end + end + + private + + def reject_invalid_state!(deployment) + raise InvalidStatus.new("Cannot continue a deployment with status: #{deployment.status_value} and reason: #{deployment.status_reason}") + end + + def record_audit_event(deployment, user_audit_info) + app = deployment.app + Repositories::DeploymentEventRepository.record_continue( + deployment, + deployment.droplet, + user_audit_info, + app.name, + app.space_guid, + app.space.organization_guid + ) + end + end + end +end diff --git a/app/actions/deployment_create.rb b/app/actions/deployment_create.rb index 31464860aa4..8b440c211c3 100644 --- a/app/actions/deployment_create.rb +++ b/app/actions/deployment_create.rb @@ -13,6 +13,8 @@ def create(app:, user_audit_info:, message:) DeploymentModel.db.transaction do app.lock! + message.strategy ||= DeploymentModel::ROLLING_STRATEGY + target_state = DeploymentTargetState.new(app, message) previous_droplet = app.droplet @@ -20,7 +22,7 @@ def create(app:, user_audit_info:, message:) if target_state.rollback_target_revision revision = RevisionResolver.rollback_app_revision(app, target_state.rollback_target_revision, user_audit_info) - log_rollback_event(app.guid, user_audit_info.user_guid, target_state.rollback_target_revision.guid) + log_rollback_event(app.guid, user_audit_info.user_guid, target_state.rollback_target_revision.guid, message.strategy) else revision = RevisionResolver.update_app_revision(app, user_audit_info) end @@ -41,7 +43,7 @@ def create(app:, user_audit_info:, message:) deployment = DeploymentModel.create( app: app, - state: DeploymentModel::DEPLOYING_STATE, + state: starting_state(message), status_value: DeploymentModel::ACTIVE_STATUS_VALUE, status_reason: DeploymentModel::DEPLOYING_STATUS_REASON, droplet: target_state.droplet, @@ -49,7 +51,7 @@ def create(app:, user_audit_info:, message:) original_web_process_instance_count: desired_instances(app.oldest_web_process, previous_deployment), revision_guid: revision&.guid, revision_version: revision&.version, - strategy: DeploymentModel::ROLLING_STRATEGY + strategy: message.strategy ) MetadataUpdate.update(deployment, message) @@ -201,7 +203,15 @@ def supersede_deployment(previous_deployment) ) end - def log_rollback_event(app_guid, user_id, revision_id) + def starting_state(message) + if message.strategy == DeploymentModel::CANARY_STRATEGY + DeploymentModel::PREPAUSED_STATE + else + DeploymentModel::DEPLOYING_STATE + end + end + + def log_rollback_event(app_guid, user_id, revision_id, strategy) TelemetryLogger.v3_emit( 'rolled-back-app', { @@ -209,7 +219,7 @@ def log_rollback_event(app_guid, user_id, revision_id) 'user-id' => user_id, 'revision-id' => revision_id }, - { 'strategy' => 'rolling' } + { 'strategy' => strategy } ) end end diff --git a/app/controllers/v3/deployments_controller.rb b/app/controllers/v3/deployments_controller.rb index f52cb963521..327659e7bcf 100644 --- a/app/controllers/v3/deployments_controller.rb +++ b/app/controllers/v3/deployments_controller.rb @@ -6,6 +6,7 @@ require 'actions/deployment_create' require 'actions/deployment_update' require 'actions/deployment_cancel' +require 'actions/deployment_continue' require 'cloud_controller/telemetry_logger' class DeploymentsController < ApplicationController @@ -57,7 +58,7 @@ def create 'app-id' => app.guid, 'user-id' => current_user.guid }, - { 'strategy' => 'rolling' } + { 'strategy' => deployment.strategy } ) rescue DeploymentCreate::Error => e unprocessable!(e.message) @@ -97,6 +98,22 @@ def cancel head :ok end + def continue + deployment = DeploymentModel.find(guid: hashed_params[:guid]) + + resource_not_found!(:deployment) unless deployment && permission_queryer.can_manage_apps_in_active_space?(deployment.app.space.id) && + permission_queryer.is_space_active?(deployment.app.space.id) + + begin + DeploymentContinue.continue(deployment:, user_audit_info:) + logger.info("Continued deployment #{deployment.guid} for app #{deployment.app_guid}") + rescue DeploymentContinue::Error => e + unprocessable!(e.message) + end + + head :ok + end + private def deployments_not_enabled! diff --git a/app/jobs/runtime/prune_completed_deployments.rb b/app/jobs/runtime/prune_completed_deployments.rb index cec4cc21252..69a98cbd3b6 100644 --- a/app/jobs/runtime/prune_completed_deployments.rb +++ b/app/jobs/runtime/prune_completed_deployments.rb @@ -25,7 +25,7 @@ def perform select(:id) deployments_to_delete = deployments_dataset. - exclude(state: [DeploymentModel::DEPLOYING_STATE, DeploymentModel::CANCELING_STATE]). + exclude(state: DeploymentModel::ACTIVE_STATES). exclude(id: deployments_to_keep) delete_count = DeploymentDelete.delete(deployments_to_delete) diff --git a/app/messages/deployment_create_message.rb b/app/messages/deployment_create_message.rb index 2021a5334ae..c2ddd137461 100644 --- a/app/messages/deployment_create_message.rb +++ b/app/messages/deployment_create_message.rb @@ -11,7 +11,7 @@ class DeploymentCreateMessage < MetadataBaseMessage validates_with NoAdditionalKeysValidator validates :strategy, - inclusion: { in: %w[rolling], message: "'%s' is not a supported deployment strategy" }, + inclusion: { in: %w[rolling canary], message: "'%s' is not a supported deployment strategy" }, allow_nil: true validate :mutually_exclusive_droplet_sources diff --git a/app/models/runtime/app_model.rb b/app/models/runtime/app_model.rb index 73f498951fb..00fcdd11188 100644 --- a/app/models/runtime/app_model.rb +++ b/app/models/runtime/app_model.rb @@ -132,7 +132,7 @@ def stopped? end def deploying? - deployments_dataset.where(state: DeploymentModel::DEPLOYING_STATE).any? + deployments_dataset.where(state: DeploymentModel::PROGRESSING_STATES).any? end def self.user_visibility_filter(user) diff --git a/app/models/runtime/deployment_model.rb b/app/models/runtime/deployment_model.rb index 4dbc7372eaa..ac1d2af5415 100644 --- a/app/models/runtime/deployment_model.rb +++ b/app/models/runtime/deployment_model.rb @@ -2,6 +2,8 @@ module VCAP::CloudController class DeploymentModel < Sequel::Model(:deployments) DEPLOYMENT_STATES = [ DEPLOYING_STATE = 'DEPLOYING'.freeze, + PREPAUSED_STATE = 'PREPAUSED'.freeze, + PAUSED_STATE = 'PAUSED'.freeze, DEPLOYED_STATE = 'DEPLOYED'.freeze, CANCELING_STATE = 'CANCELING'.freeze, CANCELED_STATE = 'CANCELED'.freeze @@ -13,15 +15,28 @@ class DeploymentModel < Sequel::Model(:deployments) ].freeze STATUS_REASONS = [ - DEPLOYED_STATUS_REASON = 'DEPLOYED'.freeze, DEPLOYING_STATUS_REASON = 'DEPLOYING'.freeze, + PAUSED_STATUS_REASON = 'PAUSED'.freeze, + DEPLOYED_STATUS_REASON = 'DEPLOYED'.freeze, CANCELED_STATUS_REASON = 'CANCELED'.freeze, CANCELING_STATUS_REASON = 'CANCELING'.freeze, SUPERSEDED_STATUS_REASON = 'SUPERSEDED'.freeze ].freeze DEPLOYMENT_STRATEGIES = [ - ROLLING_STRATEGY = 'rolling'.freeze + ROLLING_STRATEGY = 'rolling'.freeze, + CANARY_STRATEGY = 'canary'.freeze + ].freeze + + PROGRESSING_STATES = [ + DEPLOYING_STATE, + PREPAUSED_STATE, + PAUSED_STATE + ].freeze + + ACTIVE_STATES = [ + *PROGRESSING_STATES, + CANCELING_STATE ].freeze many_to_one :app, @@ -63,7 +78,7 @@ class DeploymentModel < Sequel::Model(:deployments) dataset_module do def deploying_count - where(state: DeploymentModel::DEPLOYING_STATE).count + where(state: DeploymentModel::PROGRESSING_STATES).count end end @@ -73,13 +88,15 @@ def before_update end def deploying? - state == DEPLOYING_STATE + DeploymentModel::PROGRESSING_STATES.include?(state) end def cancelable? - valid_states_for_cancel = [DeploymentModel::DEPLOYING_STATE, - DeploymentModel::CANCELING_STATE] - valid_states_for_cancel.include?(state) + DeploymentModel::ACTIVE_STATES.include?(state) + end + + def continuable? + state == DeploymentModel::PAUSED_STATE end private diff --git a/app/presenters/v3/deployment_presenter.rb b/app/presenters/v3/deployment_presenter.rb index 53247e928e7..12364d1b0d6 100644 --- a/app/presenters/v3/deployment_presenter.rb +++ b/app/presenters/v3/deployment_presenter.rb @@ -76,6 +76,13 @@ def build_links method: 'POST' } end + end.tap do |links| + if deployment.continuable? + links[:continue] = { + href: url_builder.build_url(path: "/v3/deployments/#{deployment.guid}/actions/continue"), + method: 'POST' + } + end end end end diff --git a/app/repositories/deployment_event_repository.rb b/app/repositories/deployment_event_repository.rb index eaf229f4e72..8a4097c3840 100644 --- a/app/repositories/deployment_event_repository.rb +++ b/app/repositories/deployment_event_repository.rb @@ -11,7 +11,8 @@ def self.record_create(deployment, droplet, user_audit_info, v3_app_name, space_ droplet_guid: droplet&.guid, type: type, revision_guid: deployment.revision_guid, - request: params + request: params, + strategy: deployment.strategy } Event.create( @@ -53,6 +54,30 @@ def self.record_cancel(deployment, droplet, user_audit_info, v3_app_name, space_ organization_guid: org_guid ) end + + def self.record_continue(deployment, droplet, user_audit_info, v3_app_name, space_guid, org_guid) + VCAP::AppLogEmitter.emit(deployment.app_guid, "Continuing deployment for app with guid #{deployment.app_guid}") + + metadata = { + deployment_guid: deployment.guid, + droplet_guid: droplet&.guid + } + + Event.create( + type: EventTypes::APP_DEPLOYMENT_CONTINUE, + actor: user_audit_info.user_guid, + actor_type: 'user', + actor_name: user_audit_info.user_email, + actor_username: user_audit_info.user_name, + actee: deployment.app_guid, + actee_type: 'app', + actee_name: v3_app_name, + timestamp: Sequel::CURRENT_TIMESTAMP, + metadata: metadata, + space_guid: space_guid, + organization_guid: org_guid + ) + end end end end diff --git a/app/repositories/event_types.rb b/app/repositories/event_types.rb index 5b6d14c84b9..a0228f2e65c 100644 --- a/app/repositories/event_types.rb +++ b/app/repositories/event_types.rb @@ -47,6 +47,7 @@ class EventTypesError < StandardError APP_REVISION_ENV_VARS_SHOW = 'audit.app.revision.environment_variables.show'.freeze, APP_DEPLOYMENT_CANCEL = 'audit.app.deployment.cancel'.freeze, APP_DEPLOYMENT_CREATE = 'audit.app.deployment.create'.freeze, + APP_DEPLOYMENT_CONTINUE = 'audit.app.deployment.continue'.freeze, APP_COPY_BITS = 'audit.app.copy-bits'.freeze, APP_UPLOAD_BITS = 'audit.app.upload-bits'.freeze, APP_APPLY_MANIFEST = 'audit.app.apply_manifest'.freeze, diff --git a/config/routes.rb b/config/routes.rb index 68535640dda..5994216e73b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -87,6 +87,7 @@ get '/deployments/', to: 'deployments#index' get '/deployments/:guid', to: 'deployments#show' post '/deployments/:guid/actions/cancel', to: 'deployments#cancel' + post '/deployments/:guid/actions/continue', to: 'deployments#continue' # domains post '/domains', to: 'domains#create' diff --git a/docs/v3/source/includes/api_resources/_deployments.erb b/docs/v3/source/includes/api_resources/_deployments.erb index bd2f215d6b6..d185b35fc23 100644 --- a/docs/v3/source/includes/api_resources/_deployments.erb +++ b/docs/v3/source/includes/api_resources/_deployments.erb @@ -9,7 +9,7 @@ "last_successful_healthcheck": "2018-04-25T22:42:10Z" } }, - "strategy": "rolling", + "strategy": "canary", "droplet": { "guid": "44ccfa61-dbcf-4a0d-82fe-f668e9d2a962" }, @@ -50,6 +50,10 @@ "href": "https://api.example.org/v3/deployments/59c3d133-2b83-46f3-960e-7765a129aea4/actions/cancel", "method": "POST" } + "continue": { + "href": "https://api.example.org/v3/deployments/59c3d133-2b83-46f3-960e-7765a129aea4/actions/continue", + "method": "POST" + } } } <% end %> diff --git a/docs/v3/source/includes/resources/audit_events/_header.md.erb b/docs/v3/source/includes/resources/audit_events/_header.md.erb index 828422c84b0..a8919243425 100644 --- a/docs/v3/source/includes/resources/audit_events/_header.md.erb +++ b/docs/v3/source/includes/resources/audit_events/_header.md.erb @@ -14,6 +14,7 @@ For more information, see the [Cloud Foundry docs](https://docs.cloudfoundry.org - `audit.app.delete-request` - `audit.app.deployment.cancel` - `audit.app.deployment.create` +- `audit.app.deployment.continue` - `audit.app.droplet.create` - `audit.app.droplet.delete` - `audit.app.droplet.download` diff --git a/docs/v3/source/includes/resources/deployments/_continue.md.erb b/docs/v3/source/includes/resources/deployments/_continue.md.erb new file mode 100644 index 00000000000..bb4c9c71187 --- /dev/null +++ b/docs/v3/source/includes/resources/deployments/_continue.md.erb @@ -0,0 +1,30 @@ +### Continue a deployment + +``` +Example Request +``` + +```shell +curl "https://api.example.org/v3/deployments/[guid]/actions/continue" \ + -X POST \ + -H "Authorization: bearer [token]" +``` + +``` +Example Response +``` + +```http +HTTP/1.1 200 OK + +``` + +#### Definition +`POST /v3/deployments/:guid/actions/continue` + +#### Permitted roles + | +--- | +Admin | +Space Developer | +Space Supporter | diff --git a/docs/v3/source/includes/resources/deployments/_header.md b/docs/v3/source/includes/resources/deployments/_header.md index d4fee35724b..149709e9a76 100644 --- a/docs/v3/source/includes/resources/deployments/_header.md +++ b/docs/v3/source/includes/resources/deployments/_header.md @@ -8,7 +8,11 @@ They can either: * Roll an app back to a specific [revision](#revisions) along with its associated droplet +Deployments are different than the traditional method of pushing app updates which performs start/stop deployments. -It is possible to use [rolling deployments](https://docs.cloudfoundry.org/devguide/deploy-apps/rolling-deploy.html) for -applications without incurring downtime. This is different from the traditional method of pushing app updates which performs start/stop deployments. +Deployment strategies supported: +* [Rolling deployments](https://docs.cloudfoundry.org/devguide/deploy-apps/rolling-deploy.html) allows for +applications to be deployed without incurring downtime by gradually rolling out instances. + +* Canary deployments deploy a single instance and pause for user evaluation. If the canary instance is deemed successful, the deployment can be resumed via the [continue action](#continue-a-deployment). The deployment then continues like a rolling deployment. This feature is experimental and is subject to change. diff --git a/docs/v3/source/includes/resources/deployments/_list.md.erb b/docs/v3/source/includes/resources/deployments/_list.md.erb index 58648a3f7bb..299ef16f615 100644 --- a/docs/v3/source/includes/resources/deployments/_list.md.erb +++ b/docs/v3/source/includes/resources/deployments/_list.md.erb @@ -32,7 +32,7 @@ Name | Type | Description ---- | ---- | ------------ **app_guids** | _list of strings_ | Comma-delimited list of app guids to filter by **states** | _list of strings_ | Comma-delimited list of states to filter by -**status_reasons** | _list of strings_ | Comma-delimited list of status reasons to filter by;
valid values include `DEPLOYING`, `CANCELING`, `DEPLOYED`, `CANCELED`, `SUPERSEDED` +**status_reasons** | _list of strings_ | Comma-delimited list of status reasons to filter by;
valid values include `DEPLOYING`, `PAUSED`, `CANCELING`, `DEPLOYED`, `CANCELED`, `SUPERSEDED` **status_values** | _list of strings_ | Comma-delimited list of status values to filter by;
valid values include `ACTIVE` and `FINALIZED` **page** | _integer_ | Page to display; valid values are integers >= 1 **per_page** | _integer_ | Number of results per page;
valid values are 1 through 5000 diff --git a/docs/v3/source/includes/resources/deployments/_object.md.erb b/docs/v3/source/includes/resources/deployments/_object.md.erb index ee30f5c2124..111f1a12422 100644 --- a/docs/v3/source/includes/resources/deployments/_object.md.erb +++ b/docs/v3/source/includes/resources/deployments/_object.md.erb @@ -13,10 +13,10 @@ Name | Type | Description **created_at** | _[timestamp](#timestamps)_ | The time with zone when the object was created **updated_at** | _[timestamp](#timestamps)_ | The time with zone when the object was last updated **status.value** | _string_ | The current status of the deployment; valid values are `ACTIVE` (meaning in progress) and `FINALIZED` (meaning finished, either successfully or not) -**status.reason** | _string_ | The reason for the status of the deployment;
following list represents valid values:
1. If **status.value** is `ACTIVE`
- `DEPLOYING`
- `CANCELING`
2. If **status.value** is `FINALIZED`
- `DEPLOYED`
- `CANCELED`
- `SUPERSEDED` (another deployment created for app before completion)
+**status.reason** | _string_ | The reason for the status of the deployment;
following list represents valid values:
1. If **status.value** is `ACTIVE`
- `DEPLOYING`
- `PAUSED` (only valid for canary deployments)
- `CANCELING`
2. If **status.value** is `FINALIZED`
- `DEPLOYED`
- `CANCELED`
- `SUPERSEDED` (another deployment created for app before completion)
**status.details.last_successful_healthcheck** | _[timestamp](#timestamps)_ | Timestamp of the last successful healthcheck **status.details.last_status_change** | _[timestamp](#timestamps)_ | Timestamp of last change to status.value or status.reason -**strategy** | _string_ | Strategy used for the deployment; supported strategies are `rolling` only +**strategy** | _string_ | Strategy used for the deployment; supported strategies are `rolling` and `canary` (experimental) **droplet.guid** | _string_ | The droplet guid that the deployment is transitioning the app to **previous_droplet.guid** | _string_ | The app's [current droplet guid](#get-current-droplet-association-for-an-app) before the deployment was created **new_processes** | _array_ | List of processes created as part of the deployment diff --git a/docs/v3/source/index.html.md b/docs/v3/source/index.html.md index 1217b4429ec..cc60bfea116 100644 --- a/docs/v3/source/index.html.md +++ b/docs/v3/source/index.html.md @@ -120,6 +120,7 @@ includes: - resources/deployments/list - resources/deployments/update - resources/deployments/cancel + - resources/deployments/continue - resources/domains/header - resources/domains/object - resources/domains/create diff --git a/lib/cloud_controller/deployment_updater/dispatcher.rb b/lib/cloud_controller/deployment_updater/dispatcher.rb index 71e357658e6..8fb59c96d63 100644 --- a/lib/cloud_controller/deployment_updater/dispatcher.rb +++ b/lib/cloud_controller/deployment_updater/dispatcher.rb @@ -11,6 +11,7 @@ def dispatch deployments_to_scale = DeploymentModel.where(state: DeploymentModel::DEPLOYING_STATE).all deployments_to_cancel = DeploymentModel.where(state: DeploymentModel::CANCELING_STATE).all + deployments_to_canary = DeploymentModel.where(state: DeploymentModel::PREPAUSED_STATE).all begin workpool = WorkPool.new(50) @@ -22,8 +23,14 @@ def dispatch end end - logger.info("canceling #{deployments_to_cancel.size} deployments") + logger.info("canarying #{deployments_to_canary.size} deployments") + deployments_to_canary.each do |deployment| + workpool.submit(deployment, logger) do |d, l| + Updater.new(d, l).canary + end + end + logger.info("canceling #{deployments_to_cancel.size} deployments") deployments_to_cancel.each do |deployment| workpool.submit(deployment, logger) do |d, l| Updater.new(d, l).cancel diff --git a/lib/cloud_controller/deployment_updater/updater.rb b/lib/cloud_controller/deployment_updater/updater.rb index 8fff5a05e42..76e6b8bac5e 100644 --- a/lib/cloud_controller/deployment_updater/updater.rb +++ b/lib/cloud_controller/deployment_updater/updater.rb @@ -15,6 +15,13 @@ def scale end end + def canary + with_error_logging('error-canarying-deployment') do + canary_deployment + logger.info("ran-canarying-deployment-for-#{deployment.guid}") + end + end + def cancel with_error_logging('error-canceling-deployment') do cancel_deployment @@ -59,6 +66,25 @@ def cancel_deployment end end + def canary_deployment + deployment.db.transaction do + deployment.lock! + return unless deployment.state == DeploymentModel::PREPAUSED_STATE + + scale_canceled_web_processes_to_zero + + if canary_ready? + deployment.update( + last_healthy_at: Time.now, + state: DeploymentModel::PAUSED_STATE, + status_value: DeploymentModel::ACTIVE_STATUS_VALUE, + status_reason: DeploymentModel::PAUSED_STATUS_REASON + ) + logger.info("paused-canary-deployment-for-#{deployment.guid}") + end + end + end + def scale_deployment deployment.db.transaction do app.lock! @@ -186,6 +212,10 @@ def update_non_web_processes end end + def canary_ready? + ready_to_scale? + end + def ready_to_scale? instances = instance_reporters.all_instances_for_app(deployment.deploying_web_process) instances.all? { |_, val| val[:state] == VCAP::CloudController::Diego::LRP_RUNNING && val[:routable] } diff --git a/spec/request/deployments_spec.rb b/spec/request/deployments_spec.rb index b9df2511eaa..97fada893d4 100644 --- a/spec/request/deployments_spec.rb +++ b/spec/request/deployments_spec.rb @@ -561,6 +561,7 @@ let(:create_request) do { + strategy: 'canary', relationships: { app: { data: { @@ -592,7 +593,7 @@ 'telemetry-time' => Time.now.to_datetime.rfc3339, 'create-deployment' => { 'api-version' => 'v3', - 'strategy' => 'rolling', + 'strategy' => 'canary', 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) } @@ -612,7 +613,7 @@ 'telemetry-time' => Time.now.to_datetime.rfc3339, 'rolled-back-app' => { 'api-version' => 'v3', - 'strategy' => 'rolling', + 'strategy' => 'canary', 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid), 'revision-id' => OpenSSL::Digest::SHA256.hexdigest(revision.guid) @@ -773,6 +774,67 @@ end end + context 'when strategy "canary" is provided' do + let(:strategy) { 'canary' } + let(:user) { make_developer_for_space(space) } + + it 'creates a deployment with strategy "canary" when "strategy":"canary" is provided' do + post '/v3/deployments', create_request.to_json, user_header + expect(last_response.status).to eq(201), last_response.body + + deployment = VCAP::CloudController::DeploymentModel.last + + expect(parsed_response).to be_a_response_like({ + 'guid' => deployment.guid, + 'status' => { + 'value' => VCAP::CloudController::DeploymentModel::ACTIVE_STATUS_VALUE, + 'reason' => VCAP::CloudController::DeploymentModel::DEPLOYING_STATUS_REASON, + 'details' => { + 'last_successful_healthcheck' => iso8601, + 'last_status_change' => iso8601 + } + }, + 'strategy' => 'canary', + 'droplet' => { + 'guid' => droplet.guid + }, + 'revision' => { + 'guid' => app_model.latest_revision.guid, + 'version' => app_model.latest_revision.version + }, + 'previous_droplet' => { + 'guid' => droplet.guid + }, + 'new_processes' => [{ + 'guid' => deployment.deploying_web_process.guid, + 'type' => deployment.deploying_web_process.type + }], + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => metadata, + 'relationships' => { + 'app' => { + 'data' => { + 'guid' => app_model.guid + } + } + }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/deployments/#{deployment.guid}" + }, + 'app' => { + 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" + }, + 'cancel' => { + 'href' => "#{link_prefix}/v3/deployments/#{deployment.guid}/actions/cancel", + 'method' => 'POST' + } + } + }) + end + end + context 'when a strategy other than "rolling" is provided' do let(:strategy) { 'potato' } @@ -984,6 +1046,39 @@ h end + context 'PAUSED deployment' do + let(:user) { make_developer_for_space(space) } + let(:deployment) do + VCAP::CloudController::DeploymentModelTestFactory.make( + app: app_model, + droplet: droplet, + previous_droplet: old_droplet, + strategy: 'canary', + state: VCAP::CloudController::DeploymentModel::PAUSED_STATE, + status_value: VCAP::CloudController::DeploymentModel::ACTIVE_STATUS_VALUE, + status_reason: VCAP::CloudController::DeploymentModel::PAUSED_STATUS_REASON + ) + end + + it 'includes the continue action in the links' do + get "/v3/deployments/#{deployment.guid}", nil, user_header + parsed_response = Oj.load(last_response.body) + expect(parsed_response['links']['continue']).to eq({ + 'href' => "#{link_prefix}/v3/deployments/#{deployment.guid}/actions/continue", + 'method' => 'POST' + }) + end + + it 'includes the cancel action in the links' do + get "/v3/deployments/#{deployment.guid}", nil, user_header + parsed_response = Oj.load(last_response.body) + expect(parsed_response['links']['cancel']).to eq({ + 'href' => "#{link_prefix}/v3/deployments/#{deployment.guid}/actions/cancel", + 'method' => 'POST' + }) + end + end + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS end @@ -1054,6 +1149,15 @@ status_reason: VCAP::CloudController::DeploymentModel::SUPERSEDED_STATUS_REASON) end + let!(:deployment6) do + VCAP::CloudController::DeploymentModelTestFactory.make(app: app5, droplet: droplet5, + previous_droplet: droplet5, + strategy: 'canary', + status_value: VCAP::CloudController::DeploymentModel::ACTIVE_STATUS_VALUE, + state: VCAP::CloudController::DeploymentModel::DEPLOYING_STATE, + status_reason: VCAP::CloudController::DeploymentModel::DEPLOYING_STATUS_REASON) + end + def json_for_deployment(deployment, app_model, droplet, status_value, status_reason, cancel_link=true) { guid: deployment.guid, @@ -1065,7 +1169,7 @@ def json_for_deployment(deployment, app_model, droplet, status_value, status_rea last_status_change: iso8601 } }, - strategy: 'rolling', + strategy: deployment.strategy, droplet: { guid: droplet.guid }, @@ -1116,7 +1220,7 @@ def json_for_deployment(deployment, app_model, droplet, status_value, status_rea parsed_response = Oj.load(last_response.body) expect(parsed_response).to match_json_response({ pagination: { - total_results: 5, + total_results: 6, total_pages: 3, first: { href: "#{link_prefix}/v3/deployments?page=1&per_page=2" @@ -1248,6 +1352,9 @@ def json_for_deployment(deployment, app_model, droplet, status_value, status_rea code: 200, response_objects: [ json_for_deployment(deployment, app_model, droplet, + VCAP::CloudController::DeploymentModel::ACTIVE_STATUS_VALUE, + VCAP::CloudController::DeploymentModel::DEPLOYING_STATUS_REASON), + json_for_deployment(deployment6, app5, droplet5, VCAP::CloudController::DeploymentModel::ACTIVE_STATUS_VALUE, VCAP::CloudController::DeploymentModel::DEPLOYING_STATUS_REASON) ] @@ -1264,7 +1371,7 @@ def json_for_deployment(deployment, app_model, droplet, status_value, status_rea context 'pagination' do let(:pagination_hsh) do { - total_results: 1, + total_results: 2, total_pages: 1, first: { href: "#{link_prefix}#{url}?page=1&per_page=50&#{query.gsub(',', '%2C')}" }, last: { href: "#{link_prefix}#{url}?page=1&per_page=50&#{query.gsub(',', '%2C')}" }, @@ -1466,4 +1573,106 @@ def json_for_deployment(deployment, app_model, droplet, status_value, status_rea end end end + + describe 'POST /v3/deployments/:guid/actions/continue' do + let(:state) {} + let(:deployment) do + VCAP::CloudController::DeploymentModelTestFactory.make( + app: app_model, + droplet: droplet, + state: state + ) + end + + context 'when the deployment is in paused state' do + let(:user) { make_developer_for_space(space) } + let(:state) { VCAP::CloudController::DeploymentModel::PAUSED_STATE } + + it 'transitions the deployment from paused to deploying' do + post "/v3/deployments/#{deployment.guid}/actions/continue", {}.to_json, user_header + expect(last_response.status).to eq(200), last_response.body + expect(last_response.body).to be_empty + + deployment.reload + expect(deployment.state).to eq(VCAP::CloudController::DeploymentModel::DEPLOYING_STATE) + expect(deployment.status_reason).to eq(VCAP::CloudController::DeploymentModel::DEPLOYING_STATUS_REASON) + end + end + + context 'when the deployment is in a prepaused state' do + let(:user) { make_developer_for_space(space) } + let(:state) { VCAP::CloudController::DeploymentModel::PREPAUSED_STATE } + + it 'returns 422 with an error' do + post "/v3/deployments/#{deployment.guid}/actions/continue", {}.to_json, user_header + expect(last_response.status).to eq(422), last_response.body + end + end + + context 'when the deployment is in a deploying state' do + let(:user) { make_developer_for_space(space) } + let(:state) { VCAP::CloudController::DeploymentModel::DEPLOYING_STATE } + + it 'returns 422 with an error' do + post "/v3/deployments/#{deployment.guid}/actions/continue", {}.to_json, user_header + expect(last_response.status).to eq(422), last_response.body + end + end + + context 'when the deployment is in a canceling state' do + let(:user) { make_developer_for_space(space) } + let(:state) { VCAP::CloudController::DeploymentModel::CANCELING_STATE } + + it 'returns 422 with an error' do + post "/v3/deployments/#{deployment.guid}/actions/continue", {}.to_json, user_header + expect(last_response.status).to eq(422), last_response.body + end + end + + context 'when the deployment is in a deployed state' do + let(:user) { make_developer_for_space(space) } + let(:state) { VCAP::CloudController::DeploymentModel::DEPLOYED_STATE } + + it 'returns 422 with an error' do + post "/v3/deployments/#{deployment.guid}/actions/continue", {}.to_json, user_header + expect(last_response.status).to eq(422), last_response.body + end + end + + context 'when the deployment is in a canceled state' do + let(:user) { make_developer_for_space(space) } + let(:state) { VCAP::CloudController::DeploymentModel::CANCELED_STATE } + + it 'returns 422 with an error' do + post "/v3/deployments/#{deployment.guid}/actions/continue", {}.to_json, user_header + expect(last_response.status).to eq(422), last_response.body + end + end + + context 'with a running deployment' do + let(:state) { VCAP::CloudController::DeploymentModel::PAUSED_STATE } + let(:api_call) { ->(user_headers) { post "/v3/deployments/#{deployment.guid}/actions/continue", {}.to_json, user_headers } } + let(:expected_codes_and_responses) do + h = Hash.new(code: 404) + h['admin'] = h['space_developer'] = h['space_supporter'] = { code: 200 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 404 } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + end end diff --git a/spec/support/fakes/blueprints.rb b/spec/support/fakes/blueprints.rb index b3bc64e6349..f70ee3574de 100644 --- a/spec/support/fakes/blueprints.rb +++ b/spec/support/fakes/blueprints.rb @@ -195,6 +195,7 @@ module VCAP::CloudController droplet { DropletModel.make(app:) } deploying_web_process { ProcessModel.make(app: app, type: "web-deployment-#{Sham.guid}") } original_web_process_instance_count { 1 } + strategy { 'rolling' } end DeploymentProcessModel.blueprint do diff --git a/spec/unit/actions/deployment_continue_spec.rb b/spec/unit/actions/deployment_continue_spec.rb new file mode 100644 index 00000000000..5cab8cb07e0 --- /dev/null +++ b/spec/unit/actions/deployment_continue_spec.rb @@ -0,0 +1,115 @@ +require 'spec_helper' +require 'actions/deployment_continue' +require 'cloud_controller/deployment_updater/dispatcher' + +module VCAP::CloudController + RSpec.describe DeploymentContinue do + let(:space) { Space.make } + let(:instance_count) { 6 } + let(:app) { AppModel.make } + let(:droplet) { DropletModel.make(app: app, process_types: { 'web' => 'the net' }) } + let(:original_web_process) { ProcessModelFactory.make(space: space, instances: 1, state: 'STARTED', app: app) } + let(:deploying_web_process) { ProcessModelFactory.make(space: space, instances: instance_count, state: 'STARTED', app: app, type: 'web-deployment-deployment-guid') } + let(:status_reason) { nil } + let!(:deployment) do + VCAP::CloudController::DeploymentModel.make( + state: state, + status_value: status_value, + status_reason: status_reason, + droplet: droplet, + app: original_web_process.app, + deploying_web_process: deploying_web_process + ) + end + + let(:user_audit_info) { UserAuditInfo.new(user_guid: '1234', user_email: 'eric@example.com', user_name: 'eric') } + + describe '.continue' do + context 'when the deployment is in the PAUSED state' do + let(:state) { DeploymentModel::PAUSED_STATE } + let(:status_value) { DeploymentModel::ACTIVE_STATUS_VALUE } + let(:status_reason) { DeploymentModel::DEPLOYING_STATUS_REASON } + + it 'sets the deployments status to DEPLOYING' do + expect(deployment.state).not_to eq(DeploymentModel::DEPLOYING_STATE) + + DeploymentContinue.continue(deployment:, user_audit_info:) + deployment.reload + + expect(deployment.state).to eq(DeploymentModel::DEPLOYING_STATE) + expect(deployment.status_value).to eq(DeploymentModel::ACTIVE_STATUS_VALUE) + expect(deployment.status_reason).to eq(DeploymentModel::DEPLOYING_STATUS_REASON) + end + + it 'records an audit event for the continue deployment' do + DeploymentContinue.continue(deployment:, user_audit_info:) + + event = VCAP::CloudController::Event.find(type: 'audit.app.deployment.continue') + expect(event).not_to be_nil + expect(event.actor).to eq('1234') + expect(event.actor_type).to eq('user') + expect(event.actor_name).to eq('eric@example.com') + expect(event.actor_username).to eq('eric') + expect(event.actee).to eq(app.guid) + expect(event.actee_type).to eq('app') + expect(event.actee_name).to eq(app.name) + expect(event.timestamp).to be + expect(event.space_guid).to eq(app.space_guid) + expect(event.organization_guid).to eq(app.space.organization.guid) + expect(event.metadata).to eq({ + 'droplet_guid' => droplet.guid, + 'deployment_guid' => deployment.guid + }) + end + end + + context 'when the deployment is in the PREPAUSED state' do + let(:state) { DeploymentModel::PREPAUSED_STATE } + let(:status_value) { DeploymentModel::ACTIVE_STATUS_VALUE } + let(:status_reason) { DeploymentModel::DEPLOYING_STATUS_REASON } + + it 'raises an error' do + expect do + DeploymentContinue.continue(deployment:, user_audit_info:) + end.to raise_error(DeploymentContinue::InvalidStatus, 'Cannot continue a deployment with status: ACTIVE and reason: DEPLOYING') + end + end + + context 'when the deployment is in the DEPLOYING state' do + let(:state) { DeploymentModel::DEPLOYING_STATE } + let(:status_value) { DeploymentModel::ACTIVE_STATUS_VALUE } + let(:status_reason) { DeploymentModel::DEPLOYING_STATUS_REASON } + + it 'raises an error' do + expect do + DeploymentContinue.continue(deployment:, user_audit_info:) + end.to raise_error(DeploymentContinue::InvalidStatus, 'Cannot continue a deployment with status: ACTIVE and reason: DEPLOYING') + end + end + + context 'when the deployment is in the DEPLOYED state' do + let(:state) { DeploymentModel::DEPLOYED_STATE } + let(:status_value) { DeploymentModel::FINALIZED_STATUS_VALUE } + let(:status_reason) { DeploymentModel::DEPLOYED_STATUS_REASON } + + it 'raises an error' do + expect do + DeploymentContinue.continue(deployment:, user_audit_info:) + end.to raise_error(DeploymentContinue::InvalidStatus, 'Cannot continue a deployment with status: FINALIZED and reason: DEPLOYED') + end + end + + context 'when the deployment is in the CANCELED state' do + let(:state) { DeploymentModel::CANCELED_STATE } + let(:status_value) { DeploymentModel::FINALIZED_STATUS_VALUE } + let(:status_reason) { DeploymentModel::CANCELED_STATUS_REASON } + + it 'raises an error' do + expect do + DeploymentContinue.continue(deployment:, user_audit_info:) + end.to raise_error(DeploymentContinue::InvalidStatus, 'Cannot continue a deployment with status: FINALIZED and reason: CANCELED') + end + end + end + end +end diff --git a/spec/unit/actions/deployment_create_spec.rb b/spec/unit/actions/deployment_create_spec.rb index f23ad3d642c..1c8629200b3 100644 --- a/spec/unit/actions/deployment_create_spec.rb +++ b/spec/unit/actions/deployment_create_spec.rb @@ -15,10 +15,13 @@ module VCAP::CloudController let(:user_audit_info) { UserAuditInfo.new(user_guid: '123', user_email: 'connor@example.com', user_name: 'braa') } let(:runner) { instance_double(Diego::Runner) } + let(:strategy) { 'rolling' } + let(:message) do DeploymentCreateMessage.new({ relationships: { app: { data: { guid: app.guid } } }, - droplet: { guid: next_droplet.guid } + droplet: { guid: next_droplet.guid }, + strategy: strategy }) end @@ -148,7 +151,8 @@ module VCAP::CloudController 'deployment_guid' => deployment.guid, 'type' => nil, 'revision_guid' => RevisionModel.last.guid, - 'request' => message.audit_hash + 'request' => message.audit_hash, + 'strategy' => 'rolling' }) end @@ -315,7 +319,8 @@ module VCAP::CloudController 'deployment_guid' => deployment.guid, 'type' => nil, 'revision_guid' => app.latest_revision.guid, - 'request' => message.audit_hash + 'request' => message.audit_hash, + 'strategy' => 'rolling' }) end @@ -372,7 +377,8 @@ module VCAP::CloudController 'deployment_guid' => deployment.guid, 'type' => nil, 'revision_guid' => app_without_current_droplet.latest_revision.guid, - 'request' => message.audit_hash + 'request' => message.audit_hash, + 'strategy' => 'rolling' }) end end @@ -538,7 +544,8 @@ module VCAP::CloudController 'deployment_guid' => deployment.guid, 'type' => nil, 'revision_guid' => app.latest_revision.guid, - 'request' => message.audit_hash + 'request' => message.audit_hash, + 'strategy' => 'rolling' }) end @@ -708,7 +715,8 @@ module VCAP::CloudController 'deployment_guid' => deployment.guid, 'type' => nil, 'revision_guid' => app.latest_revision.guid, - 'request' => message.audit_hash + 'request' => message.audit_hash, + 'strategy' => 'rolling' }) end @@ -867,7 +875,8 @@ module VCAP::CloudController 'deployment_guid' => deployment.guid, 'type' => 'rollback', 'revision_guid' => RevisionModel.last.guid, - 'request' => message.audit_hash + 'request' => message.audit_hash, + 'strategy' => 'rolling' }) end @@ -967,7 +976,8 @@ module VCAP::CloudController 'deployment_guid' => deployment.guid, 'type' => 'rollback', 'revision_guid' => revision.guid, - 'request' => message.audit_hash + 'request' => message.audit_hash, + 'strategy' => 'rolling' }) end @@ -1001,6 +1011,67 @@ module VCAP::CloudController end end end + + context 'strategy' do + context 'when the strategy is rolling' do + let(:strategy) { 'rolling' } + + it 'creates the deployment with the rolling strategy' do + deployment = nil + + expect do + deployment = DeploymentCreate.create(app:, message:, user_audit_info:) + end.to change(DeploymentModel, :count).by(1) + + expect(deployment.strategy).to eq(DeploymentModel::ROLLING_STRATEGY) + end + end + + context 'when the strategy is canary' do + let(:strategy) { 'canary' } + + it 'creates the deployment with the canary strategy' do + deployment = nil + + expect do + deployment = DeploymentCreate.create(app:, message:, user_audit_info:) + end.to change(DeploymentModel, :count).by(1) + + expect(deployment.strategy).to eq(DeploymentModel::CANARY_STRATEGY) + end + + it 'creates a process with a single canary instance' do + DeploymentCreate.create(app:, message:, user_audit_info:) + + deploying_web_process = app.reload.newest_web_process + expect(deploying_web_process.instances).to eq(1) + end + + it 'sets the deployment state to PREPAUSED' do + deployment = nil + + expect do + deployment = DeploymentCreate.create(app:, message:, user_audit_info:) + end.to change(DeploymentModel, :count).by(1) + + expect(deployment.state).to eq(DeploymentModel::PREPAUSED_STATE) + end + end + + context 'when the strategy is nil' do + let(:strategy) { nil } + + it 'defaults to the rolling strategy' do + deployment = nil + + expect do + deployment = DeploymentCreate.create(app:, message:, user_audit_info:) + end.to change(DeploymentModel, :count).by(1) + + expect(deployment.strategy).to eq(DeploymentModel::ROLLING_STRATEGY) + end + end + end end end end diff --git a/spec/unit/jobs/runtime/prune_completed_deployments_spec.rb b/spec/unit/jobs/runtime/prune_completed_deployments_spec.rb index 4f8a689d632..dca9a1817c9 100644 --- a/spec/unit/jobs/runtime/prune_completed_deployments_spec.rb +++ b/spec/unit/jobs/runtime/prune_completed_deployments_spec.rb @@ -58,6 +58,34 @@ module Jobs::Runtime expect(DeploymentModel.map(&:id)).to match_array((1..50).to_a) end + it 'does NOT delete any prepaused deployments over the limit' do + expect(DeploymentModel.count).to eq(0) + + total = 50 + (1..50).each do |i| + DeploymentModel.make(id: i, state: DeploymentModel::PREPAUSED_STATE, app: app, created_at: Time.now - total + i) + end + + job.perform + + expect(DeploymentModel.count).to eq(50) + expect(DeploymentModel.map(&:id)).to match_array((1..50).to_a) + end + + it 'does NOT delete any paused deployments over the limit' do + expect(DeploymentModel.count).to eq(0) + + total = 50 + (1..50).each do |i| + DeploymentModel.make(id: i, state: DeploymentModel::PAUSED_STATE, app: app, created_at: Time.now - total + i) + end + + job.perform + + expect(DeploymentModel.count).to eq(50) + expect(DeploymentModel.map(&:id)).to match_array((1..50).to_a) + end + it 'does NOT delete any canceling deployments over the limit' do expect(DeploymentModel.count).to eq(0) diff --git a/spec/unit/lib/cloud_controller/deployment_updater/dispatcher_spec.rb b/spec/unit/lib/cloud_controller/deployment_updater/dispatcher_spec.rb index af38e20aee2..c6dcd2408e0 100644 --- a/spec/unit/lib/cloud_controller/deployment_updater/dispatcher_spec.rb +++ b/spec/unit/lib/cloud_controller/deployment_updater/dispatcher_spec.rb @@ -6,11 +6,12 @@ module VCAP::CloudController subject(:dispatcher) { DeploymentUpdater::Dispatcher } let(:scaling_deployment) { DeploymentModel.make(state: DeploymentModel::DEPLOYING_STATE) } + let(:prepaused_deployment) { DeploymentModel.make(state: DeploymentModel::PREPAUSED_STATE) } let(:canceling_deployment) { DeploymentModel.make(state: DeploymentModel::CANCELING_STATE) } let(:logger) { instance_double(Steno::Logger, info: nil, error: nil, warn: nil) } let(:workpool) { instance_double(WorkPool, submit: nil, drain: nil) } - let(:updater) { instance_double(DeploymentUpdater::Updater, scale: nil, cancel: nil) } + let(:updater) { instance_double(DeploymentUpdater::Updater, scale: nil, canary: nil, cancel: nil) } describe '.dispatch' do before do @@ -26,6 +27,7 @@ module VCAP::CloudController subject.dispatch expect(updater).not_to have_received(:scale) expect(updater).not_to have_received(:cancel) + expect(updater).not_to have_received(:canary) end end @@ -40,6 +42,17 @@ module VCAP::CloudController end end + context 'when a deployment is in pre-paused' do + before do + allow(DeploymentUpdater::Updater).to receive(:new).with(prepaused_deployment, logger).and_return(updater) + end + + it 'starts a canary deployment' do + subject.dispatch + expect(updater).to have_received(:canary) + end + end + context 'when a deployment is being canceled' do before do allow(DeploymentUpdater::Updater).to receive(:new).with(canceling_deployment, logger).and_return(updater) diff --git a/spec/unit/lib/cloud_controller/deployment_updater/updater_spec.rb b/spec/unit/lib/cloud_controller/deployment_updater/updater_spec.rb index d4794a34665..8e5ece1379f 100644 --- a/spec/unit/lib/cloud_controller/deployment_updater/updater_spec.rb +++ b/spec/unit/lib/cloud_controller/deployment_updater/updater_spec.rb @@ -34,11 +34,13 @@ module VCAP::CloudController let(:current_web_instances) { 2 } let(:current_deploying_instances) { 0 } + let(:state) { DeploymentModel::DEPLOYING_STATE } + let(:deployment) do DeploymentModel.make( app: web_process.app, deploying_web_process: deploying_web_process, - state: 'DEPLOYING', + state: state, original_web_process_instance_count: original_web_process_instance_count ) end @@ -506,6 +508,178 @@ module VCAP::CloudController end end + describe '#canary' do + let(:state) { DeploymentModel::PREPAUSED_STATE } + let(:current_deploying_instances) { 1 } + + it 'locks the deployment' do + allow(deployment).to receive(:lock!).and_call_original + subject.canary + expect(deployment).to have_received(:lock!) + end + + context 'when the canary instance starts succesfully' do + let(:all_instances_results) do + { + 0 => { state: 'RUNNING', uptime: 50, since: 2, routable: true } + } + end + + it 'pauses the deployment' do + subject.canary + expect(deployment.state).to eq(DeploymentModel::PAUSED_STATE) + expect(deployment.status_value).to eq(DeploymentModel::ACTIVE_STATUS_VALUE) + expect(deployment.status_reason).to eq(DeploymentModel::PAUSED_STATUS_REASON) + end + + it 'updates last_healthy_at' do + previous_last_healthy_at = deployment.last_healthy_at + Timecop.travel(deployment.last_healthy_at + 10.seconds) do + subject.canary + expect(deployment.reload.last_healthy_at).to be > previous_last_healthy_at + end + end + + it 'does not alter the existing web processes' do + expect do + subject.canary + end.not_to(change do + web_process.reload.instances + end) + end + + it 'logs the canary is paused' do + subject.canary + expect(logger).to have_received(:info).with( + "paused-canary-deployment-for-#{deployment.guid}" + ) + end + + it 'logs the canary run' do + subject.canary + expect(logger).to have_received(:info).with( + "ran-canarying-deployment-for-#{deployment.guid}" + ) + end + end + + context 'while the canary instance is still starting' do + let(:all_instances_results) do + { + 0 => { state: 'STARTING', uptime: 50, since: 2, routable: true } + } + end + + it 'skips the deployment update' do + subject.canary + expect(deployment.state).to eq(DeploymentModel::PREPAUSED_STATE) + expect(deployment.status_value).to eq(DeploymentModel::ACTIVE_STATUS_VALUE) + expect(deployment.status_reason).to eq(DeploymentModel::DEPLOYING_STATUS_REASON) + end + end + + context 'when the canary is not routable routable' do + let(:all_instances_results) do + { + 0 => { state: 'RUNNING', uptime: 50, since: 2, routable: false } + } + end + + it 'skips the deployment update' do + subject.canary + expect(deployment.state).to eq(DeploymentModel::PREPAUSED_STATE) + expect(deployment.status_value).to eq(DeploymentModel::ACTIVE_STATUS_VALUE) + expect(deployment.status_reason).to eq(DeploymentModel::DEPLOYING_STATUS_REASON) + end + end + + context 'when the canary instance is failing' do + let(:all_instances_results) do + { + 0 => { state: 'FAILING', uptime: 50, since: 2, routable: true } + } + end + + it 'does not update the deployments last_healthy_at' do + Timecop.travel(Time.now + 1.minute) do + expect do + subject.canary + end.not_to(change { deployment.reload.last_healthy_at }) + end + end + + it 'changes nothing' do + previous_last_healthy_at = deployment.last_healthy_at + subject.canary + expect(deployment.reload.last_healthy_at).to eq previous_last_healthy_at + expect(deployment.state).to eq(DeploymentModel::PREPAUSED_STATE) + expect(deployment.status_value).to eq(DeploymentModel::ACTIVE_STATUS_VALUE) + expect(deployment.status_reason).to eq(DeploymentModel::DEPLOYING_STATUS_REASON) + end + end + + context 'when an error occurs while canarying a deployment' do + before do + allow(deployment).to receive(:lock!).and_raise(StandardError.new('Something real bad happened')) + end + + it 'logs the error' do + subject.canary + + expect(logger).to have_received(:error).with( + 'error-canarying-deployment', + deployment_guid: deployment.guid, + error: 'StandardError', + error_message: 'Something real bad happened', + backtrace: anything + ) + end + + it 'does not throw an error (so that other deployments can still proceed)' do + expect do + subject.scale + end.not_to raise_error + end + end + + context 'when there is an interim deployment that has been SUPERSEDED (CANCELED)' do + let!(:interim_canceling_web_process) do + ProcessModel.make( + app: app, + created_at: an_hour_ago, + type: ProcessTypes::WEB, + instances: 1, + guid: 'guid-canceling' + ) + end + let!(:interim_canceled_superseded_deployment) do + DeploymentModel.make( + deploying_web_process: interim_canceling_web_process, + state: 'CANCELED', + status_reason: 'SUPERSEDED' + ) + end + + it 'scales the canceled web process to zero' do + subject.canary + expect(interim_canceling_web_process.reload.instances).to eq(0) + end + end + + context 'when this deployment got superseded' do + before do + deployment.update(state: 'DEPLOYED', status_reason: 'SUPERSEDED') + + allow(deployment).to receive(:update).and_call_original + end + + it 'skips the deployment update' do + subject.canary + expect(deployment).not_to have_received(:update) + end + end + end + describe '#cancel' do before do deployment.update(state: 'CANCELING') diff --git a/spec/unit/messages/deployment_create_message_spec.rb b/spec/unit/messages/deployment_create_message_spec.rb new file mode 100644 index 00000000000..68a066fce86 --- /dev/null +++ b/spec/unit/messages/deployment_create_message_spec.rb @@ -0,0 +1,83 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe DeploymentCreateMessage do + let(:body) do + { + 'strategy' => 'rolling', + 'relationships' => { + 'app' => { + 'data' => { + 'guid' => '123' + } + } + } + } + end + + describe 'validations' do + describe 'strategy' do + it 'can be rolling' do + body['strategy'] = 'rolling' + message = DeploymentCreateMessage.new(body) + expect(message).to be_valid + end + + it 'can be canary' do + body['strategy'] = 'canary' + message = DeploymentCreateMessage.new(body) + expect(message).to be_valid + end + + it 'is valid with nil strategy' do + body['strategy'] = nil + message = DeploymentCreateMessage.new(body) + expect(message).to be_valid + end + + it 'is not a valid strategy' do + body['strategy'] = 'potato' + message = DeploymentCreateMessage.new(body) + expect(message).not_to be_valid + expect(message.errors.full_messages).to include("Strategy 'potato' is not a supported deployment strategy") + end + end + + describe 'metadata' do + context 'when the annotations params are valid' do + let(:params) do + { + 'metadata' => { + 'annotations' => { + 'potato' => 'mashed' + } + } + } + end + + it 'is valid and correctly parses the annotations' do + message = DeploymentCreateMessage.new(params) + expect(message).to be_valid + expect(message.annotations).to include(potato: 'mashed') + end + end + + context 'when the annotations params are not valid' do + let(:params) do + { + 'metadata' => { + 'annotations' => 'timmyd' + } + } + end + + it 'is invalid' do + message = DeploymentCreateMessage.new(params) + expect(message).not_to be_valid + expect(message.errors_on(:metadata)).to include('\'annotations\' is not an object') + end + end + end + end + end +end diff --git a/spec/unit/models/runtime/deployment_model_spec.rb b/spec/unit/models/runtime/deployment_model_spec.rb index e77423f06bb..06ab99a0cad 100644 --- a/spec/unit/models/runtime/deployment_model_spec.rb +++ b/spec/unit/models/runtime/deployment_model_spec.rb @@ -59,6 +59,18 @@ module VCAP::CloudController expect(deployment.deploying?).to be(true) end + it 'returns true if the deployment is PAUSED' do + deployment.state = DeploymentModel::PAUSED_STATE + + expect(deployment.deploying?).to be(true) + end + + it 'returns true if the deployment is PREPAUSED' do + deployment.state = DeploymentModel::PREPAUSED_STATE + + expect(deployment.deploying?).to be(true) + end + it 'returns false if the deployment has been deployed' do deployment.state = 'DEPLOYED' @@ -85,6 +97,18 @@ module VCAP::CloudController expect(deployment.cancelable?).to be(true) end + it 'returns true if the deployment is PAUSED' do + deployment.state = DeploymentModel::PAUSED_STATE + + expect(deployment.cancelable?).to be(true) + end + + it 'returns true if the deployment is PREPAUSED' do + deployment.state = DeploymentModel::PREPAUSED_STATE + + expect(deployment.cancelable?).to be(true) + end + it 'returns false if the deployment is DEPLOYED' do deployment.state = DeploymentModel::DEPLOYED_STATE @@ -104,6 +128,65 @@ module VCAP::CloudController end end + describe '#continuable?' do + it 'returns true if the deployment is PAUSED' do + deployment.state = DeploymentModel::PAUSED_STATE + + expect(deployment.continuable?).to be(true) + end + + it 'returns false if the deployment is PREPAUSED' do + deployment.state = DeploymentModel::PREPAUSED_STATE + + expect(deployment.continuable?).to be(false) + end + + it 'returns false if the deployment is DEPLOYING state' do + deployment.state = DeploymentModel::DEPLOYING_STATE + + expect(deployment.continuable?).to be(false) + end + + it 'returns false if the deployment is DEPLOYED state' do + deployment.state = DeploymentModel::DEPLOYED_STATE + + expect(deployment.continuable?).to be(false) + end + + it 'returns true if the deployment is CANCELING' do + deployment.state = DeploymentModel::CANCELING_STATE + + expect(deployment.continuable?).to be(false) + end + + it 'returns false if the deployment is CANCELED' do + deployment.state = DeploymentModel::CANCELED_STATE + + expect(deployment.continuable?).to be(false) + end + end + + describe 'PROGRESSING_STATES' do + it 'contains progressing forward states' do + expect(DeploymentModel::PROGRESSING_STATES).to include( + DeploymentModel::DEPLOYING_STATE, + DeploymentModel::PAUSED_STATE, + DeploymentModel::PREPAUSED_STATE + ) + end + end + + describe 'ACTIVE_STATES' do + it 'contains active states' do + expect(DeploymentModel::ACTIVE_STATES).to include( + DeploymentModel::DEPLOYING_STATE, + DeploymentModel::PAUSED_STATE, + DeploymentModel::PREPAUSED_STATE, + DeploymentModel::CANCELING_STATE + ) + end + end + describe '#status_updated_at' do let(:deployment) do DeploymentModel.make( diff --git a/spec/unit/repositories/deployment_event_repository_spec.rb b/spec/unit/repositories/deployment_event_repository_spec.rb index fbf8a85f07e..6f86c43440e 100644 --- a/spec/unit/repositories/deployment_event_repository_spec.rb +++ b/spec/unit/repositories/deployment_event_repository_spec.rb @@ -7,7 +7,7 @@ module Repositories let(:app) { AppModel.make(name: 'popsicle') } let(:user) { User.make } let(:droplet) { DropletModel.make } - let(:deployment) { DeploymentModel.make(app_guid: app.guid) } + let(:deployment) { DeploymentModel.make(app_guid: app.guid, strategy: strategy) } let(:email) { 'user-email' } let(:user_name) { 'user-name' } let(:user_audit_info) { UserAuditInfo.new(user_email: email, user_name: user_name, user_guid: user.guid) } @@ -15,10 +15,11 @@ module Repositories { 'foo' => 'bar ' } end let(:type) { 'rollback' } + let(:strategy) { 'canary' } describe '#record_create_deployment' do context 'when a droplet is associated with the deployment' do - let(:deployment) { DeploymentModel.make(app_guid: app.guid, droplet_guid: droplet.guid) } + let(:deployment) { DeploymentModel.make(app_guid: app.guid, droplet_guid: droplet.guid, strategy: strategy) } it 'creates a new audit.app.deployment.create event' do event = DeploymentEventRepository.record_create(deployment, droplet, user_audit_info, app.name, @@ -40,6 +41,7 @@ module Repositories expect(metadata['droplet_guid']).to eq(droplet.guid) expect(metadata['request']).to eq(params) expect(metadata['type']).to eq(type) + expect(metadata['strategy']).to eq(strategy) end end @@ -95,6 +97,32 @@ module Repositories expect(metadata['droplet_guid']).to eq(droplet.guid) end end + + describe 'record_continue_deployment' do + let(:deployment) { DeploymentModel.make(app_guid: app.guid) } + + it 'creates a new audit.app.deployment.continue event' do + event = DeploymentEventRepository.record_continue(deployment, droplet, user_audit_info, app.name, + app.space.guid, app.space.organization.guid) + event.reload + + expect(event.type).to eq('audit.app.deployment.continue') + expect(event.actor).to eq(user.guid) + expect(event.actor_type).to eq('user') + expect(event.actor_name).to eq(email) + expect(event.actor_username).to eq(user_name) + expect(event.actee).to eq(deployment.app_guid) + expect(event.actee_type).to eq('app') + expect(event.actee_name).to eq('popsicle') + expect(event.timestamp).not_to be_nil + expect(event.space_guid).to eq(app.space.guid) + expect(event.organization_guid).to eq(app.organization.guid) + + metadata = event.metadata + expect(metadata['deployment_guid']).to eq(deployment.guid) + expect(metadata['droplet_guid']).to eq(droplet.guid) + end + end end end end diff --git a/spec/unit/repositories/event_types_spec.rb b/spec/unit/repositories/event_types_spec.rb index 43a77704622..fe660224052 100644 --- a/spec/unit/repositories/event_types_spec.rb +++ b/spec/unit/repositories/event_types_spec.rb @@ -58,6 +58,7 @@ module Repositories 'audit.app.revision.environment_variables.show', 'audit.app.deployment.cancel', 'audit.app.deployment.create', + 'audit.app.deployment.continue', 'audit.app.copy-bits', 'audit.app.upload-bits', 'audit.app.apply_manifest',