backend/app/utils/AwsDiscovery.scala (177 lines of code) (raw):
package utils
import java.util.Locale
import com.amazonaws.services.ec2.model.{DescribeInstancesRequest, Filter, Instance}
import com.amazonaws.services.ec2.{AmazonEC2, AmazonEC2ClientBuilder}
import com.amazonaws.services.simplesystemsmanagement.model.GetParameterRequest
import com.amazonaws.services.simplesystemsmanagement.{AWSSimpleSystemsManagement, AWSSimpleSystemsManagementClientBuilder}
import com.amazonaws.util.EC2MetadataUtils
import com.typesafe.config.ConfigValueFactory.fromAnyRef
import org.apache.commons.lang3.StringUtils
import services.{AWSDiscoveryConfig, BucketConfig, Config, DatabaseAuthConfig, PostgresConfig}
import com.amazonaws.services.secretsmanager.{AWSSecretsManager, AWSSecretsManagerClientBuilder}
import com.amazonaws.services.secretsmanager.model.GetSecretValueRequest
import play.api.libs.json.Json
import scala.jdk.CollectionConverters._
import scala.util.Try
case class DiscoveryResult(updatedConfig: Config, jsonLoggingProperties: Map[String, String])
object AwsDiscovery extends Logging {
def build(config: Config, discoveryConfig: AWSDiscoveryConfig): DiscoveryResult = {
// We won't have an instance ID if running locally but against databases in S3
val maybeInstanceId = Option(EC2MetadataUtils.getInstanceId)
val AWSDiscoveryConfig(region, stack, app, stage, _, _) = discoveryConfig
val runningLocally = discoveryConfig.runningLocally.getOrElse(false)
val credentials = AwsCredentials(profile = if(runningLocally) { Some("investigations") } else { None })
val ec2Client = AmazonEC2ClientBuilder.standard().withCredentials(credentials).withRegion(region).build()
val ssmClient = AWSSimpleSystemsManagementClientBuilder.standard().withCredentials(credentials).withRegion(region).build()
val secretsManagerClient = AWSSecretsManagerClientBuilder.standard().withCredentials(credentials).withRegion(region).build()
logger.info(s"AWS discovery stack: $stack app: $app stage: $stage region: $region runningLocally: $runningLocally")
val updatedConfig = config.copy(
app = config.app.copy(
hideDownloadButton = false,
label = getLabel(stack)
),
auth = config.auth.copy(
provider = config.auth.provider match {
case db: DatabaseAuthConfig => db.copy(require2FA = true)
case other => other
}
),
s3 = config.s3.copy(
region = region,
buckets = buildBuckets(config.s3.buckets, stack, stage),
sseAlgorithm = Some("aws:kms"),
// these are determined using instance credentials
endpoint = None, accessKey = None, secretKey = None
),
elasticsearch = config.elasticsearch.copy(
hosts = if(runningLocally) {
List("http://localhost:19200")
} else {
buildElasticsearchHosts(stack, stage, ec2Client)
},
disableSniffing = Some(runningLocally)
),
postgres = getDbSecrets(stack, secretsManagerClient),
neo4j = config.neo4j.copy(
url = if(runningLocally) {
"bolt://localhost:17687"
} else {
buildNeo4jUrl(stack, stage, ec2Client)
},
password = readSSMParameter("neo4j/password", stack, stage, ssmClient)
),
// Using the instanceId as the worker name will allow us to break locks on terminated instances in the future
worker = maybeInstanceId.map { instanceId =>
config.worker.copy(
name = Some(instanceId))
}.getOrElse(config.worker),
transcribe = config.transcribe.copy(
whisperModelFilename = readSSMParameter("transcribe/modelFilename", stack, stage, ssmClient),
transcriptionOutputQueueUrl = readSSMParameter("transcribe/transcriptionOutputQueueUrl", stack, stage, ssmClient),
transcriptionServiceQueueUrl = readSSMParameter("transcribe/transcriptionServiceQueueUrl", stack, stage, ssmClient),
transcriptionOutputDeadLetterQueueUrl = readSSMParameter("transcribe/transcriptionOutputDeadLetterQueueUrl", stack, stage, ssmClient)
),
sqs = config.sqs.copy(endpoint = None),
underlying = config.underlying
.withValue("play.http.secret.key", fromAnyRef(readSSMParameter("pfi/playSecret", stack, stage, ssmClient)))
.withValue("pekko.actor.provider", fromAnyRef("local")) // disable Pekko clustering, we query EC2 directly
)
val jsonLoggingProperties = Map(
"stack" -> discoveryConfig.stack,
"app" -> discoveryConfig.app,
"stage" -> discoveryConfig.stage
) ++ maybeInstanceId.map { instanceId =>
Map("instanceId" -> instanceId)
}.getOrElse(Map.empty)
DiscoveryResult(updatedConfig, jsonLoggingProperties)
}
def getLabel(stack: String): Option[String] = {
stack.split("-").toList match {
case "pfi" :: stack :: Nil if stack != "giant" =>
Some(StringUtils.capitalize(stack))
case _ =>
None
}
}
def findRunningInstances(stack: String, app: String, stage: String, ec2Client: AmazonEC2): Iterable[Instance] = {
val request = new DescribeInstancesRequest().withFilters(
new Filter("tag:Stack").withValues(stack),
new Filter("tag:App").withValues(app),
new Filter("tag:Stage").withValues(stage),
new Filter("instance-state-name").withValues("running")
)
ec2Client
.describeInstances(request)
.getReservations.asScala
.flatMap(_.getInstances.asScala)
}
def isRiffRaffDeployRunning(stack: String, stage: String, ec2Client: AmazonEC2): Boolean = {
val request = new DescribeInstancesRequest().withFilters(
new Filter("tag:Stack").withValues(stack),
new Filter("tag:Stage").withValues(stage),
new Filter("tag:Magenta").withValues("Terminate"),
new Filter("instance-state-name").withValues("running")
)
ec2Client
.describeInstances(request)
.getReservations.asScala
.nonEmpty
}
private def buildElasticsearchHosts(stack: String, stage: String, ec2Client: AmazonEC2): List[String] = {
val instances = findRunningInstances(stack, app = "elasticsearch", stage, ec2Client).toList
val hosts = instances.map(_.getPrivateIpAddress).map("http://" + _ + ":9200")
logger.info(s"AWS discovery elasticsearch hosts: [${hosts.mkString(",")}]")
hosts
}
private def buildNeo4jUrl(stack: String, stage: String, ec2Client: AmazonEC2): String = {
findRunningInstances(stack, app = "neo4j", stage, ec2Client).toList match {
case instance :: Nil =>
val url = s"bolt://${instance.getPrivateIpAddress}:7687"
logger.info(s"AWS discovery neo4j url: $url")
url
case Nil =>
throw new IllegalStateException(s"Unable to find instance. stack=$stack app=neo4j stage=$stage")
case _ =>
throw new IllegalStateException(s"More than one instance. stack=$stack app=neo4j stage=$stage")
}
}
private def buildBuckets(before: BucketConfig, stack: String, stage: String): BucketConfig = {
val lowerCaseStage = stage.toLowerCase(Locale.UK)
val after = BucketConfig(
ingestion = s"$stack-${before.ingestion}-$lowerCaseStage",
deadLetter = s"$stack-${before.deadLetter}-$lowerCaseStage",
collections = s"$stack-${before.collections}-$lowerCaseStage",
preview = s"$stack-${before.preview}-$lowerCaseStage",
transcription = s"$stack-${before.transcription}-$lowerCaseStage"
)
logger.info(s"AWS discovery buckets: [${after.all.mkString(",")}]")
after
}
private def readSSMParameter(name: String, stack: String, stage: String, ssmClient: AWSSimpleSystemsManagement): String = {
val request = new GetParameterRequest()
.withName(s"/pfi/$stack/$stage/$name")
.withWithDecryption(true)
val response = ssmClient.getParameter(request)
response.getParameter.getValue
}
private def getDbSecrets(stack: String, secretsManagerClient: AWSSecretsManager): Option[PostgresConfig] = {
val secretStagePath = stack match {
case "pfi-giant" => Some("PROD")
case "pfi-playground" => Some("CODE")
case _ => None
}
secretStagePath.flatMap { stage =>
val secretId = s"pfi-giant-postgres-${stage}"
val getSecretValueRequest = new GetSecretValueRequest().withSecretId(secretId)
val result = Try {
val secret = secretsManagerClient.getSecretValue(getSecretValueRequest)
Json.parse(secret.getSecretString).asOpt[PostgresConfig].orElse {
logger.error(s"Unable to parse credentials retrieved from secret $secretId")
None
}
}.recover {
case error =>
logger.error(s"Could not fetch secret for $secretId", error)
None
}.get
result
}
}
}