app/actions/app_apply_manifest.rb
require 'actions/process_create'
require 'actions/process_scale'
require 'actions/process_update'
require 'actions/service_credential_binding_app_create'
require 'actions/service_credential_binding_delete'
require 'actions/manifest_route_update'
require 'cloud_controller/strategies/manifest_strategy'
require 'cloud_controller/app_manifest/manifest_route'
require 'cloud_controller/random_route_generator'
require 'services/service_brokers/v2/orphan_mitigator'
module VCAP::CloudController
class AppApplyManifest
class Error < StandardError; end
class NoDefaultDomain < StandardError; end
class ServiceBindingError < StandardError; end
class MaximumPollingDurationExceeded < StandardError; end
class ServiceBrokerRespondedAsyncWhenNotAllowed < StandardError; end
SERVICE_BINDING_TYPE = 'app'.freeze
DEFAULT_POLLING_INTERVAL = 5.seconds
MAX_POLLING_INTERVAL = 60
def initialize(user_audit_info)
@user_audit_info = user_audit_info
end
def apply(app_guid, message)
app = AppModel.first(guid: app_guid)
app_instance_not_found!(app_guid) unless app
message.manifest_process_update_messages.each do |manifest_process_update_msg|
if manifest_process_update_msg.requested_keys == [:type] && manifest_process_update_msg.type == 'web'
# ignore trivial messages, most likely from manifest
next
end
process_type = manifest_process_update_msg.type
process = find_process(app, process_type) || create_process(app, manifest_process_update_msg, process_type)
process_update = ProcessUpdate.new(@user_audit_info, manifest_triggered: true)
process_update.update(process, manifest_process_update_msg, ManifestStrategy)
end
message.manifest_process_scale_messages.each do |manifest_process_scale_msg|
process = find_process(app, manifest_process_scale_msg.type)
process.skip_process_version_update = true if manifest_process_scale_msg.requested?(:memory)
process_scale = ProcessScale.new(@user_audit_info, process, manifest_process_scale_msg.to_process_scale_message, manifest_triggered: true)
process_scale.scale
end
message.sidecar_create_messages.each do |sidecar_create_message|
sidecar = find_sidecar(app, sidecar_create_message.name)
if sidecar
SidecarUpdate.update(sidecar, sidecar_create_message)
else
SidecarCreate.create(app.guid, sidecar_create_message)
end
end
app_update_message = message.app_update_message
lifecycle = AppLifecycleProvider.provide_for_update(app_update_message, app)
AppUpdate.new(@user_audit_info, manifest_triggered: true).update(app, app_update_message, lifecycle)
update_routes(app, message)
AppPatchEnvironmentVariables.new(@user_audit_info, manifest_triggered: true).patch(app, message.app_update_environment_variables_message)
create_service_bindings(message.manifest_service_bindings_message, app) if message.services.present?
app
end
private
def find_process(app, process_type)
ProcessModel.where(app: app, type: process_type).order(Sequel.asc(:created_at), Sequel.asc(:id)).last
end
def create_process(app, manifest_process_update_msg, process_type)
ProcessCreate.new(@user_audit_info, manifest_triggered: true).create(app, {
type: process_type,
command: manifest_process_update_msg.command
})
end
def find_sidecar(app, sidecar_name)
app.sidecars_dataset.where(name: sidecar_name).last
end
def update_routes(app, message)
update_message = message.manifest_routes_update_message
existing_routes = RouteMappingModel.where(app_guid: app.guid).all
if update_message.no_route
RouteMappingDelete.new(@user_audit_info, manifest_triggered: true).delete(existing_routes)
return
end
if update_message.routes
ManifestRouteUpdate.update(app.guid, update_message, @user_audit_info)
return
end
if update_message.random_route && existing_routes.empty?
random_host = "#{app.name}-#{RandomRouteGenerator.new.route}"
domain_name = get_default_domain_name(app)
route = "#{random_host}.#{domain_name}"
random_route_message = ManifestRoutesUpdateMessage.new(routes: [{ route: }])
ManifestRouteUpdate.update(app.guid, random_route_message, @user_audit_info)
end
return unless update_message.default_route && existing_routes.empty?
validate_name_dns_compliant!(app.name)
domain_name = get_default_domain_name(app)
route = "#{app.name}.#{domain_name}"
random_route_message = ManifestRoutesUpdateMessage.new(routes: [{ route: }])
ManifestRouteUpdate.update(app.guid, random_route_message, @user_audit_info)
end
def get_default_domain_name(app)
domain_name = app.organization.default_domain&.name
raise NoDefaultDomain.new('No default domains available') unless domain_name
domain_name
end
def validate_name_dns_compliant!(name)
prefix = 'Failed to create default route from app name:'
error!(prefix + ' Host cannot exceed 63 characters') if name.present? && name.length > 63
return if name&.match(/\A[\w\-]+\z/)
error!(prefix + ' Host must be either "*" or contain only alphanumeric characters, "_", or "-"')
end
def create_service_bindings(manifest_service_bindings_message, app)
manifest_service_bindings_message.manifest_service_bindings.each do |manifest_service_binding|
service_instance = app.space.find_visible_service_instance_by_name(manifest_service_binding.name)
service_instance_not_found!(manifest_service_binding.name) unless service_instance
binding = ServiceBinding.first(service_instance:, app:)
next if binding&.create_succeeded?
raise_binding_operation_in_progress!(service_instance, binding.last_operation.type) if binding&.operation_in_progress?
raise_binding_delete_failed!(service_instance) if binding&.delete_failed?
begin
binding_message = create_binding_message(service_instance.guid, app.guid, manifest_service_binding)
action = V3::ServiceCredentialBindingAppCreate.new(@user_audit_info, binding_message.audit_hash, manifest_triggered: true)
binding = action.precursor(
service_instance,
app: app,
volume_mount_services_enabled: volume_services_enabled?,
message: binding_message
)
begin
binding = action.bind(binding, parameters: binding_message.parameters, accepts_incomplete: false)
raise ServiceBrokerRespondedAsyncWhenNotAllowed.new('The service broker responded asynchronously when a synchronous bind was requested.') if binding[:async]
rescue VCAP::Services::ServiceBrokers::V2::Errors::AsyncRequired
binding = action.bind(binding, parameters: binding_message.parameters, accepts_incomplete: true)
poll_async_binding(action, binding)
end
rescue StandardError => e
raise_binding_error!(service_instance, e.message)
end
end
end
def poll_async_binding(action, binding)
start = Time.now
poll_result = action.poll(binding)
while !poll_result[:finished] && Time.now < (start + max_polling_duration)
retry_after = poll_result[:retry_after] || DEFAULT_POLLING_INTERVAL
sleep [retry_after, MAX_POLLING_INTERVAL].min
poll_result = action.poll(binding)
end
return if poll_result[:finished]
binding.save_with_attributes_and_new_operation(
{},
{
type: 'create',
state: 'failed',
description: 'Polling exceed the maximum polling duration'
}
)
VCAP::Services::ServiceBrokers::V2::OrphanMitigator.new.cleanup_failed_bind(binding)
raise MaximumPollingDurationExceeded.new("Polling exceeded the maximum duration of #{max_polling_duration}")
end
def create_binding_message(service_instance_guid, app_guid, manifest_service_binding)
ServiceCredentialAppBindingCreateMessage.new(
type: SERVICE_BINDING_TYPE,
name: manifest_service_binding.binding_name,
parameters: manifest_service_binding.parameters,
relationships: {
service_instance: {
data: {
guid: service_instance_guid
}
},
app: {
data: {
guid: app_guid
}
}
}
)
end
def raise_binding_error!(service_instance, message)
error_message = "For service '#{service_instance.name}': #{message}"
raise ServiceBindingError.new(error_message)
end
def raise_binding_operation_in_progress!(service_instance, operation)
error_message = "A binding is being #{operation}d. Retry this operation later."
raise_binding_error!(service_instance, error_message)
end
def raise_binding_delete_failed!(service_instance)
error_message = 'A binding failed to be deleted. Resolve the issue with this binding before retrying this operation.'
raise_binding_error!(service_instance, error_message)
end
def app_instance_not_found!(app_guid)
raise CloudController::Errors::NotFound.new_from_details('ResourceNotFound', "App with guid '#{app_guid}' not found")
end
def service_instance_not_found!(name)
raise CloudController::Errors::NotFound.new_from_details('ResourceNotFound', "Service instance '#{name}' not found")
end
def volume_services_enabled?
VCAP::CloudController::Config.config.get(:volume_services_enabled)
end
def max_polling_duration
VCAP::CloudController::Config.config.get(:max_manifest_service_binding_poll_duration_in_seconds)
end
def logger
@logger ||= Steno.logger('cc.action.app_apply_manifest')
end
def error!(message)
raise Error.new(message)
end
end
end