diff --git a/.editorconfig b/.editorconfig index c2cdfb8a..8a80734f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,7 +9,7 @@ root = true # Change these settings to your own preference indent_style = space -indent_size = 2 +indent_size = 4 # We recommend you to keep these unchanged end_of_line = lf diff --git a/grails-app/conf/BuildConfig.groovy b/grails-app/conf/BuildConfig.groovy index 246ed12b..d963a3cd 100644 --- a/grails-app/conf/BuildConfig.groovy +++ b/grails-app/conf/BuildConfig.groovy @@ -52,6 +52,7 @@ grails.project.dependency.resolution = { grailsHome() grailsCentral() mavenCentral() + mavenRepo "http://dl.bintray.com/spinnaker/spinnaker" // Optional custom repository for dependencies. Closure internalRepo = { @@ -140,7 +141,10 @@ grails.project.dependency.resolution = { // Used for JSON parsing of AWS Simple Workflow Service metadata. // Previously this was an indirect depencency through Grails itself, but this caused errors in some // Grails environments. - 'com.googlecode.json-simple:json-simple:1.1' + 'com.googlecode.json-simple:json-simple:1.1', + + // Spinnaker client is used to retrieve application metadata + 'com.netflix.spinnaker.client:spinnaker-client:0.6' ) { // Exclude superfluous and dangerous transitive dependencies excludes( // Some libraries bring older versions of JUnit as a transitive dependency and that can interfere diff --git a/grails-app/conf/spring/resources.groovy b/grails-app/conf/spring/resources.groovy index 8b64a41e..d90341d6 100644 --- a/grails-app/conf/spring/resources.groovy +++ b/grails-app/conf/spring/resources.groovy @@ -19,6 +19,8 @@ import com.google.common.base.CaseFormat import com.netflix.asgard.CachedMapBuilder import com.netflix.asgard.Caches import com.netflix.asgard.CsiAsgAnalyzer +import com.netflix.asgard.applications.SimpleDBApplicationService +import com.netflix.asgard.applications.SpinnakerApplicationService import com.netflix.asgard.NoOpAsgAnalyzer import com.netflix.asgard.Region import com.netflix.asgard.ServiceInitLoggingBeanPostProcessor @@ -113,6 +115,19 @@ beans = { restrictBrowserAuthorizationProvider(RestrictBrowserAuthorizationProvider) + if (application.config.spinnaker?.gateUrl) { + applicationService( + SpinnakerApplicationService, application.config.spinnaker.gateUrl, application.config.cloud.accountName + ) { bean -> + bean.lazyInit = true + } + } else { + applicationService(SimpleDBApplicationService) { bean -> + bean.lazyInit = true + } + } + + //**** Plugin behavior xmlns lang:'http://www.springframework.org/schema/lang' diff --git a/grails-app/controllers/com/netflix/asgard/ApplicationController.groovy b/grails-app/controllers/com/netflix/asgard/ApplicationController.groovy index 9e8d1945..2152112e 100644 --- a/grails-app/controllers/com/netflix/asgard/ApplicationController.groovy +++ b/grails-app/controllers/com/netflix/asgard/ApplicationController.groovy @@ -179,8 +179,9 @@ class ApplicationController { String monitorBucketTypeString = params.monitorBucketType String tags = normalizeTagDelimiter(params.tags) MonitorBucketType bucketType = Enum.valueOf(MonitorBucketType, monitorBucketTypeString) - CreateApplicationResult result = applicationService.createRegisteredApplication(userContext, name, group, - type, desc, owner, email, bucketType, tags) + def result = applicationService.createRegisteredApplication( + userContext, name, group, type, desc, owner, email, bucketType, tags + ) flash.message = result.toString() if (result.succeeded()) { redirect(action: 'show', params: [id: name]) @@ -210,9 +211,10 @@ class ApplicationController { String monitorBucketTypeString = params.monitorBucketType try { MonitorBucketType bucketType = Enum.valueOf(MonitorBucketType, monitorBucketTypeString) - applicationService.updateRegisteredApplication(userContext, name, group, type, desc, owner, email, tags, - bucketType) - flash.message = "Application '${name}' has been updated." + def result = applicationService.updateRegisteredApplication( + userContext, name, group, type, desc, owner, email, tags, bucketType + ) + flash.message = result.toString() } catch (Exception e) { flash.message = "Could not update Application: ${e}" } diff --git a/grails-app/services/com/netflix/asgard/ApplicationService.groovy b/grails-app/services/com/netflix/asgard/ApplicationService.groovy index 09ca8ed3..75fef427 100644 --- a/grails-app/services/com/netflix/asgard/ApplicationService.groovy +++ b/grails-app/services/com/netflix/asgard/ApplicationService.groovy @@ -1,5 +1,5 @@ /* - * Copyright 2012 Netflix, Inc. + * Copyright 2014 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,172 +15,33 @@ */ package com.netflix.asgard -import com.amazonaws.AmazonServiceException -import com.amazonaws.services.simpledb.model.Attribute -import com.amazonaws.services.simpledb.model.Item -import com.amazonaws.services.simpledb.model.ReplaceableAttribute -import com.netflix.asgard.cache.CacheInitializer import com.netflix.asgard.collections.GroupedAppRegistrationSet import com.netflix.asgard.model.MonitorBucketType -import org.joda.time.DateTime -import org.springframework.beans.factory.InitializingBean - -class ApplicationService implements CacheInitializer, InitializingBean { - - static transactional = false - - /** The name of SimpleDB domain that stores the cloud application registry. */ - String domainName - - def grailsApplication // injected after construction - def awsAutoScalingService - def awsClientService - def awsEc2Service - def awsLoadBalancerService - def awsSimpleDbService - Caches caches - def configService - def fastPropertyService - def mergedInstanceGroupingService - def taskService - - void afterPropertiesSet() { - domainName = configService.applicationsDomain - } - void initializeCaches() { - caches.allApplications.ensureSetUp({ retrieveApplications() }) - } +interface ApplicationService { + List getRegisteredApplications(UserContext userContext) - private Collection retrieveApplications() { - List items = awsSimpleDbService.selectAll(domainName).sort { it.name.toLowerCase() } - items.collect { AppRegistration.from(it) } - } - - List getRegisteredApplications(UserContext userContext) { - caches.allApplications.list().sort { it.name } - } - - List getRegisteredApplicationsForLoadBalancer(UserContext userContext) { - new ArrayList(getRegisteredApplications(userContext).findAll { - Relationships.checkAppNameForLoadBalancer(it.name) - }) - } - - GroupedAppRegistrationSet getGroupedRegisteredApplications(UserContext ctx) { - new GroupedAppRegistrationSet(getRegisteredApplications(ctx)) - } + List getRegisteredApplicationsForLoadBalancer(UserContext userContext) - AppRegistration getRegisteredApplication(UserContext userContext, String nameInput, From from = From.AWS) { - if (!nameInput) { return null } - String name = nameInput.toLowerCase() - if (from == From.CACHE) { - return caches.allApplications.get(name) - } - Item item = awsSimpleDbService.selectOne(domainName, name.toUpperCase()) - AppRegistration appRegistration = AppRegistration.from(item) - caches.allApplications.put(name, appRegistration) - appRegistration - } + GroupedAppRegistrationSet getGroupedRegisteredApplications(UserContext ctx) - AppRegistration getRegisteredApplicationForLoadBalancer(UserContext userContext, String name) { - Relationships.checkAppNameForLoadBalancer(name) ? getRegisteredApplication(userContext, name) : null - } + AppRegistration getRegisteredApplication(UserContext userContext, String nameInput) - CreateApplicationResult createRegisteredApplication(UserContext userContext, String nameInput, String group, - String type, String description, String owner, String email, MonitorBucketType monitorBucketType, - String tags) { - String name = nameInput.toLowerCase() - CreateApplicationResult result = new CreateApplicationResult() - result.appName = name - if (getRegisteredApplication(userContext, name)) { - result.appCreateException = new IllegalStateException("Can't add Application ${name}. It already exists.") - return result - } - String nowEpoch = new DateTime().millis as String - Collection attributes = buildAttributesList(group, type, description, owner, email, - monitorBucketType, tags, false) - attributes << new ReplaceableAttribute('createTs', nowEpoch, false) - String creationLogMessage = "Create registered app ${name}, type ${type}, owner ${owner}, email ${email}" - taskService.runTask(userContext, creationLogMessage, { task -> - try { - awsSimpleDbService.save(domainName, name.toUpperCase(), attributes) - result.appCreated = true - } catch (AmazonServiceException e) { - result.appCreateException = e - } - }, Link.to(EntityType.application, name)) - getRegisteredApplication(userContext, name) - result - } + AppRegistration getRegisteredApplication(UserContext userContext, String nameInput, From from) - private static Collection buildAttributesList(String group, String type, String description, - String owner, String email, MonitorBucketType monitorBucketType, String tags, - Boolean replaceExistingValues) { - - Check.notNull(monitorBucketType, MonitorBucketType, 'monitorBucketType') - String nowEpoch = new DateTime().millis as String - Collection attributes = [] - attributes << new ReplaceableAttribute('group', group ?: '', replaceExistingValues) - attributes << new ReplaceableAttribute('type', Check.notEmpty(type), replaceExistingValues) - attributes << new ReplaceableAttribute('description', Check.notEmpty(description), replaceExistingValues) - attributes << new ReplaceableAttribute('owner', Check.notEmpty(owner), replaceExistingValues) - attributes << new ReplaceableAttribute('email', Check.notEmpty(email), replaceExistingValues) - attributes << new ReplaceableAttribute('monitorBucketType', monitorBucketType.name(), replaceExistingValues) - attributes << new ReplaceableAttribute('updateTs', nowEpoch, replaceExistingValues) - if (tags) { - attributes << new ReplaceableAttribute('tags', tags, replaceExistingValues) - } - return attributes - } + AppRegistration getRegisteredApplicationForLoadBalancer(UserContext userContext, String name) - void updateRegisteredApplication(UserContext userContext, String name, String group, String type, String desc, - String owner, String email, String tags, MonitorBucketType bucketType) { - Collection attributes = buildAttributesList(group, type, desc, owner, email, - bucketType, tags, true) - taskService.runTask(userContext, - "Update registered app ${name}, type ${type}, owner ${owner}, email ${email}", { task -> - awsSimpleDbService.save(domainName, name.toUpperCase(), attributes) - if (!tags) { - awsSimpleDbService.delete(domainName, name.toUpperCase(), [new Attribute().withName('tags')]) - } - }, Link.to(EntityType.application, name)) - getRegisteredApplication(userContext, name) - } + ApplicationModificationResult createRegisteredApplication(UserContext userContext, String name, String group, + String type, String description, String owner, + String email, MonitorBucketType monitorBucketType, + String tags) - void deleteRegisteredApplication(UserContext userContext, String name) { - Check.notEmpty(name, "name") - validateDelete(userContext, name) - taskService.runTask(userContext, "Delete registered app ${name}", { task -> - awsSimpleDbService.delete(domainName, name.toUpperCase()) - }, Link.to(EntityType.application, name)) - getRegisteredApplication(userContext, name) - } + ApplicationModificationResult updateRegisteredApplication(UserContext userContext, String name, String group, + String type, String desc, String owner, + String email, MonitorBucketType bucketType, + String tags) - private void validateDelete(UserContext userContext, String name) { - List objectsWithEntities = [] - if (awsAutoScalingService.getAutoScalingGroupsForApp(userContext, name)) { - objectsWithEntities.add('Auto Scaling Groups') - } - if (awsLoadBalancerService.getLoadBalancersForApp(userContext, name)) { - objectsWithEntities.add('Load Balancers') - } - if (awsEc2Service.getSecurityGroupsForApp(userContext, name)) { - objectsWithEntities.add('Security Groups') - } - if (mergedInstanceGroupingService.getMergedInstances(userContext, name)) { - objectsWithEntities.add('Instances') - } - if (fastPropertyService.getFastPropertiesByAppName(userContext, name)) { - objectsWithEntities.add('Fast Properties') - } - - if (objectsWithEntities) { - String referencesString = objectsWithEntities.join(', ') - String message = "${name} ineligible for delete because it still references ${referencesString}" - throw new ValidationException(message) - } - } + void deleteRegisteredApplication(UserContext userContext, String name) /** * Get the email address of the relevant app, or empty string if no email address can be found for the specified @@ -189,9 +50,7 @@ class ApplicationService implements CacheInitializer, InitializingBean { * @param appName the name of the app that has the email address * @return the email address associated with the app, or empty string if no email address can be found */ - String getEmailFromApp(UserContext userContext, String appName) { - getRegisteredApplication(userContext, appName)?.email ?: '' - } + String getEmailFromApp(UserContext userContext, String appName) /** * Provides a string to use for monitoring bucket, either provided an empty string, cluster name or app name based @@ -202,33 +61,22 @@ class ApplicationService implements CacheInitializer, InitializingBean { * @param clusterName value to return if the application's monitor bucket type is 'cluster' * @return appName or clusterName or empty string, based on the application's monitorBucketType */ - String getMonitorBucket(UserContext userContext, String appName, String clusterName) { - MonitorBucketType type = getRegisteredApplication(userContext, appName)?.monitorBucketType - type == MonitorBucketType.application ? appName : type == MonitorBucketType.cluster ? clusterName : '' - } + String getMonitorBucket(UserContext userContext, String appName, String clusterName) } /** - * Records the results of trying to create an Application. + * Records the results of trying to modify an Application. */ -class CreateApplicationResult { - String appName - Boolean appCreated - Exception appCreateException +class ApplicationModificationResult { + boolean successful + String message String toString() { - StringBuilder output = new StringBuilder() - if (appCreated) { - output.append("Application '${appName}' has been created. ") - } - if (appCreateException) { - output.append("Could not create Application '${appName}': ${appCreateException}. ") - } - output.toString() + return message } Boolean succeeded() { - appCreated && !appCreateException + return successful } } diff --git a/src/groovy/com/netflix/asgard/applications/AbstractApplicationService.groovy b/src/groovy/com/netflix/asgard/applications/AbstractApplicationService.groovy new file mode 100644 index 00000000..b9704f76 --- /dev/null +++ b/src/groovy/com/netflix/asgard/applications/AbstractApplicationService.groovy @@ -0,0 +1,53 @@ +/* + * Copyright 2014 Netflix, 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.asgard.applications + +import com.netflix.asgard.AppRegistration +import com.netflix.asgard.ApplicationService +import com.netflix.asgard.Relationships +import com.netflix.asgard.UserContext +import com.netflix.asgard.collections.GroupedAppRegistrationSet +import com.netflix.asgard.model.MonitorBucketType + +abstract class AbstractApplicationService implements ApplicationService { + @Override + final List getRegisteredApplicationsForLoadBalancer(UserContext userContext) { + new ArrayList(getRegisteredApplications(userContext).findAll { + Relationships.checkAppNameForLoadBalancer(it.name) + }) + } + + @Override + final GroupedAppRegistrationSet getGroupedRegisteredApplications(UserContext ctx) { + new GroupedAppRegistrationSet(getRegisteredApplications(ctx)) + } + + @Override + final AppRegistration getRegisteredApplicationForLoadBalancer(UserContext userContext, String name) { + Relationships.checkAppNameForLoadBalancer(name) ? getRegisteredApplication(userContext, name) : null + } + + @Override + final String getEmailFromApp(UserContext userContext, String appName) { + getRegisteredApplication(userContext, appName)?.email ?: '' + } + + @Override + final String getMonitorBucket(UserContext userContext, String appName, String clusterName) { + MonitorBucketType type = getRegisteredApplication(userContext, appName)?.monitorBucketType + type == MonitorBucketType.application ? appName : type == MonitorBucketType.cluster ? clusterName : '' + } +} diff --git a/src/groovy/com/netflix/asgard/applications/SimpleDBApplicationService.groovy b/src/groovy/com/netflix/asgard/applications/SimpleDBApplicationService.groovy new file mode 100644 index 00000000..296d4e1f --- /dev/null +++ b/src/groovy/com/netflix/asgard/applications/SimpleDBApplicationService.groovy @@ -0,0 +1,224 @@ +/* + * Copyright 2012 Netflix, 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.asgard.applications + +import com.amazonaws.AmazonServiceException +import com.amazonaws.services.simpledb.model.Attribute +import com.amazonaws.services.simpledb.model.Item +import com.amazonaws.services.simpledb.model.ReplaceableAttribute +import com.netflix.asgard.AppRegistration +import com.netflix.asgard.AwsAutoScalingService +import com.netflix.asgard.AwsClientService +import com.netflix.asgard.AwsEc2Service +import com.netflix.asgard.AwsLoadBalancerService +import com.netflix.asgard.AwsSimpleDbService +import com.netflix.asgard.Caches +import com.netflix.asgard.Check +import com.netflix.asgard.ConfigService +import com.netflix.asgard.ApplicationModificationResult +import com.netflix.asgard.EntityType +import com.netflix.asgard.FastPropertyService +import com.netflix.asgard.From +import com.netflix.asgard.Link +import com.netflix.asgard.MergedInstanceGroupingService +import com.netflix.asgard.TaskService +import com.netflix.asgard.UserContext +import com.netflix.asgard.ValidationException +import com.netflix.asgard.cache.CacheInitializer +import com.netflix.asgard.model.MonitorBucketType +import org.joda.time.DateTime +import org.springframework.beans.factory.InitializingBean +import org.springframework.beans.factory.annotation.Autowired + +class SimpleDBApplicationService extends AbstractApplicationService implements CacheInitializer, InitializingBean { + + static transactional = false + + /** The name of SimpleDB domain that stores the cloud application registry. */ + String domainName + + @Autowired + AwsAutoScalingService awsAutoScalingService + + @Autowired + AwsClientService awsClientService + + @Autowired + AwsEc2Service awsEc2Service + + @Autowired + AwsLoadBalancerService awsLoadBalancerService + + @Autowired + AwsSimpleDbService awsSimpleDbService + + @Autowired + Caches caches + + @Autowired + ConfigService configService + + @Autowired + FastPropertyService fastPropertyService + + @Autowired + MergedInstanceGroupingService mergedInstanceGroupingService + + @Autowired + TaskService taskService + + void afterPropertiesSet() { + domainName = configService.applicationsDomain + } + + void initializeCaches() { + caches.allApplications.ensureSetUp({ retrieveApplications() }) + } + + private Collection retrieveApplications() { + List items = awsSimpleDbService.selectAll(domainName).sort { it.name.toLowerCase() } + items.collect { AppRegistration.from(it) } + } + + @Override + List getRegisteredApplications(UserContext userContext) { + caches.allApplications.list().sort { it.name } + } + + @Override + AppRegistration getRegisteredApplication(UserContext userContext, String nameInput, From from = From.AWS) { + if (!nameInput) { + return null + } + String name = nameInput.toLowerCase() + if (from == From.CACHE) { + return caches.allApplications.get(name) + } + Item item = awsSimpleDbService.selectOne(domainName, name.toUpperCase()) + AppRegistration appRegistration = AppRegistration.from(item) + caches.allApplications.put(name, appRegistration) + appRegistration + } + + @Override + ApplicationModificationResult createRegisteredApplication(UserContext userContext, String nameInput, String group, + String type, String description, String owner, + String email, MonitorBucketType monitorBucketType, + String tags) { + String name = nameInput.toLowerCase() + + if (getRegisteredApplication(userContext, name)) { + return new ApplicationModificationResult( + successful: false, + message: "Can't add Application ${name}. It already exists." + ) + } + + String message = "Application '${name}' has been created." + boolean successful = true + + String nowEpoch = new DateTime().millis as String + Collection attributes = buildAttributesList(group, type, description, owner, email, + monitorBucketType, tags, false) + attributes << new ReplaceableAttribute('createTs', nowEpoch, false) + String creationLogMessage = "Create registered app ${name}, type ${type}, owner ${owner}, email ${email}" + taskService.runTask(userContext, creationLogMessage, { task -> + try { + awsSimpleDbService.save(domainName, name.toUpperCase(), attributes) + getRegisteredApplication(userContext, name) + } catch (AmazonServiceException e) { + message = "Could not create Application, reason: ${e}" + } + }, Link.to(EntityType.application, name)) + + return new ApplicationModificationResult(successful: successful, message: message) + } + + private static Collection buildAttributesList(String group, String type, String description, + String owner, String email, + MonitorBucketType monitorBucketType, + String tags, Boolean replaceExistingValues) { + + Check.notNull(monitorBucketType, MonitorBucketType, 'monitorBucketType') + String nowEpoch = new DateTime().millis as String + Collection attributes = [] + attributes << new ReplaceableAttribute('group', group ?: '', replaceExistingValues) + attributes << new ReplaceableAttribute('type', Check.notEmpty(type), replaceExistingValues) + attributes << new ReplaceableAttribute('description', Check.notEmpty(description), replaceExistingValues) + attributes << new ReplaceableAttribute('owner', Check.notEmpty(owner), replaceExistingValues) + attributes << new ReplaceableAttribute('email', Check.notEmpty(email), replaceExistingValues) + attributes << new ReplaceableAttribute('monitorBucketType', monitorBucketType.name(), replaceExistingValues) + attributes << new ReplaceableAttribute('updateTs', nowEpoch, replaceExistingValues) + if (tags) { + attributes << new ReplaceableAttribute('tags', tags, replaceExistingValues) + } + return attributes + } + + @Override + ApplicationModificationResult updateRegisteredApplication(UserContext userContext, String name, String group, + String type, String desc, String owner, String email, + MonitorBucketType bucketType, String tags) { + Collection attributes = buildAttributesList(group, type, desc, owner, email, + bucketType, tags, true) + + taskService.runTask(userContext, + "Update registered app ${name}, type ${type}, owner ${owner}, email ${email}", { task -> + awsSimpleDbService.save(domainName, name.toUpperCase(), attributes) + if (!tags) { + awsSimpleDbService.delete(domainName, name.toUpperCase(), [new Attribute().withName('tags')]) + } + getRegisteredApplication(userContext, name) + }, Link.to(EntityType.application, name)) + + return new ApplicationModificationResult(successful: true, message: "Application '${name}' has been updated.") + } + + @Override + void deleteRegisteredApplication(UserContext userContext, String name) { + Check.notEmpty(name, "name") + validateDelete(userContext, name) + taskService.runTask(userContext, "Delete registered app ${name}", { task -> + awsSimpleDbService.delete(domainName, name.toUpperCase()) + }, Link.to(EntityType.application, name)) + getRegisteredApplication(userContext, name) + } + + private void validateDelete(UserContext userContext, String name) { + List objectsWithEntities = [] + if (awsAutoScalingService.getAutoScalingGroupsForApp(userContext, name)) { + objectsWithEntities.add('Auto Scaling Groups') + } + if (awsLoadBalancerService.getLoadBalancersForApp(userContext, name)) { + objectsWithEntities.add('Load Balancers') + } + if (awsEc2Service.getSecurityGroupsForApp(userContext, name)) { + objectsWithEntities.add('Security Groups') + } + if (mergedInstanceGroupingService.getMergedInstances(userContext, name)) { + objectsWithEntities.add('Instances') + } + if (fastPropertyService.getFastPropertiesByAppName(userContext, name)) { + objectsWithEntities.add('Fast Properties') + } + + if (objectsWithEntities) { + String referencesString = objectsWithEntities.join(', ') + String message = "${name} ineligible for delete because it still references ${referencesString}" + throw new ValidationException(message) + } + } +} diff --git a/src/groovy/com/netflix/asgard/applications/SpinnakerApplicationService.groovy b/src/groovy/com/netflix/asgard/applications/SpinnakerApplicationService.groovy new file mode 100644 index 00000000..e2a5df88 --- /dev/null +++ b/src/groovy/com/netflix/asgard/applications/SpinnakerApplicationService.groovy @@ -0,0 +1,278 @@ +/* + * Copyright 2014 Netflix, 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.asgard.applications + +import com.netflix.asgard.AppRegistration +import com.netflix.asgard.AwsAutoScalingService +import com.netflix.asgard.AwsClientService +import com.netflix.asgard.AwsEc2Service +import com.netflix.asgard.AwsLoadBalancerService +import com.netflix.asgard.AwsSimpleDbService +import com.netflix.asgard.Caches +import com.netflix.asgard.Check +import com.netflix.asgard.ConfigService +import com.netflix.asgard.ApplicationModificationResult +import com.netflix.asgard.EntityType +import com.netflix.asgard.FastPropertyService +import com.netflix.asgard.From +import com.netflix.asgard.Link +import com.netflix.asgard.MergedInstanceGroupingService +import com.netflix.asgard.TaskService +import com.netflix.asgard.UserContext +import com.netflix.asgard.ValidationException +import com.netflix.asgard.cache.CacheInitializer +import com.netflix.asgard.model.MonitorBucketType +import com.netflix.spinnaker.client.Spinnaker +import com.netflix.spinnaker.client.model.Application +import com.netflix.spinnaker.client.model.TaskExecutionException +import org.apache.commons.logging.LogFactory +import org.springframework.beans.factory.annotation.Autowired + +class SpinnakerApplicationService extends AbstractApplicationService implements CacheInitializer { + private static final log = LogFactory.getLog(this) + + @Autowired + AwsAutoScalingService awsAutoScalingService + + @Autowired + AwsClientService awsClientService + + @Autowired + AwsEc2Service awsEc2Service + + @Autowired + AwsLoadBalancerService awsLoadBalancerService + + @Autowired + AwsSimpleDbService awsSimpleDbService + + @Autowired + Caches caches + + @Autowired + ConfigService configService + + @Autowired + FastPropertyService fastPropertyService + + @Autowired + MergedInstanceGroupingService mergedInstanceGroupingService + + @Autowired + TaskService taskService + + Spinnaker spinnaker + String accountName + + SpinnakerApplicationService(String spinnakerUrl, String accountName) { + this(Spinnaker.using(spinnakerUrl), accountName) + } + + private SpinnakerApplicationService(Spinnaker spinnaker, String accountName) { + log.info("Initializing SpinnakerApplicationService (account: ${accountName})") + + this.spinnaker = spinnaker + this.accountName = accountName + } + + void initializeCaches() { + caches.allApplications.ensureSetUp({ retrieveApplications() }) + } + + private Collection retrieveApplications() { + log.info("Retrieving all applications for account '${accountName}'") + spinnaker.applications().collect { convertToAppRegistration(it) }.findAll { it != null } + } + + @Override + List getRegisteredApplications(UserContext userContext) { + caches.allApplications.list().sort { it.name } + } + + @Override + AppRegistration getRegisteredApplication(UserContext userContext, String nameInput, From from = From.AWS) { + if (!nameInput) { + return null + } + String name = nameInput.toLowerCase() + if (from == From.CACHE) { + return caches.allApplications.get(name) + } + + AppRegistration appRegistration = convertToAppRegistration(spinnaker.application(name.toUpperCase())) + if (appRegistration) { + caches.allApplications.put(name, appRegistration) + } else { + caches.allApplications.remove(name) + } + + appRegistration + } + + @Override + ApplicationModificationResult createRegisteredApplication(UserContext userContext, String nameInput, String group, + String type, String description, String applicationOwner, + String email, MonitorBucketType monitorBucketType, + String tags) { + String name = nameInput.toLowerCase() + log.info("Creating Application (name: '${name}')") + + if (getRegisteredApplication(userContext, name)) { + return new ApplicationModificationResult( + successful: false, + message: "Can't add Application ${name}. It already exists." + ) + } + + String message = "Application '${name}' has been created." + boolean successful = true + + taskService.runTask( + userContext, + "Create registered app ${name}, type ${type}, owner ${applicationOwner}, email ${email}", + { task -> + try { + def tagSet = (tags ?: "").split(",").collect { it.trim().toLowerCase() } as Set + spinnaker.operations().application() + .withAccount(accountName) + .withName(name) + .withGroup(group) + .withType(type) + .withDescription(description) + .withOwner(applicationOwner) + .withEmail(email) + .withMonitorBucketType(monitorBucketType.name()) + .withTags(tagSet) + .saveAndGet() + getRegisteredApplication(userContext, name) + } catch (TaskExecutionException e) { + successful = false + message = "Could not create Application, reason: ${extractErrorReason(e)}" + } + }, + Link.to(EntityType.application, name) + ) + + return new ApplicationModificationResult(successful: successful, message: message) + } + + @Override + ApplicationModificationResult updateRegisteredApplication(UserContext userContext, String name, String group, + String type, String desc, String applicationOwner, + String email, MonitorBucketType bucketType, + String tags) { + String message = "Application '${name}' has been updated." + boolean successful = true + + taskService.runTask( + userContext, + "Update registered app ${name}, type ${type}, owner ${applicationOwner}, email ${email}", + { task -> + try { + def tagSet = (tags ?: "").split(",").collect { it.trim().toLowerCase() } as Set + spinnaker.application(name.toUpperCase()) + .withAccount(accountName) + .withName(name) + .withGroup(group) + .withType(type) + .withDescription(desc) + .withOwner(applicationOwner) + .withEmail(email) + .withMonitorBucketType(bucketType.name()) + .withTags(tagSet) + .saveAndGet() + getRegisteredApplication(userContext, name) + } catch (TaskExecutionException e) { + successful = false + message = "Could not update Application, reason: ${extractErrorReason(e)}" + } + }, + Link.to(EntityType.application, name) + ) + + return new ApplicationModificationResult(successful: successful, message: message) + } + + @Override + void deleteRegisteredApplication(UserContext userContext, String name) { + Check.notEmpty(name, "name") + validateDelete(userContext, name) + taskService.runTask(userContext, "Delete registered app ${name}", { task -> + spinnaker.application(name) + .withAccount(accountName) + .deleteAndGet() + getRegisteredApplication(userContext, name) + }, Link.to(EntityType.application, name)) + } + + private void validateDelete(UserContext userContext, String name) { + List objectsWithEntities = [] + if (awsAutoScalingService.getAutoScalingGroupsForApp(userContext, name)) { + objectsWithEntities.add('Auto Scaling Groups') + } + if (awsLoadBalancerService.getLoadBalancersForApp(userContext, name)) { + objectsWithEntities.add('Load Balancers') + } + if (awsEc2Service.getSecurityGroupsForApp(userContext, name)) { + objectsWithEntities.add('Security Groups') + } + if (mergedInstanceGroupingService.getMergedInstances(userContext, name)) { + objectsWithEntities.add('Instances') + } + if (fastPropertyService.getFastPropertiesByAppName(userContext, name)) { + objectsWithEntities.add('Fast Properties') + } + + if (objectsWithEntities) { + String referencesString = objectsWithEntities.join(', ') + String message = "${name} ineligible for delete because it still references ${referencesString}" + throw new ValidationException(message) + } + } + + private AppRegistration convertToAppRegistration(Application application) { + if (application?.metadata) { + def metadata = application.metadata + if (metadata.accounts.contains(accountName)) { + def monitorBucketType = MonitorBucketType.byName(metadata.monitorBucketType) + return new AppRegistration( + name: metadata.name, + group: metadata.group, + type: metadata.type, + description: metadata.description, + owner: metadata.owner, + email: metadata.email, + createTime: metadata.created, + updateTime: metadata.updated, + monitorBucketType: monitorBucketType ?: MonitorBucketType.defaultForOldApps, + tags: metadata.tags as List + ) + } + } + + return null + } + + private String extractErrorReason(TaskExecutionException e) { + def exceptionVariable = e.task.variables.find { it.key.toUpperCase().contains("EXCEPTION") } + def errors = exceptionVariable?.value?.details?.errors + if (!errors) { + throw e + } + + return errors.join(", ") + } +} diff --git a/src/groovy/com/netflix/asgard/mock/Mocks.groovy b/src/groovy/com/netflix/asgard/mock/Mocks.groovy index b8d51366..7c1f1b38 100644 --- a/src/groovy/com/netflix/asgard/mock/Mocks.groovy +++ b/src/groovy/com/netflix/asgard/mock/Mocks.groovy @@ -35,6 +35,7 @@ import com.netflix.asgard.AwsSqsService import com.netflix.asgard.CachedMapBuilder import com.netflix.asgard.Caches import com.netflix.asgard.ConfigService +import com.netflix.asgard.applications.SimpleDBApplicationService import com.netflix.asgard.userdata.DefaultUserDataProvider import com.netflix.asgard.DiscoveryService import com.netflix.asgard.DnsService @@ -172,16 +173,8 @@ class Mocks { private static ApplicationService applicationService static ApplicationService applicationService() { if (applicationService == null) { - MockUtils.mockLogging(ApplicationService, false) - applicationService = new ApplicationService() - applicationService.caches = caches() - applicationService.grailsApplication = grailsApplication() - applicationService.configService = configService() - applicationService.awsClientService = awsClientService() - applicationService.awsSimpleDbService = awsSimpleDbService() - List names = - ['abcache', 'api', 'aws_stats', 'cryptex', 'helloworld', 'ntsuiboot', 'videometadata'].asImmutable() + ['abcache', 'api', 'aws_stats', 'cryptex', 'helloworld', 'ntsuiboot', 'videometadata'].asImmutable() List apps = names.collect({ AppRegistration.from(item(it.toUpperCase())) }).asImmutable() // Populate map of names to apps @@ -189,11 +182,22 @@ class Mocks { apps.eachWithIndex { app, index -> namesToApps[names[index]] = app } namesToApps = namesToApps.asImmutable() - applicationService.metaClass.getRegisteredApplications = { UserContext userContext -> apps } - applicationService.metaClass.getRegisteredApplication = { UserContext userContext, String name -> - namesToApps[name] - } + MockUtils.mockLogging(SimpleDBApplicationService, false) + applicationService = new SimpleDBApplicationService() { + @Override + List getRegisteredApplications(UserContext userContext) { + return apps + } + @Override + AppRegistration getRegisteredApplication(UserContext userContext, String nameInput) { + return namesToApps[nameInput] + } + } + applicationService.caches = caches() + applicationService.configService = configService() + applicationService.awsClientService = awsClientService() + applicationService.awsSimpleDbService = awsSimpleDbService() applicationService.afterPropertiesSet() applicationService.initializeCaches() diff --git a/test/unit/com/netflix/asgard/ApplicationControllerSpec.groovy b/test/unit/com/netflix/asgard/ApplicationControllerSpec.groovy index 83d890c1..344032ab 100644 --- a/test/unit/com/netflix/asgard/ApplicationControllerSpec.groovy +++ b/test/unit/com/netflix/asgard/ApplicationControllerSpec.groovy @@ -30,7 +30,7 @@ class ApplicationControllerSpec extends Specification { setup: ApplicationService applicationService = Mock(ApplicationService) controller.applicationService = applicationService - def result = Mock(CreateApplicationResult) + def result = Mock(ApplicationModificationResult) result.succeeded() >> true params.name = "name" diff --git a/test/unit/com/netflix/asgard/ApplicationServiceUnitSpec.groovy b/test/unit/com/netflix/asgard/SimpleDBApplicationServiceUnitSpec.groovy similarity index 94% rename from test/unit/com/netflix/asgard/ApplicationServiceUnitSpec.groovy rename to test/unit/com/netflix/asgard/SimpleDBApplicationServiceUnitSpec.groovy index 7650a51a..80529213 100644 --- a/test/unit/com/netflix/asgard/ApplicationServiceUnitSpec.groovy +++ b/test/unit/com/netflix/asgard/SimpleDBApplicationServiceUnitSpec.groovy @@ -17,12 +17,13 @@ package com.netflix.asgard import com.amazonaws.AmazonServiceException import com.amazonaws.services.simpledb.model.Item +import com.netflix.asgard.applications.SimpleDBApplicationService import com.netflix.asgard.model.MonitorBucketType import spock.lang.Specification import spock.lang.Unroll @SuppressWarnings("GroovyAssignabilityCheck") -class ApplicationServiceUnitSpec extends Specification { +class SimpleDBApplicationServiceUnitSpec extends Specification { static final DOMAIN_NAME = 'CLOUD_APPLICATIONS' @@ -32,10 +33,10 @@ class ApplicationServiceUnitSpec extends Specification { def caches = new Caches(new MockCachedMapBuilder([ (EntityType.application): allApplications, ])) - ApplicationService applicationService + SimpleDBApplicationService applicationService void setup() { - applicationService = Spy(ApplicationService) + applicationService = Spy(SimpleDBApplicationService) applicationService.caches = caches applicationService.taskService = new TaskService() { def runTask(UserContext context, String name, Closure work, Link link = null, diff --git a/test/unit/com/netflix/asgard/SpinnakerApplicationServiceUnitSpec.groovy b/test/unit/com/netflix/asgard/SpinnakerApplicationServiceUnitSpec.groovy new file mode 100644 index 00000000..b36faaa9 --- /dev/null +++ b/test/unit/com/netflix/asgard/SpinnakerApplicationServiceUnitSpec.groovy @@ -0,0 +1,226 @@ +/* + * Copyright 2014 Netflix, 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.asgard + +import com.amazonaws.services.autoscaling.model.AutoScalingGroup +import com.netflix.asgard.applications.SpinnakerApplicationService +import com.netflix.asgard.model.MonitorBucketType +import com.netflix.spinnaker.client.Spinnaker +import com.netflix.spinnaker.client.model.ApplicationMetadata +import com.netflix.spinnaker.client.model.MutableApplication +import com.netflix.spinnaker.client.model.SpinnakerOperations +import com.netflix.spinnaker.client.model.TaskExecutionException +import spock.lang.Specification +import spock.lang.Subject +import spock.lang.Unroll + +@SuppressWarnings("GroovyAssignabilityCheck") +class SpinnakerApplicationServiceUnitSpec extends Specification { + def spinnaker = Mock(Spinnaker) + + @Subject + def applicationService = new SpinnakerApplicationService(spinnaker, "test") + + void setup() { + applicationService.awsAutoScalingService = Mock(AwsAutoScalingService) + applicationService.awsLoadBalancerService = Mock(AwsLoadBalancerService) + applicationService.awsEc2Service = Mock(AwsEc2Service) + applicationService.mergedInstanceGroupingService = Mock(MergedInstanceGroupingService) + applicationService.fastPropertyService = Mock(FastPropertyService) + applicationService.taskService = new TaskService() { + def runTask(UserContext context, String name, Closure work, Link link = null, Task existingTask = null) { + work(new Task()) + } + } + applicationService.caches = new Caches(new MockCachedMapBuilder([ + (EntityType.application): new CachedMapBuilder(null).of(EntityType.application).buildCachedMap(), + ])) + } + + @Unroll + void "should filter out applications not in the current account"() { + given: + def metadata = new ApplicationMetadata( + null, null, null, null, null, null, null, null, null, accounts, null, null + ) + + when: + def appRegistration = applicationService.convertToAppRegistration(new MutableApplication(null, metadata)) + + then: + appRegistration == expectedAppRegistration + + where: + accounts | expectedAppRegistration + ["prod"] as Set | null + ["prod", "test"] as Set | new AppRegistration(monitorBucketType: MonitorBucketType.none) + } + + void "should create application if it does not already exist"() { + given: + def mutableApplication = Mock(MutableApplication) + def spinnakerOperations = Mock(SpinnakerOperations) { + 1 * application() >> mutableApplication + } + + when: + def result = applicationService.createRegisteredApplication( + UserContext.auto(), name, group, type, description, owner, email, monitorBucketType, tags.join(",") + ) + + then: + 1 * spinnaker.operations() >> spinnakerOperations + 1 * mutableApplication.withAccount(applicationService.accountName) >> mutableApplication + 1 * mutableApplication.withName(name) >> mutableApplication + 1 * mutableApplication.withGroup(group) >> mutableApplication + 1 * mutableApplication.withType(type) >> mutableApplication + 1 * mutableApplication.withDescription(description) >> mutableApplication + 1 * mutableApplication.withOwner(owner) >> mutableApplication + 1 * mutableApplication.withEmail(email) >> mutableApplication + 1 * mutableApplication.withMonitorBucketType(monitorBucketType.name()) >> mutableApplication + 1 * mutableApplication.withTags(tags) >> mutableApplication + 1 * mutableApplication.saveAndGet() + + result.succeeded() + + where: + name = "app" + group = "group" + type = "type" + description = "description" + owner = "owner" + email = "email" + monitorBucketType = MonitorBucketType.none + tags = ["tag1", "tag2"] as Set + } + + void "should surface an error message if create application fails"() { + when: + def result = applicationService.createRegisteredApplication( + UserContext.auto(), "app", null, null, null, null, null, null, null + ) + + then: + 1 * spinnaker.operations() >> { throw new TaskExecutionException("Error", taskWithErrors()) } + + result.message == "Could not create Application, reason: Error1, Error2" + !result.succeeded() + } + + void "should update application if it already exists"() { + given: + def mutableApplication = Mock(MutableApplication) + + when: + def result = applicationService.updateRegisteredApplication( + UserContext.auto(), name, group, type, description, owner, email, monitorBucketType, tags.join(",") + ) + + then: + 2 * spinnaker.application(name.toUpperCase()) >> mutableApplication + 1 * mutableApplication.withAccount(applicationService.accountName) >> mutableApplication + 1 * mutableApplication.withName(name) >> mutableApplication + 1 * mutableApplication.withGroup(group) >> mutableApplication + 1 * mutableApplication.withType(type) >> mutableApplication + 1 * mutableApplication.withDescription(description) >> mutableApplication + 1 * mutableApplication.withOwner(owner) >> mutableApplication + 1 * mutableApplication.withEmail(email) >> mutableApplication + 1 * mutableApplication.withMonitorBucketType(monitorBucketType.name()) >> mutableApplication + 1 * mutableApplication.withTags(tags) >> mutableApplication + 1 * mutableApplication.saveAndGet() + + result.succeeded() + + where: + name = "app" + group = "group" + type = "type" + description = "description" + owner = "owner" + email = "email" + monitorBucketType = MonitorBucketType.none + tags = ["tag1", "tag2"] as Set + } + + void "should surface an error message if update application fails"() { + when: + def result = applicationService.updateRegisteredApplication( + UserContext.auto(), appName, null, null, null, null, null, null, null + ) + + then: + 1 * spinnaker.application(appName.toUpperCase()) >> { + throw new TaskExecutionException("Error", taskWithErrors()) + } + + result.message == "Could not update Application, reason: Error1, Error2" + !result.succeeded() + + where: + appName = "app" + } + + void "should delete application if validation passes"() { + given: + applicationService.caches.allApplications.put(appName, new AppRegistration()) + + and: + def mutableApplication = Mock(MutableApplication) + + when: + applicationService.deleteRegisteredApplication(UserContext.auto(Region.US_EAST_1), appName) + + then: + 1 * spinnaker.application(appName) >> mutableApplication + 1 * mutableApplication.withAccount(applicationService.accountName) >> mutableApplication + 1 * mutableApplication.deleteAndGet() + + applicationService.caches.allApplications.get(appName) == null + + where: + appName = "app" + } + + void "should not delete application if validation fails"() { + given: + applicationService.awsAutoScalingService = Mock(AwsAutoScalingService) { + 1 * getAutoScalingGroupsForApp(_, _) >> [ + new AutoScalingGroup() + ] + } + + when: + applicationService.deleteRegisteredApplication(UserContext.auto(Region.US_EAST_1), "app") + + then: + 0 * applicationService.taskService.runTask(_, _, _, _) + thrown(ValidationException) + } + + private taskWithErrors() { + Mock(com.netflix.spinnaker.client.model.Task) { + 1 * getVariables() >> [ + [ + key : "exception", + value: [ + details: [ + errors: [ "Error1", "Error2"] + ] + ]] + ] + } + } +} diff --git a/test/unit/com/netflix/asgard/userdata/NetflixAdvancedUserDataProviderSpec.groovy b/test/unit/com/netflix/asgard/userdata/NetflixAdvancedUserDataProviderSpec.groovy index 62e6922f..c4fce9bf 100644 --- a/test/unit/com/netflix/asgard/userdata/NetflixAdvancedUserDataProviderSpec.groovy +++ b/test/unit/com/netflix/asgard/userdata/NetflixAdvancedUserDataProviderSpec.groovy @@ -24,6 +24,7 @@ import com.netflix.asgard.MonkeyPatcherService import com.netflix.asgard.PluginService import com.netflix.asgard.Region import com.netflix.asgard.UserContext +import com.netflix.asgard.applications.SimpleDBApplicationService import com.netflix.asgard.model.AutoScalingGroupBeanOptions import com.netflix.asgard.model.LaunchConfigurationBeanOptions import com.netflix.asgard.model.LaunchContext @@ -119,7 +120,7 @@ class NetflixAdvancedUserDataProviderSpec extends Specification { launchContext.autoScalingGroup = new AutoScalingGroupBeanOptions(autoScalingGroupName: 'hi-dev-v001') launchContext.launchConfiguration = new LaunchConfigurationBeanOptions( launchConfigurationName: 'hi-dev-v001-1234567') - netflixAdvancedUserDataProvider.applicationService = Spy(ApplicationService) { + netflixAdvancedUserDataProvider.applicationService = Spy(SimpleDBApplicationService) { getRegisteredApplication(_, _) >> app }