app/components/AppComponents.scala (373 lines of code) (raw):
package components
import com.amazonaws.auth.profile.ProfileCredentialsProvider
import com.amazonaws.auth.{
AWSCredentialsProviderChain,
InstanceProfileCredentialsProvider
}
import com.amazonaws.regions.Regions
import com.amazonaws.retry.PredefinedRetryPolicies.SDKDefaultRetryCondition
import com.amazonaws.retry.{PredefinedRetryPolicies, RetryPolicy}
import com.amazonaws.services.ec2.{AmazonEC2, AmazonEC2ClientBuilder}
import com.amazonaws.services.s3.{AmazonS3, AmazonS3ClientBuilder}
import com.amazonaws.services.securitytoken.model.GetCallerIdentityRequest
import com.amazonaws.services.securitytoken.{
AWSSecurityTokenService,
AWSSecurityTokenServiceClientBuilder
}
import com.amazonaws.services.sns.AmazonSNSClientBuilder
import com.amazonaws.{
AmazonClientException,
AmazonWebServiceRequest,
ClientConfiguration
}
import com.google.auth.oauth2.ServiceAccountCredentials
import com.gu.googleauth.{
AntiForgeryChecker,
AuthAction,
GoogleAuthConfig,
GoogleGroupChecker
}
import com.gu.play.secretrotation.aws.parameterstore.{AwsSdkV2, SecretSupplier}
import com.gu.play.secretrotation.{
RotatingSecretComponents,
SnapshotProvider,
TransitionTiming
}
import com.gu.{AppIdentity, AwsIdentity, DevIdentity}
import controllers._
import data.{Dynamo, Recipes}
import event.{ActorSystemWrapper, BakeEvent, Behaviours}
import housekeeping._
import housekeeping.utils.{BakesRepo, PackerEC2Client}
import models.NotificationConfig
import notification.{LambdaDistributionBucket, NotificationSender, SNS}
import org.apache.pekko.actor.typed.ActorSystem
import org.apache.pekko.actor.{ActorSystem => UntypedActorSystem}
import org.quartz.Scheduler
import org.quartz.impl.StdSchedulerFactory
import packer.{PackerConfig, PackerRunner}
import play.api.ApplicationLoader.Context
import play.api.BuiltInComponentsFromContext
import play.api.i18n.I18nComponents
import play.api.libs.ws.ahc.AhcWSComponents
import play.api.mvc.{AnyContent, EssentialFilter}
import play.api.routing.Router
import play.filters.HttpFiltersComponents
import play.filters.csp.CSPComponents
import prism.Prism
import router.Routes
import schedule.{BakeScheduler, ScheduledBakeRunner}
import services.{AmiMetadataLookup, Loggable, PrismData}
import software.amazon.awssdk.auth.credentials.{
AwsCredentialsProviderChain => AwsCredentialsProviderChainV2,
InstanceProfileCredentialsProvider => InstanceProfileCredentialsProviderV2,
ProfileCredentialsProvider => ProfileCredentialsProviderV2
}
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.dynamodb.DynamoDbClient
import software.amazon.awssdk.services.sns.SnsAsyncClient
import software.amazon.awssdk.services.ssm.SsmClient
import java.io.FileInputStream
import java.time.Duration
import java.time.Duration.{ofHours, ofMinutes}
import scala.concurrent.Await
import scala.concurrent.duration._
import scala.language.postfixOps
import scala.util.Try
class LoggingRetryCondition extends SDKDefaultRetryCondition with Loggable {
private def exceptionInfo(e: Throwable): String = {
s"${e.getClass.getName} ${e.getMessage} Cause: ${Option(
e.getCause
).map(e => exceptionInfo(e))}"
}
override def shouldRetry(
originalRequest: AmazonWebServiceRequest,
exception: AmazonClientException,
retriesAttempted: Int
): Boolean = {
val willRetry =
super.shouldRetry(originalRequest, exception, retriesAttempted)
if (willRetry) {
log.warn(s"AWS SDK retry $retriesAttempted: ${Option(originalRequest)
.map(_.getClass.getName)} threw ${exceptionInfo(exception)}")
} else {
log.warn(s"Encountered fatal exception during AWS API call", exception)
Option(exception.getCause).foreach(t =>
log.warn(s"Cause of fatal exception", t)
)
}
willRetry
}
}
class AppComponents(context: Context, identity: AppIdentity)
extends BuiltInComponentsFromContext(context)
with AhcWSComponents
with I18nComponents
with Loggable
with AssetsComponents
with HttpFiltersComponents
with RotatingSecretComponents
with CSPComponents {
val stage = identity match {
case DevIdentity(_) => "DEV"
case AwsIdentity(_, _, stage, _) => stage
}
def mandatoryConfig(key: String): String = configuration
.get[Option[String]](key)
.getOrElse(sys.error(s"Missing config key: $key"))
val awsCredsForV1 = new AWSCredentialsProviderChain(
new ProfileCredentialsProvider("deployTools"),
new ProfileCredentialsProvider(),
InstanceProfileCredentialsProvider.getInstance()
)
val awsCredsForV2 = AwsCredentialsProviderChainV2
.builder()
.credentialsProviders(
ProfileCredentialsProviderV2.create("deployTools"),
ProfileCredentialsProviderV2.create(),
InstanceProfileCredentialsProviderV2.create()
)
.build()
val region = Regions.EU_WEST_1
val clientConfiguration = new ClientConfiguration().withRetryPolicy(
new RetryPolicy(
new LoggingRetryCondition(),
PredefinedRetryPolicies.DEFAULT_BACKOFF_STRATEGY,
20,
false
)
)
val secretStateSupplier: SnapshotProvider = {
new SecretSupplier(
TransitionTiming(usageDelay = ofMinutes(3), overlapDuration = ofHours(2)),
s"/$stage/deploy/amigo/play.http.secret.key",
AwsSdkV2(
SsmClient.builder
.credentialsProvider(awsCredsForV2)
.region(Region.of(region.getName))
.build()
)
)
}
implicit val dynamo: Dynamo = {
val dynamoClient: DynamoDbClient = DynamoDbClient
.builder()
.credentialsProvider(awsCredsForV2)
.region(Region.of(region.getName))
.build()
new Dynamo(dynamoClient, stage)
}
dynamo.initTables()
val awsAccount = {
val stsClient: AWSSecurityTokenService =
AWSSecurityTokenServiceClientBuilder.standard
.withCredentials(awsCredsForV1)
.withRegion(region)
.withClientConfiguration(clientConfiguration)
.build()
val result = stsClient.getCallerIdentity(new GetCallerIdentityRequest())
val amigoAwsAccount = result.getAccount
amigoAwsAccount
}
val ec2Client: AmazonEC2 = AmazonEC2ClientBuilder.standard
.withCredentials(awsCredsForV1)
.withRegion(region)
.withClientConfiguration(clientConfiguration)
.build()
val amiMetadataLookup: AmiMetadataLookup = new AmiMetadataLookup(ec2Client)
val prism = new Prism(wsClient)
val pekkoActorSystem = UntypedActorSystem.create("pekko")
val prismAgents = new PrismData(
prism,
applicationLifecycle,
pekkoActorSystem.scheduler,
environment
)
// do this synchronously at startup so we can set permissions
val accountNumbers: Seq[String] =
Await.result(prism.findAllAWSAccounts(), 30 seconds).map(_.accountNumber)
val sns: SNS = {
val snsClient = AmazonSNSClientBuilder.standard
.withRegion(region)
.withCredentials(awsCredsForV1)
.withClientConfiguration(clientConfiguration)
.build()
new SNS(snsClient, stage, accountNumbers)
}
val s3Client: AmazonS3 = AmazonS3ClientBuilder.standard
.withRegion(region)
.withCredentials(awsCredsForV1)
.withClientConfiguration(clientConfiguration)
.build()
val anghammaradSNSClient: SnsAsyncClient =
SnsAsyncClient
.builder()
.region(Region.of(region.getName))
.credentialsProvider(awsCredsForV2)
.build()
val amigoUrl: String = configuration
.get[Option[String]]("amigo.url")
.getOrElse(s"https://amigo.gutools.co.uk")
val anghammaradNotificationTopic: Option[String] =
configuration.get[Option[String]]("anghammarad.sns.topicArn")
val notificationConfig: Option[NotificationConfig] =
anghammaradNotificationTopic.map { t =>
NotificationConfig(amigoUrl, t, anghammaradSNSClient, stage)
}
configuration.get[Option[String]]("aws.distributionBucket").foreach {
bucketName =>
LambdaDistributionBucket
.updateBucketPolicy(s3Client, bucketName, stage, accountNumbers)
}
val sender: NotificationSender =
new NotificationSender(sns, region.getName, stage)
val eventBusActorSystem: ActorSystem[BakeEvent] = {
val eventListeners = Map(
"logWriter" -> Behaviours.writeToLog,
"dynamoWriter" -> Behaviours.persistBakeEvent(notificationConfig),
"snsWriter" -> Behaviours.sendAmiCreatedNotification(
sender.sendTopicMessage
)
)
ActorSystem[BakeEvent](Behaviours.guardian(eventListeners), "EventBus")
}
implicit val eventBus: ActorSystemWrapper = new ActorSystemWrapper(
eventBusActorSystem
)
val googleAuthConfig = GoogleAuthConfig(
clientId = mandatoryConfig("google.clientId"),
clientSecret = mandatoryConfig("google.clientSecret"),
redirectUrl = mandatoryConfig("google.redirectUrl"),
domains = List("guardian.co.uk"),
maxAuthAge = Some(Duration.ofDays(90)),
enforceValidity = true,
antiForgeryChecker = AntiForgeryChecker(secretStateSupplier)
)
implicit val packerConfig: PackerConfig = PackerConfig(
stage = stage,
vpcId = configuration.get[Option[String]]("packer.vpcId"),
subnetId = configuration.get[Option[String]]("packer.subnetId"),
instanceProfile =
configuration.get[Option[String]]("packer.instanceProfile"),
securityGroupId =
configuration.get[Option[String]]("packer.securityGroupId")
)
val ansibleVariables: Map[String, String] =
Map(
"s3_prefix" -> configuration.get[String]("ansible.packages.s3prefix")
) ++
configuration
.get[Option[String]]("ansible.packages.s3bucket")
.map("s3_bucket" ->)
val amigoDataBucket: Option[String] =
configuration.get[Option[String]]("amigo.data.bucket")
val packerRunner = new PackerRunner(
configuration.get[Int]("packer.maxInstances")
)
val quartzScheduler: Scheduler = StdSchedulerFactory.getDefaultScheduler()
val scheduledBakeRunner: ScheduledBakeRunner = {
val enabled = stage == "PROD" // don't run scheduled bakes on dev machines
new ScheduledBakeRunner(
stage,
enabled,
prismAgents,
eventBus,
ansibleVariables,
amiMetadataLookup,
amigoDataBucket,
packerRunner
)
}
val bakeScheduler = new BakeScheduler(quartzScheduler, scheduledBakeRunner)
log.info("Registering all scheduled bakes with the scheduler")
bakeScheduler.initialise(Recipes.list())
val bakesRepo = new BakesRepo(notificationConfig)
val packerEC2Client = new PackerEC2Client(ec2Client, stage)
val bakeDeletionFrequencyMinutes = 1
val houseKeepingJobs = List(
new BakeDeletion(
dynamo,
awsAccount,
prismAgents,
sender,
bakeDeletionFrequencyMinutes
),
new MarkOldUnusedBakesForDeletion(prismAgents, dynamo),
new MarkOrphanedBakesForDeletion(prismAgents, dynamo),
new TimeOutLongRunningBakes(bakesRepo, packerEC2Client),
new DeleteLongRunningEC2Instances(bakesRepo, packerEC2Client)
)
val housekeepingScheduler =
new HousekeepingScheduler(quartzScheduler, houseKeepingJobs)
housekeepingScheduler.initialise()
val debugAvailable = stage != "PROD"
// Membership in at least one of these groups is required to pass authentication.
val googleGroupsToCheck = Set(
configuration.get[String]("auth.google.departmentGroupId"),
configuration.get[String]("auth.google.dataScientistsGroupId")
)
val groupChecker = {
val serviceAccountCertPath =
configuration.get[String]("auth.google.serviceAccountCertPath")
val serviceAccountCert = Try(new FileInputStream(serviceAccountCertPath))
.getOrElse(
throw new RuntimeException(
s"Could not load service account JSON from $serviceAccountCertPath"
)
)
val serviceAccount =
ServiceAccountCredentials.fromStream(serviceAccountCert)
val impersonatedUser =
configuration.get[String]("auth.google.impersonatedUser")
new GoogleGroupChecker(impersonatedUser, serviceAccount)
}
/** Play 2.8's default is Seq(csrfFilter, securityHeadersFilter,
* allowedHostsFilter). The allowedHostsFilter is removed here as it causes
* healthchecks to fail. This service is not accessible on the public
* internet.
*
* We also enable cspFilter, as per
* https://www.playframework.com/documentation/2.8.x/CspFilter#Enabling-Through-Compile-Time
*/
override def httpFilters: Seq[EssentialFilter] =
Seq(csrfFilter, securityHeadersFilter, cspFilter)
val authAction = new AuthAction[AnyContent](
googleAuthConfig,
routes.Login.loginAction(),
controllerComponents.parsers.default
)(executionContext)
val rootController = new RootController(authAction, controllerComponents)
val baseImageController =
new BaseImageController(authAction, prismAgents, controllerComponents)
val housekeepingController =
new HousekeepingController(authAction, controllerComponents)
val roleController = new RoleController(authAction, controllerComponents)
val recipeController = new RecipeController(
authAction,
bakeScheduler,
prismAgents,
controllerComponents,
debugAvailable
)
val bakeController = new BakeController(
authAction,
stage,
prismAgents,
controllerComponents,
ansibleVariables,
debugAvailable,
amiMetadataLookup,
amigoDataBucket,
s3Client,
packerRunner,
bakeDeletionFrequencyMinutes
)
val loginController = new Login(
googleAuthConfig,
wsClient,
controllerComponents,
googleGroupsToCheck,
groupChecker
)
lazy val router: Router = new Routes(
httpErrorHandler,
rootController,
baseImageController,
housekeepingController,
roleController,
recipeController,
bakeController,
loginController,
assets
)
}