Skip to content

Commit

Permalink
feat(appengine): upsert autoscaling policy (#1564)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielpeach committed Apr 7, 2017
1 parent 4fc0c1e commit 82e9aa1
Show file tree
Hide file tree
Showing 12 changed files with 617 additions and 11 deletions.
@@ -0,0 +1,37 @@
/*
* Copyright 2017 Google, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.netflix.spinnaker.clouddriver.appengine.deploy.converters

import com.netflix.spinnaker.clouddriver.appengine.AppengineOperation
import com.netflix.spinnaker.clouddriver.appengine.deploy.description.UpsertAppengineAutoscalingPolicyDescription
import com.netflix.spinnaker.clouddriver.appengine.deploy.ops.UpsertAppengineAutoscalingPolicyAtomicOperation
import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperation
import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperations
import com.netflix.spinnaker.clouddriver.security.AbstractAtomicOperationsCredentialsSupport
import org.springframework.stereotype.Component

@AppengineOperation(AtomicOperations.UPSERT_SCALING_POLICY)
@Component
class UpsertAppengineAutoscalingPolicyAtomicOperationConverter extends AbstractAtomicOperationsCredentialsSupport {
AtomicOperation convertOperation(Map input) {
new UpsertAppengineAutoscalingPolicyAtomicOperation(convertDescription(input))
}

UpsertAppengineAutoscalingPolicyDescription convertDescription(Map input) {
AppengineAtomicOperationConverterHelper.convertDescription(input, this, UpsertAppengineAutoscalingPolicyDescription)
}
}
@@ -0,0 +1,24 @@
/*
* Copyright 2017 Google, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.netflix.spinnaker.clouddriver.appengine.deploy.description

class UpsertAppengineAutoscalingPolicyDescription extends AbstractAppengineCredentialsDescription {
String accountName
String serverGroupName
Integer minIdleInstances
Integer maxIdleInstances
}
@@ -0,0 +1,96 @@
/*
* Copyright 2017 Google, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.netflix.spinnaker.clouddriver.appengine.deploy.ops

import com.google.api.services.appengine.v1.model.AutomaticScaling
import com.google.api.services.appengine.v1.model.Operation
import com.google.api.services.appengine.v1.model.Version
import com.netflix.spinnaker.clouddriver.appengine.deploy.AppengineSafeRetry
import com.netflix.spinnaker.clouddriver.appengine.deploy.description.UpsertAppengineAutoscalingPolicyDescription
import com.netflix.spinnaker.clouddriver.appengine.deploy.exception.AppengineResourceNotFoundException
import com.netflix.spinnaker.clouddriver.appengine.provider.view.AppengineClusterProvider
import com.netflix.spinnaker.clouddriver.data.task.Task
import com.netflix.spinnaker.clouddriver.data.task.TaskRepository
import org.springframework.beans.factory.annotation.Autowired

class UpsertAppengineAutoscalingPolicyAtomicOperation extends AppengineAtomicOperation<Void> {
private static final String BASE_PHASE = "UPSERT_SCALING_POLICY"

private static Task getTask() {
TaskRepository.threadLocalTask.get()
}

private final UpsertAppengineAutoscalingPolicyDescription description

@Autowired
AppengineClusterProvider appengineClusterProvider

@Autowired
AppengineSafeRetry safeRetry

UpsertAppengineAutoscalingPolicyAtomicOperation(UpsertAppengineAutoscalingPolicyDescription description) {
this.description = description
}

@Override
Void operate(List priorOutputs) {
task.updateStatus BASE_PHASE, "Initializing upsert of autoscaling policy for server group " +
"$description.serverGroupName in $description.credentials.region..."

def credentials = description.credentials
def serverGroupName = description.serverGroupName
def projectName = credentials.project

task.updateStatus BASE_PHASE, "Looking up $description.serverGroupName..."
def serverGroup = appengineClusterProvider.getServerGroup(credentials.name,
credentials.region,
serverGroupName)
def loadBalancerName = serverGroup?.loadBalancers?.first()

if (!serverGroup) {
throw new AppengineResourceNotFoundException("Unable to locate server group $serverGroupName.")
}
if (!loadBalancerName) {
throw new AppengineResourceNotFoundException("Unable to locate load balancer for $serverGroupName.")
}

def updatedAutoscalingPolicy = new AutomaticScaling(
minIdleInstances: description.minIdleInstances ?: serverGroup.scalingPolicy?.minIdleInstances,
maxIdleInstances: description.maxIdleInstances ?: serverGroup.scalingPolicy?.maxIdleInstances)
def version = new Version(automaticScaling: updatedAutoscalingPolicy)

task.updateStatus BASE_PHASE, "Setting min and max idle instance boundaries for $serverGroupName..."
safeRetry.doRetry(
{ callApi(projectName, loadBalancerName, serverGroupName, version) },
"version",
task,
[409],
[],
[action: "upsertAutoscalingPolicy", phase: BASE_PHASE],
registry)

task.updateStatus BASE_PHASE, "Completed upsert of autoscaling policy for $serverGroupName."
return null
}

Operation callApi(String projectName, String loadBalancerName, String serverGroupName, Version version) {
return description.credentials.appengine.apps().services().versions()
.patch(projectName, loadBalancerName, serverGroupName, version)
.setUpdateMask("automaticScaling.min_idle_instances,automaticScaling.max_idle_instances")
.execute()
}
}
Expand Up @@ -132,6 +132,24 @@ class StandardAppengineAttributeValidator {
}
}

def validateMaxNotLessThanMin(Integer minValue, Integer maxValue, String minAttribute, String maxAttribute) {
if (maxValue < minValue) {
errors.rejectValue(maxAttribute, "${context}.${maxAttribute} must not be less than ${context}.${minAttribute}.")
return false
} else {
return true
}
}

def validateNonNegative(Integer value, String attribute) {
if (value && value < 0) {
errors.rejectValue(attribute, "${context}.${attribute}.negative")
return false
} else {
return true
}
}

def validateAllocations(Map<String, Double> allocations, ShardBy shardBy, String attribute) {
def decimalPlaces = shardBy == ShardBy.COOKIE ?
AppengineModelUtil.COOKIE_SPLIT_DECIMAL_PLACES :
Expand Down Expand Up @@ -292,7 +310,7 @@ class StandardAppengineAttributeValidator {

if (!(isFlex || usesBasicScaling || usesManualScaling)) {
errors.rejectValue("${context}.${attribute}",
"${context}.${attribute}.invalid (Only server groups that use the flexible environment," +
"${context}.${attribute}.invalid (Only server groups that use the App Engine flexible environment," +
" or use basic or manual scaling can be started or stopped).")
return false
} else {
Expand Down Expand Up @@ -339,12 +357,40 @@ class StandardAppengineAttributeValidator {
def serverGroup = clusterProvider.getServerGroup(credentials.name, credentials.region, split.allocations.keySet()[0])
if (!serverGroup.allowsGradualTrafficMigration) {
errors.rejectValue("${context}.${attribute}", "${context}.${attribute}.invalid " +
"(Cannot gradually migrate traffic to this server group. " +
"Gradual migration is allowed only for server groups in the standard " +
"environment that use automatic scaling and have warmup requests enabled).")
"(Cannot gradually migrate traffic to this server group. " +
"Gradual migration is allowed only for server groups in the App Engine standard " +
"environment that use automatic scaling and have warmup requests enabled).")
return false
}

return true
}

def validateServerGroupSupportsAutoscalingPolicyUpsert(String serverGroupName,
AppengineNamedAccountCredentials credentials,
AppengineClusterProvider clusterProvider,
String attribute) {
if (!serverGroupName) {
return
}

def serverGroup = clusterProvider.getServerGroup(credentials.name, credentials.region, serverGroupName)
if (serverGroup) {
// App Engine standard versions don't always have an 'env' property.
def isStandard = serverGroup.env != AppengineServerGroup.Environment.FLEXIBLE
def usesAutomaticScaling = serverGroup.scalingPolicy?.type == ScalingPolicyType.AUTOMATIC
if (isStandard && usesAutomaticScaling) {
return true
} else {
errors.rejectValue("${context}.${attribute}", "${context}.${attribute}.invalid " +
"(Autoscaling policies can only be updated for " +
"server groups in the App Engine standard environment that use automatic scaling).")
return false
}
} else {
errors.rejectValue("${context}.${attribute}", "${context}.${attribute}.notFound " +
"(Cannot find server group $serverGroupName.)")
return false
}
}
}
@@ -0,0 +1,64 @@
/*
* Copyright 2017 Google, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.netflix.spinnaker.clouddriver.appengine.deploy.validators

import com.netflix.spinnaker.clouddriver.appengine.AppengineOperation
import com.netflix.spinnaker.clouddriver.appengine.deploy.description.UpsertAppengineAutoscalingPolicyDescription
import com.netflix.spinnaker.clouddriver.appengine.provider.view.AppengineClusterProvider
import com.netflix.spinnaker.clouddriver.deploy.DescriptionValidator
import com.netflix.spinnaker.clouddriver.orchestration.AtomicOperations
import com.netflix.spinnaker.clouddriver.security.AccountCredentialsProvider
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Component
import org.springframework.validation.Errors

@AppengineOperation(AtomicOperations.UPSERT_SCALING_POLICY)
@Component
class UpsertAppengineAutoscalingPolicyDescriptionValidator extends DescriptionValidator<UpsertAppengineAutoscalingPolicyDescription> {
@Autowired
AccountCredentialsProvider accountCredentialsProvider

@Autowired
AppengineClusterProvider appengineClusterProvider

@Override
void validate(List priorDescriptions, UpsertAppengineAutoscalingPolicyDescription description, Errors errors) {
def helper = new StandardAppengineAttributeValidator("upsertAppengineAutoscalingPolicyAtomicOperationDescription", errors)

if (!helper.validateCredentials(description.accountName, accountCredentialsProvider)) {
return
}

helper.validateNotEmpty(description.serverGroupName, "serverGroupName")
helper.validateNotEmpty(description.minIdleInstances, "minIdleInstances")
helper.validateNotEmpty(description.maxIdleInstances, "maxIdleInstances")

helper.validateMaxNotLessThanMin(
description.minIdleInstances,
description.maxIdleInstances,
"minIdleInstances",
"maxIdleInstances")
helper.validateNonNegative(description.minIdleInstances, "minIdleInstances")
helper.validateNonNegative(description.maxIdleInstances, "maxIdleInstances")

helper.validateServerGroupSupportsAutoscalingPolicyUpsert(
description.serverGroupName,
description.credentials,
appengineClusterProvider,
"serverGroupName")
}
}
Expand Up @@ -88,9 +88,12 @@ class AppengineServerGroup implements ServerGroup, Serializable {
* For the flexible environment, a version using automatic scaling can be stopped.
* A stopped version scales down to zero instances and ignores its scaling policy.
* */
def min = servingStatus == ServingStatus.SERVING ? (scalingPolicy.minTotalInstances ?: 0) : 0
def min = servingStatus == ServingStatus.SERVING ?
[scalingPolicy.minTotalInstances, scalingPolicy.minIdleInstances].max() : 0
def max = servingStatus == ServingStatus.SERVING ?
[scalingPolicy.maxTotalInstances, scalingPolicy.maxIdleInstances, instanceCount].max() : instanceCount
return new ServerGroup.Capacity(min: min,
max: scalingPolicy.maxTotalInstances ?: instanceCount,
max: max,
desired: min)
break
case ScalingPolicyType.BASIC:
Expand Down
@@ -0,0 +1,68 @@
/*
* Copyright 2017 Google, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.netflix.spinnaker.clouddriver.appengine.deploy.converters

import com.fasterxml.jackson.databind.ObjectMapper
import com.netflix.spinnaker.clouddriver.appengine.deploy.description.UpsertAppengineAutoscalingPolicyDescription
import com.netflix.spinnaker.clouddriver.appengine.deploy.ops.UpsertAppengineAutoscalingPolicyAtomicOperation
import com.netflix.spinnaker.clouddriver.appengine.security.AppengineNamedAccountCredentials
import com.netflix.spinnaker.clouddriver.security.AccountCredentialsProvider
import spock.lang.Shared
import spock.lang.Specification

class UpsertAppengineAutoscalingPolicyAtomicOperationConverterSpec extends Specification {
private static final ACCOUNT_NAME = "my-appengine-account"
private static final SERVER_GROUP_NAME = "app-stack-detail-v000"
private static final MIN_IDLE_INSTANCES = 10
private static final MAX_IDLE_INSTANCES = 20

@Shared
ObjectMapper mapper = new ObjectMapper()

@Shared
UpsertAppengineAutoscalingPolicyAtomicOperationConverter converter

def setupSpec() {
converter = new UpsertAppengineAutoscalingPolicyAtomicOperationConverter(objectMapper: mapper)
def accountCredentialsProvider = Mock(AccountCredentialsProvider)
def mockCredentials = Mock(AppengineNamedAccountCredentials)
accountCredentialsProvider.getCredentials(_) >> mockCredentials
converter.accountCredentialsProvider = accountCredentialsProvider
}

void "upsertAppengineAutoscalingPolicyDescription type returns UpsertAppengineAutoscalingPolicyDescription and UpsertAppengineAutoscalingPolicyAtomicOperation"() {
setup:
def input = [
credentials: ACCOUNT_NAME,
loadBalancerName: SERVER_GROUP_NAME,
minIdleInstances: MIN_IDLE_INSTANCES,
maxIdleInstances: MAX_IDLE_INSTANCES,
]

when:
def description = converter.convertDescription(input)

then:
description instanceof UpsertAppengineAutoscalingPolicyDescription

when:
def operation = converter.convertOperation(input)

then:
operation instanceof UpsertAppengineAutoscalingPolicyAtomicOperation
}
}
Expand Up @@ -90,8 +90,8 @@ class StartStopAppengineAtomicOperationSpec extends Specification {
1 * patchMock.execute()

where:
start | expectedVersion
true | new Version(servingStatus: "SERVING")
false | new Version(servingStatus: "STOPPED")
start || expectedVersion
true || new Version(servingStatus: "SERVING")
false || new Version(servingStatus: "STOPPED")
}
}

0 comments on commit 82e9aa1

Please sign in to comment.