From e487b0bb438928deb76a255a2b56df3368eb55cd Mon Sep 17 00:00:00 2001 From: jeyrs Date: Mon, 9 Jul 2018 18:24:38 -0700 Subject: [PATCH] feat(Images): Adding support for scanning Images - added `OrphanedImageRule` to look for unused images - rule looks to see if image is referenced by instances & launch configs - looks to see if the current image has siblings in other accounts --- .../swabbie/aws/images/AmazonImage.kt | 34 ++ .../swabbie/aws/images/AmazonImageHandler.kt | 269 +++++++++++++ .../spinnaker/swabbie/aws/images/Rules.kt | 52 +++ .../swabbie/aws/instances/AmazonInstance.kt | 32 ++ .../LaunchConfiguration.kt | 32 ++ .../aws/images/AmazonImageHandlerTest.kt | 371 ++++++++++++++++++ .../spinnaker/swabbie/edda/EddaService.kt | 21 + .../edda/providers/EddaAmazonImageProvider.kt | 83 ++++ .../edda/providers/EddaInstanceProvider.kt | 83 ++++ .../EddaLaunchConfigurationProvider.kt | 81 ++++ 10 files changed, 1058 insertions(+) create mode 100644 swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/AmazonImage.kt create mode 100644 swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/AmazonImageHandler.kt create mode 100644 swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/Rules.kt create mode 100644 swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/instances/AmazonInstance.kt create mode 100644 swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/launchconfigurations/LaunchConfiguration.kt create mode 100644 swabbie-aws/src/test/java/com/netflix/spinnaker/swabbie/aws/images/AmazonImageHandlerTest.kt create mode 100644 swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaAmazonImageProvider.kt create mode 100644 swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaInstanceProvider.kt create mode 100644 swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaLaunchConfigurationProvider.kt diff --git a/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/AmazonImage.kt b/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/AmazonImage.kt new file mode 100644 index 00000000..e88442a2 --- /dev/null +++ b/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/AmazonImage.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2018 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.spinnaker.swabbie.aws.images + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.netflix.spinnaker.swabbie.aws.model.AmazonResource +import com.netflix.spinnaker.swabbie.model.AWS +import com.netflix.spinnaker.swabbie.model.IMAGE + +@JsonTypeName("amazonImage") +data class AmazonImage( + val imageId: String, + val ownerId: String?, + val description: String?, + val state: String, + override val resourceId: String = imageId, + override val resourceType: String = IMAGE, + override val cloudProvider: String = AWS, + override val name: String? +) : AmazonResource() diff --git a/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/AmazonImageHandler.kt b/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/AmazonImageHandler.kt new file mode 100644 index 00000000..27004e33 --- /dev/null +++ b/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/AmazonImageHandler.kt @@ -0,0 +1,269 @@ +/* + * Copyright 2018 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.spinnaker.swabbie.aws.images + +import com.netflix.spectator.api.Registry +import com.netflix.spinnaker.kork.core.RetrySupport +import com.netflix.spinnaker.moniker.frigga.FriggaReflectiveNamer +import com.netflix.spinnaker.swabbie.* +import com.netflix.spinnaker.swabbie.aws.autoscalinggroups.checkStatusDelay +import com.netflix.spinnaker.swabbie.aws.instances.AmazonInstance +import com.netflix.spinnaker.swabbie.aws.launchconfigurations.AmazonLaunchConfiguration +import com.netflix.spinnaker.swabbie.echo.Notifier +import com.netflix.spinnaker.swabbie.exclusions.ResourceExclusionPolicy +import com.netflix.spinnaker.swabbie.model.* +import com.netflix.spinnaker.swabbie.orca.OrcaJob +import com.netflix.spinnaker.swabbie.orca.OrcaService +import com.netflix.spinnaker.swabbie.orca.OrchestrationRequest +import kotlinx.coroutines.experimental.Deferred +import kotlinx.coroutines.experimental.async +import kotlinx.coroutines.experimental.delay +import kotlinx.coroutines.experimental.runBlocking +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component +import java.time.Clock +import java.util.* +import kotlin.system.measureTimeMillis + +@Component +class AmazonImageHandler( + registry: Registry, + clock: Clock, + notifier: Notifier, + resourceTrackingRepository: ResourceTrackingRepository, + resourceOwnerResolver: ResourceOwnerResolver, + exclusionPolicies: List, + applicationEventPublisher: ApplicationEventPublisher, + lockingService: Optional, + retrySupport: RetrySupport, + private val rules: List>, + private val imageProvider: ResourceProvider, + private val instanceProvider: ResourceProvider, + private val launchConfigurationProvider: ResourceProvider, + private val accountProvider: AccountProvider, + private val orcaService: OrcaService +) : AbstractResourceTypeHandler( + registry, + clock, + rules, + resourceTrackingRepository, + exclusionPolicies, + resourceOwnerResolver, + notifier, + applicationEventPublisher, + lockingService, + retrySupport +) { + override fun deleteMarkedResource(markedResource: MarkedResource, workConfiguration: WorkConfiguration) { + orcaService.orchestrate( + OrchestrationRequest( + application = FriggaReflectiveNamer().deriveMoniker(markedResource).app ?: "swabbie", + job = listOf( + OrcaJob( + type = "deleteImage", + context = mutableMapOf( + "credentials" to workConfiguration.account.name, + "imageId" to markedResource.resource.resourceId, + "cloudProvider" to markedResource.resource.cloudProvider, + "regions" to listOf(workConfiguration.location) + ) + ) + ), + description = "Deleting Image Id:${markedResource.resourceId} Name: ${markedResource.name}" + ) + ).let { taskResponse -> + // "ref": "/tasks/01CK1Y63QFEP4ETC6P5DARECV6" + val taskId = taskResponse.ref.substring(taskResponse.ref.lastIndexOf("/") + 1) + var taskStatus = orcaService.getTask(taskId).status + runBlocking { + while (taskStatus.isIncomplete()) { + delay(checkStatusDelay) + taskStatus = orcaService.getTask(taskId).status + } + } + + if (!taskStatus.isSuccess()) { + throw FailedDeleteException("Failed to delete Image ${markedResource.resourceId}") + } + } + } + + override fun handles(workConfiguration: WorkConfiguration): Boolean = + workConfiguration.resourceType == IMAGE && workConfiguration.cloudProvider == AWS && !rules.isEmpty() + + override fun getCandidates(workConfiguration: WorkConfiguration): List? { + val params = Parameters( + mapOf("account" to workConfiguration.account.accountId!!, "region" to workConfiguration.location) + ) + + return imageProvider.getAll(params).also { images -> + log.info("Got {} images. Checking references", images?.size) + checkReferences(images, params) + } + } + + /** + * Checks references for: + * 1. Instances. + * 2. Launch Configurations. + * 3. Image siblings (Image sharing the same name) in other accounts/regions. + * Bubbles up any raised exception. + */ + private fun checkReferences(images: List?, params: Parameters) { + if (images == null || images.isEmpty()) { + return + } + + val elapsedTimeMillis = measureTimeMillis { + runBlocking { + listOf( + setUsedByInstancesAsync(images, params), + setUsedByLaunchConfigurationsAsync(images, params), + setHasSiblingsAsync(images, params) + ).forEach { checkingReferencesTask -> + try { + checkingReferencesTask.await() + } finally { + if (checkingReferencesTask.isCompletedExceptionally) { + val throwable = checkingReferencesTask.getCompletionExceptionOrNull() + log.error("Failed to check images references. Params: {}", params, throwable) + throw IllegalStateException("Unable to process ${images.size} images. Params: $params", throwable) + } + } + } + } + } + + log.info("Completed checking references for {} images in $elapsedTimeMillis ms. Params: {}", images.size, params) + } + + /** + * Checks if images are used by instances + */ + private fun setUsedByInstancesAsync( + images: List, + params: Parameters + ): Deferred = async { + instanceProvider.getAll(params).let { instances -> + log.info("Checking for references in {} instances", instances?.size) + if (instances == null || instances.isEmpty()) { + return@async + } + + images.filter { + USED_BY_INSTANCES !in it.details + }.forEach { image -> + onMatchedImages(instances.map { it.imageId }, image) { + image.set(USED_BY_INSTANCES, true) + } + } + } + } + + /** + * Checks if images are used by launch configurations + */ + private fun setUsedByLaunchConfigurationsAsync( + images: List, + params: Parameters + ): Deferred = async { + launchConfigurationProvider.getAll(params).let { launchConfigurations -> + log.info("Checking for references in {} launch configurations", launchConfigurations?.size) + if (launchConfigurations == null || launchConfigurations.isEmpty()) { + return@async + } + + images.filter { + USED_BY_LAUNCH_CONFIGURATIONS !in it.details + }.forEach { image -> + onMatchedImages(launchConfigurations.map { it.imageId }, image) { + image.set(USED_BY_LAUNCH_CONFIGURATIONS, true) + } + } + } + } + + /** + * Checks if images have siblings in other accounts + */ + private fun setHasSiblingsAsync( + images: List, + params: Parameters + ) = async { + log.info("Checking for sibling images.") + val imagesInOtherAccounts = getImagesFromOtherAccounts(params) + images.filter { + HAS_SIBLINGS_IN_OTHER_ACCOUNTS !in it.details + }.forEach { image -> + async { + for (pair in imagesInOtherAccounts) { + if (pair.first.matches(image)) { + image.set(HAS_SIBLINGS_IN_OTHER_ACCOUNTS, true) + break + } + } + } + } + } + + /** + * Get images in accounts. Used to check for siblings in those accounts. + */ + private fun getImagesFromOtherAccounts( + params: Parameters + ): List> { + val result: MutableList> = mutableListOf() + accountProvider.getAccounts().filter { + it.type == AWS && it.accountId != params["account"] + }.forEach { account -> + account.regions?.forEach { region -> + log.info("Looking for other images in {}/{}", account.accountId, region) + imageProvider.getAll( + Parameters(mapOf("account" to account.accountId!!, "region" to region.name)) + )?.forEach { image -> + result.add(Pair(image, account)) + } + } + } + + return result + } + + private fun onMatchedImages(ids: List, image: AmazonImage, onFound: () -> Unit) { + ids.forEach { + if (image.imageId == it) { + onFound.invoke() + } + } + } + + override fun getCandidate(markedResource: MarkedResource, workConfiguration: WorkConfiguration): AmazonImage? { + val params = Parameters(mapOf( + "imageId" to markedResource.resourceId, + "account" to workConfiguration.account.accountId!!, + "region" to workConfiguration.location) + ) + + return imageProvider.getOne(params)?.also { image -> + checkReferences(listOf(image), params) + } + } +} + +private fun AmazonImage.matches(image: AmazonImage): Boolean { + return name == image.name || imageId == image.imageId +} diff --git a/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/Rules.kt b/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/Rules.kt new file mode 100644 index 00000000..74e3af89 --- /dev/null +++ b/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/images/Rules.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2018 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.spinnaker.swabbie.aws.images + +import com.netflix.spinnaker.swabbie.model.Result +import com.netflix.spinnaker.swabbie.model.Rule +import com.netflix.spinnaker.swabbie.model.Summary +import org.springframework.stereotype.Component + +@Component +class OrphanedImageRule : Rule { + override fun apply(resource: AmazonImage): Result { + val isReferencedByInstances = resource.details.containsKey(USED_BY_INSTANCES) && + resource.details[USED_BY_INSTANCES] as Boolean + + val isReferencedByLaunchConfigs = resource.details.containsKey(USED_BY_LAUNCH_CONFIGURATIONS) && + resource.details[USED_BY_LAUNCH_CONFIGURATIONS] as Boolean + + val hasSiblings = resource.details.containsKey(HAS_SIBLINGS_IN_OTHER_ACCOUNTS) && + resource.details[HAS_SIBLINGS_IN_OTHER_ACCOUNTS] as Boolean + + if (isReferencedByInstances || isReferencedByLaunchConfigs || hasSiblings) { + return Result(null) + } + + return Result( + Summary( + description = "Image is not referenced by an Instance, Launch Configuration, " + + "and has no siblings in other accounts", + ruleName = javaClass.simpleName + ) + ) + } +} + +const val USED_BY_INSTANCES = "usedByInstances" +const val USED_BY_LAUNCH_CONFIGURATIONS = "usedByLaunchConfigurations" +const val HAS_SIBLINGS_IN_OTHER_ACCOUNTS = "hasSiblingsInOtherAccounts" diff --git a/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/instances/AmazonInstance.kt b/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/instances/AmazonInstance.kt new file mode 100644 index 00000000..e5eb1657 --- /dev/null +++ b/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/instances/AmazonInstance.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2018 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.spinnaker.swabbie.aws.instances + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.netflix.spinnaker.swabbie.aws.model.AmazonResource +import com.netflix.spinnaker.swabbie.model.AWS +import com.netflix.spinnaker.swabbie.model.INSTANCE + +@JsonTypeName("amazonInstance") +data class AmazonInstance( + private val instanceId: String, + val imageId: String, + override val resourceId: String = instanceId, + override val resourceType: String = INSTANCE, + override val cloudProvider: String = AWS, + override val name: String = instanceId +) : AmazonResource() diff --git a/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/launchconfigurations/LaunchConfiguration.kt b/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/launchconfigurations/LaunchConfiguration.kt new file mode 100644 index 00000000..39c3eecc --- /dev/null +++ b/swabbie-aws/src/main/kotlin/com/netflix/spinnaker/swabbie/aws/launchconfigurations/LaunchConfiguration.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2018 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.spinnaker.swabbie.aws.launchconfigurations + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.netflix.spinnaker.swabbie.aws.model.AmazonResource +import com.netflix.spinnaker.swabbie.model.AWS +import com.netflix.spinnaker.swabbie.model.LAUNCH_CONFIGURATION + +@JsonTypeName("amazonLaunchConfiguration") +data class AmazonLaunchConfiguration( + val imageId: String, + private val launchConfigurationName: String, + override val resourceId: String = launchConfigurationName, + override val resourceType: String = LAUNCH_CONFIGURATION, + override val cloudProvider: String = AWS, + override val name: String = launchConfigurationName +) : AmazonResource() diff --git a/swabbie-aws/src/test/java/com/netflix/spinnaker/swabbie/aws/images/AmazonImageHandlerTest.kt b/swabbie-aws/src/test/java/com/netflix/spinnaker/swabbie/aws/images/AmazonImageHandlerTest.kt new file mode 100644 index 00000000..c4785425 --- /dev/null +++ b/swabbie-aws/src/test/java/com/netflix/spinnaker/swabbie/aws/images/AmazonImageHandlerTest.kt @@ -0,0 +1,371 @@ +/* + * Copyright 2018 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.spinnaker.swabbie.aws.images + +import com.natpryce.hamkrest.equalTo +import com.natpryce.hamkrest.should.shouldMatch +import com.netflix.spectator.api.NoopRegistry +import com.netflix.spinnaker.config.* +import com.netflix.spinnaker.kork.core.RetrySupport +import com.netflix.spinnaker.swabbie.* +import com.netflix.spinnaker.swabbie.aws.instances.AmazonInstance +import com.netflix.spinnaker.swabbie.aws.launchconfigurations.AmazonLaunchConfiguration +import com.netflix.spinnaker.swabbie.events.DeleteResourceEvent +import com.netflix.spinnaker.swabbie.events.MarkResourceEvent +import com.netflix.spinnaker.swabbie.exclusions.AccountExclusionPolicy +import com.netflix.spinnaker.swabbie.exclusions.LiteralExclusionPolicy +import com.netflix.spinnaker.swabbie.exclusions.WhiteListExclusionPolicy +import com.netflix.spinnaker.swabbie.model.* +import com.netflix.spinnaker.swabbie.orca.OrcaExecutionStatus +import com.netflix.spinnaker.swabbie.orca.OrcaService +import com.netflix.spinnaker.swabbie.orca.TaskDetailResponse +import com.netflix.spinnaker.swabbie.orca.TaskResponse +import com.nhaarman.mockito_kotlin.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.context.ApplicationEventPublisher +import java.time.Clock +import java.util.* +import org.mockito.Mockito.validateMockitoUsage +import java.time.Duration +import java.time.Instant + +object AmazonImageHandlerTest { + private val front50ApplicationCache = mock>() + private val accountProvider = mock() + private val resourceRepository = mock() + private val resourceOwnerResolver = mock>() + private val clock = Clock.systemDefaultZone() + private val applicationEventPublisher = mock() + private val lockingService = Optional.empty() + private val orcaService = mock() + private val imageProvider = mock>() + private val instanceProvider = mock>() + private val launchConfigurationProvider = mock>() + private val subject = AmazonImageHandler( + clock = clock, + registry = NoopRegistry(), + notifier = mock(), + rules = listOf(OrphanedImageRule()), + resourceTrackingRepository = resourceRepository, + exclusionPolicies = listOf( + LiteralExclusionPolicy(), + WhiteListExclusionPolicy(front50ApplicationCache, accountProvider) + ), + resourceOwnerResolver = resourceOwnerResolver, + applicationEventPublisher = applicationEventPublisher, + + lockingService = lockingService, + retrySupport = RetrySupport(), + imageProvider = imageProvider, + instanceProvider = instanceProvider, + launchConfigurationProvider = launchConfigurationProvider, + orcaService = orcaService, + accountProvider = accountProvider + ) + + @BeforeEach + fun setup() { + whenever(front50ApplicationCache.get()) doReturn + setOf( + Application(name = "testapp", email = "name@netflix.com"), + Application(name = "important", email = "test@netflix.com"), + Application(name = "random", email = "random@netflix.com") + ) + + whenever(accountProvider.getAccounts()) doReturn + setOf( + SpinnakerAccount( + name = "test", + accountId = "1234", + type = "aws", + edda = "http://edda", + regions = listOf(Region(name = "us-east-1")), + eddaEnabled = false + ), + SpinnakerAccount( + name = "prod", + accountId = "4321", + type = "aws", + edda = "http://edda", + regions = listOf(Region(name = "us-east-1")), + eddaEnabled = false + ) + ) + + val params = Parameters(mapOf("account" to "1234", "region" to "us-east-1")) + whenever(imageProvider.getAll(params)) doReturn listOf( + AmazonImage( + imageId = "ami-123", + resourceId = "ami-123", + description = "ancestor_id=ami-123", + ownerId = null, + state = "available", + resourceType = IMAGE, + cloudProvider = AWS, + name = "123-xenial-hvm-sriov-ebs" + ), + AmazonImage( + imageId = "ami-132", + resourceId = "ami-132", + description = "description 132", + ownerId = null, + state = "available", + resourceType = IMAGE, + cloudProvider = AWS, + name = "132-xenial-hvm-sriov-ebs" + ) + ) + } + + @AfterEach + fun cleanup() { + validateMockitoUsage() + reset(imageProvider, accountProvider, instanceProvider, launchConfigurationProvider, applicationEventPublisher) + } + + @Test + fun `should handle work for images`() { + Assertions.assertTrue(subject.handles(getWorkConfiguration())) + } + + @Test + fun `should find image cleanup candidates`() { + subject.getCandidates(getWorkConfiguration()).let { images -> + // we get the entire collection of images + images!!.size shouldMatch equalTo(2) + } + } + + @Test + fun `should fail to get candidates if checking launch configuration references fails`() { + whenever(launchConfigurationProvider.getAll( + Parameters(mapOf("account" to "1234", "region" to "us-east-1")) + )) doThrow IllegalStateException("launch configs") + + Assertions.assertThrows(IllegalStateException::class.java, { + subject.getCandidates(getWorkConfiguration()) + }) + } + + @Test + fun `should fail to get candidates if checking instance references fails`() { + whenever(instanceProvider.getAll( + Parameters(mapOf("account" to "1234", "region" to "us-east-1")) + )) doThrow IllegalStateException("failed to get instances") + + Assertions.assertThrows(IllegalStateException::class.java, { + subject.getCandidates(getWorkConfiguration()) + }) + } + + @Test + fun `should fail to get candidates if checking for siblings fails because of accounts`() { + whenever(accountProvider.getAccounts()) doThrow IllegalStateException("failed to get accounts") + Assertions.assertThrows(IllegalStateException::class.java, { + subject.getCandidates(getWorkConfiguration()) + }) + } + + @Test + fun `should fail to get candidates if checking for siblings in other accounts fails`() { + whenever(imageProvider.getAll( + Parameters(mapOf("account" to "4321", "region" to "us-east-1")) + )) doThrow IllegalStateException("failed to get images in 4321/us-east-1") + + Assertions.assertThrows(IllegalStateException::class.java, { + subject.getCandidates(getWorkConfiguration()) + }) + } + + @Test + fun `should find cleanup candidates, apply exclusion policies on them and mark them`() { + val workConfiguration = getWorkConfiguration( + exclusionList = mutableListOf( + Exclusion() + .withType(ExclusionType.Whitelist.toString()) + .withAttributes( + listOf( + Attribute() + .withKey("imageId") + .withValue( + listOf("ami-123") // will exclude anything else not matching this imageId + ) + ) + ) + ) + ) + + subject.getCandidates(workConfiguration).let { images -> + // we get the entire collection of images + images!!.size shouldMatch equalTo(2) + } + + subject.mark(workConfiguration, postMark= { print("Done") }) + + // ami-132 is excluded by exclusion policies, specifically because ami-123 is not whitelisted + verify(applicationEventPublisher, times(1)).publishEvent( + check { event -> + Assertions.assertTrue(event.markedResource.resourceId == "ami-123") + Assertions.assertEquals(event.markedResource.projectedDeletionStamp.inDays(), 2) + } + ) + + verify(resourceRepository, times(1)).upsert(any(), any()) + } + + @Test + fun `should not mark images referenced by other resources`() { + val workConfiguration = getWorkConfiguration( + exclusionList = mutableListOf( + Exclusion() + .withType(ExclusionType.Whitelist.toString()) + .withAttributes( + listOf( + Attribute() + .withKey("imageId") + .withValue( + listOf("ami-123") // will exclude anything else not matching this imageId + ) + ) + ) + ) + ) + + whenever(instanceProvider.getAll(any())) doReturn + listOf( + AmazonInstance( + instanceId = "i-123", + cloudProvider = AWS, + imageId = "ami-123" // reference to ami-123 + ) + ) + + subject.getCandidates(workConfiguration).let { images -> + images!!.size shouldMatch equalTo(2) + Assertions.assertTrue(images.any { it.imageId == "ami-123" }) + Assertions.assertTrue(images.any { it.imageId == "ami-132" }) + } + + subject.mark(workConfiguration, { print {"postMark" } }) + + // ami-132 is excluded by exclusion policies, specifically because ami-132 is not whitelisted + // ami-123 is referenced by an instance, so therefore should not be marked for deletion + verify(applicationEventPublisher, times(0)).publishEvent(any()) + verify(resourceRepository, times(0)).upsert(any(), any()) + } + + @Test + fun `should delete images`() { + val fifteenDaysAgo = System.currentTimeMillis() - 15 * 24 * 60 * 60 * 1000L + val workConfiguration = getWorkConfiguration() + val image = AmazonImage( + imageId = "ami-123", + resourceId = "ami-123", + description = "ancestor_id=ami-123", + ownerId = null, + state = "available", + resourceType = IMAGE, + cloudProvider = AWS, + name = "123-xenial-hvm-sriov-ebs" + ) + + whenever(resourceRepository.getMarkedResourcesToDelete()) doReturn + listOf( + MarkedResource( + resource = image, + summaries = listOf(Summary("Image is unused", "testRule 1")), + namespace = workConfiguration.namespace, + resourceOwner = "test@netflix.com", + projectedDeletionStamp = fifteenDaysAgo, + notificationInfo = NotificationInfo( + recipient = "test@netflix.com", + notificationType = "Email", + notificationStamp = clock.millis() + ) + ) + ) + + val params = Parameters( + mapOf("account" to "1234", "region" to "us-east-1", "imageId" to "ami-123") + ) + + whenever(imageProvider.getOne(params)) doReturn listOf(image) + whenever(orcaService.orchestrate(any())) doReturn TaskResponse(ref = "/tasks/1234") + whenever(orcaService.getTask("1234")) doReturn + TaskDetailResponse( + id = "id", + application = "app", + buildTime = "1", + startTime = "1", + endTime = "2", + status = OrcaExecutionStatus.SUCCEEDED, + name = "delete blah" + ) + + subject.delete(workConfiguration, {}) + + verify(resourceRepository, times(1)).remove(any()) + verify(applicationEventPublisher, times(1)).publishEvent( + check { event -> + Assertions.assertTrue(event.markedResource.resourceId == "ami-123") + } + ) + + verify(orcaService, times(1)).orchestrate(any()) + } + + private fun Long.inDays(): Int + = Duration.between(Instant.ofEpochMilli(clock.millis()), Instant.ofEpochMilli(this)).toDays().toInt() + + private fun getWorkConfiguration( + isEnabled: Boolean = true, + dryRunMode: Boolean = false, + accountIds: List = listOf("test"), + regions: List = listOf("us-east-1"), + exclusionList: MutableList = mutableListOf() + ): WorkConfiguration { + val swabbieProperties = SwabbieProperties().apply { + dryRun = dryRunMode + providers = listOf( + CloudProviderConfiguration().apply { + name = "aws" + exclusions = mutableListOf() + accounts = accountIds + locations = regions + resourceTypes = listOf( + ResourceTypeConfiguration().apply { + name = "image" + enabled = isEnabled + dryRun = dryRunMode + exclusions = exclusionList + retentionDays = 2 + } + ) + } + ) + } + + return WorkConfigurator( + swabbieProperties = swabbieProperties, + accountProvider = accountProvider, + exclusionPolicies = listOf(AccountExclusionPolicy()) + ).generateWorkConfigurations()[0] + } +} diff --git a/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/EddaService.kt b/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/EddaService.kt index 8fd64359..83b776b7 100644 --- a/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/EddaService.kt +++ b/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/EddaService.kt @@ -17,6 +17,9 @@ package com.netflix.spinnaker.swabbie.edda import com.netflix.spinnaker.swabbie.aws.autoscalinggroups.AmazonAutoScalingGroup +import com.netflix.spinnaker.swabbie.aws.images.AmazonImage +import com.netflix.spinnaker.swabbie.aws.instances.AmazonInstance +import com.netflix.spinnaker.swabbie.aws.launchconfigurations.AmazonLaunchConfiguration import com.netflix.spinnaker.swabbie.aws.loadbalancers.AmazonElasticLoadBalancer import com.netflix.spinnaker.swabbie.aws.securitygroups.AmazonSecurityGroup import retrofit.http.GET @@ -43,4 +46,22 @@ interface EddaService { @GET("/api/v2/aws/autoScalingGroups;_expand") fun getAutoScalingGroups(): List + + @GET("/api/v2/aws/images;_expand:(imageId,name,description,state,tags)") + fun getImages(): List + + @GET("/api/v2/aws/images/{imageId}") + fun getImage(@Path("imageId") imageId: String): AmazonImage + + @GET("/api/v2/view/instances/{instanceId}") + fun getInstance(@Path("instanceId") instanceId: String): AmazonInstance + + @GET("/api/v2/view/instances;state.name=running,stopped,starting,rebooting;_expand:(instanceId,tags,imageId,state:(name))") + fun getInstances(): List + + @GET("/api/v2/aws/launchConfigurations;_expand:(launchConfigurationName,imageId)") + fun getLaunchConfigs(): List + + @GET("/api/v2/aws/launchConfigurations/{launchConfigurationName}") + fun getLaunchConfig(@Path("launchConfigurationName") launchConfigurationName: String): AmazonLaunchConfiguration } diff --git a/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaAmazonImageProvider.kt b/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaAmazonImageProvider.kt new file mode 100644 index 00000000..b4d30561 --- /dev/null +++ b/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaAmazonImageProvider.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2018 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.spinnaker.swabbie.edda.providers + +import com.netflix.spectator.api.Registry +import com.netflix.spinnaker.config.EddaApiClient +import com.netflix.spinnaker.kork.core.RetrySupport +import com.netflix.spinnaker.swabbie.Parameters +import com.netflix.spinnaker.swabbie.ResourceProvider +import com.netflix.spinnaker.swabbie.aws.images.AmazonImage +import com.netflix.spinnaker.swabbie.edda.EddaService +import com.netflix.spinnaker.swabbie.model.IMAGE +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import retrofit.RetrofitError + +@Component +open class EddaAmazonImageProvider( + private val eddaApiClients: List, + private val retrySupport: RetrySupport, + private val registry: Registry +) : ResourceProvider, EddaApiSupport(eddaApiClients, registry) { + private val log: Logger = LoggerFactory.getLogger(javaClass) + + override fun getAll(params: Parameters): List? { + withEddaClient(region = params["region"] as String, accountId = params["account"] as String).run { + return getAmis() + } + } + + override fun getOne(params: Parameters): AmazonImage? { + withEddaClient(region = params["region"] as String, accountId = params["account"] as String).run { + return getAmi(params["imageId"] as String) + } + } + + private fun EddaService.getAmis(): List { + return try { + retrySupport.retry({ + this.getImages() + }, maxRetries, retryBackOffMillis, true) + } catch (e: Exception) { + registry.counter(eddaFailureCountId.withTags("resourceType", IMAGE)).increment() + log.error("failed to get images", e) + throw e + } + } + + private fun EddaService.getAmi(imageId: String): AmazonImage? { + return try { + retrySupport.retry({ + try { + this.getImage(imageId) + } catch (e: Exception) { + if (e is RetrofitError && e.response.status == 404) { + null + } else { + throw e + } + } + }, maxRetries, retryBackOffMillis, false) + } catch (e: Exception) { + registry.counter(eddaFailureCountId.withTags("resourceType", IMAGE)).increment() + log.error("failed to get image {}", imageId, e) + throw e + } + } +} diff --git a/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaInstanceProvider.kt b/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaInstanceProvider.kt new file mode 100644 index 00000000..408f56b4 --- /dev/null +++ b/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaInstanceProvider.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2018 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.spinnaker.swabbie.edda.providers + +import com.netflix.spectator.api.Registry +import com.netflix.spinnaker.config.EddaApiClient +import com.netflix.spinnaker.kork.core.RetrySupport +import com.netflix.spinnaker.swabbie.Parameters +import com.netflix.spinnaker.swabbie.ResourceProvider +import com.netflix.spinnaker.swabbie.aws.instances.AmazonInstance +import com.netflix.spinnaker.swabbie.edda.EddaService +import com.netflix.spinnaker.swabbie.model.INSTANCE +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import retrofit.RetrofitError + +@Component +open class EddaInstanceProvider( + eddaApiClients: List, + private val retrySupport: RetrySupport, + private val registry: Registry +) : ResourceProvider, EddaApiSupport(eddaApiClients, registry) { + private val log: Logger = LoggerFactory.getLogger(javaClass) + override fun getAll(params: Parameters): List? { + withEddaClient(region = params["region"] as String, accountId = params["account"] as String).run { + return getNonTerminatedInstances() + } + } + + override fun getOne(params: Parameters): AmazonInstance? { + withEddaClient(region = params["region"] as String, accountId = params["account"] as String).run { + return getSingleInstance(params["instanceId"] as String) + } + } + + private fun EddaService.getNonTerminatedInstances(): List { + return try { + retrySupport.retry({ + this.getInstances() + }, maxRetries, retryBackOffMillis, true) + } catch (e: Exception) { + registry.counter(eddaFailureCountId.withTags("resourceType", INSTANCE)).increment() + log.error("failed to get instances", e) + throw e + } + } + + private fun EddaService.getSingleInstance(instanceId: String): AmazonInstance? { + return try { + retrySupport.retry({ + try { + this.getInstance(instanceId) + } catch (e: Exception) { + if (e is RetrofitError && e.response.status == 404) { + null + } else { + throw e + } + } + }, maxRetries, retryBackOffMillis, false) + } catch (e: Exception) { + registry.counter( + eddaFailureCountId.withTags("resourceType", INSTANCE)).increment() + log.error("failed to get instance {}", instanceId, e) + throw e + } + } +} diff --git a/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaLaunchConfigurationProvider.kt b/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaLaunchConfigurationProvider.kt new file mode 100644 index 00000000..0be1bc97 --- /dev/null +++ b/swabbie-edda/src/main/kotlin/com/netflix/spinnaker/swabbie/edda/providers/EddaLaunchConfigurationProvider.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2018 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.spinnaker.swabbie.edda.providers + +import com.netflix.spectator.api.Registry +import com.netflix.spinnaker.config.EddaApiClient +import com.netflix.spinnaker.kork.core.RetrySupport +import com.netflix.spinnaker.swabbie.Parameters +import com.netflix.spinnaker.swabbie.ResourceProvider +import com.netflix.spinnaker.swabbie.aws.launchconfigurations.AmazonLaunchConfiguration +import com.netflix.spinnaker.swabbie.edda.EddaService +import com.netflix.spinnaker.swabbie.model.LAUNCH_CONFIGURATION +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import retrofit.RetrofitError + +@Component +open class EddaLaunchConfigurationProvider( + eddaApiClients: List, + private val retrySupport: RetrySupport, + private val registry: Registry +) : ResourceProvider, EddaApiSupport(eddaApiClients, registry) { + private val log: Logger = LoggerFactory.getLogger(javaClass) + override fun getAll(params: Parameters): List? = + withEddaClient(region = params["region"] as String, accountId = params["account"] as String).run { + return getLaunchConfigurations() + } + + override fun getOne(params: Parameters): AmazonLaunchConfiguration? { + withEddaClient(region = params["region"] as String, accountId = params["account"] as String).run { + return getLaunchConfiguration(params["launchConfigurationName"] as String) + } + } + + private fun EddaService.getLaunchConfigurations(): List { + return try { + retrySupport.retry({ + this.getLaunchConfigs() + }, maxRetries, retryBackOffMillis, true) + } catch (e: Exception) { + registry.counter(eddaFailureCountId.withTags("resourceType", LAUNCH_CONFIGURATION)).increment() + log.error("failed to get instances", e) + throw e + } + } + + private fun EddaService.getLaunchConfiguration(launchConfigurationName: String): AmazonLaunchConfiguration? { + return try { + retrySupport.retry({ + try { + this.getLaunchConfig(launchConfigurationName) + } catch (e: Exception) { + if (e is RetrofitError && e.response.status == 404) { + null + } else { + throw e + } + } + }, maxRetries, retryBackOffMillis, false) + } catch (e: Exception) { + registry.counter(eddaFailureCountId.withTags("resourceType", LAUNCH_CONFIGURATION)).increment() + log.error("failed to get launch config {}", launchConfigurationName, e) + throw e + } + } +}