Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow storing Avatars/Attachments in S3 #166

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ Dropping a requirement of a major version of a dependency is a new contract.
## [Unreleased]
[Unreleased]: https://github.com/atlassian/aws-infrastructure/compare/release-3.1.1...master

### Added
- Add `DataCenterFormula.Builder.jiraSharedStorageConfig` function to configure DC tests to store attachments/avatars in an S3 bucket.
- Add `JiraSharedStorageConfig`.
- Update AWSCli to support V2 and use V2.9.12 by default.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When 2 CLI versions dropped, this could become an implementation detail that could be skipped in changelog


## [3.1.2] - 2023-09-13
[3.1.2]: https://github.com/atlassian/aws-infrastructure/compare/release-3.1.1...release-3.1.2

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ internal class TemplateBuilder(
private val baseTemplateName: String
) {
private val logger: Logger = LogManager.getLogger(this::class.java)
private val template = readResourceText("aws/$baseTemplateName").replace("!Ref", "__Ref__")
private val template = readResourceText("aws/$baseTemplateName")
.replace("!Ref", "__Ref__")
.replace("''", "__empty_string__")

private val mapper = ObjectMapper(
YAMLFactory()
Expand Down Expand Up @@ -80,6 +82,7 @@ internal class TemplateBuilder(
.writerWithDefaultPrettyPrinter()
.writeValueAsString(mappedTemplate)
.replace("__Ref__", "!Ref")
.replace("__empty_string__", "''")

fun build() = toString()
.also { logger.debug("Transformed $baseTemplateName into: \n$it") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.atlassian.performance.tools.awsinfrastructure.api

import com.atlassian.performance.tools.aws.api.StorageLocation
import com.atlassian.performance.tools.awsinfrastructure.api.jira.StoppableNode
import com.atlassian.performance.tools.awsinfrastructure.jira.home.SharedHomeFormula
import com.atlassian.performance.tools.concurrency.api.submitWithLogContext
import com.atlassian.performance.tools.infrastructure.api.dataset.Dataset
import com.atlassian.performance.tools.ssh.api.Ssh
Expand Down Expand Up @@ -49,16 +50,18 @@ class CustomDatasetSource private constructor(
location: StorageLocation
): Dataset {
logger.info("Uploading dataset to '$location'...")

stopJira()
stopDockerContainers(database.host)
checkResourcesStoredInJiraHome(jiraHome)

val executor = Executors.newFixedThreadPool(
3,
ThreadFactoryBuilder()
.setNameFormat("s3-upload-thread-%d")
.build()
)

stopJira()
stopDockerContainers(database.host)

val jiraHomeUpload = executor.submitWithLogContext("jiraHome") {
val renamed = jiraHome.move(FileNames.JIRAHOME, Duration.ofMinutes(1))
try {
Expand Down Expand Up @@ -97,6 +100,23 @@ class CustomDatasetSource private constructor(
Ssh(host, connectivityPatience = 4).newConnection().use { it.execute("sudo docker stop \$(sudo docker ps -aq)") }
}

/**
* Checks to see if all data from the dataset is stored in the jira home. If not then the dataset cannot be saved
* as it would be missing data.
*
* @see com.atlassian.performance.tools.awsinfrastructure.api.jira.JiraSharedStorageConfig
*/
private fun checkResourcesStoredInJiraHome(jiraHome: RemoteLocation) {
Ssh(jiraHome.host, connectivityPatience = 4).newConnection().use {
if (it.safeExecute("test -f ${jiraHome.location}/${SharedHomeFormula.SOME_RESOURCES_STORED_IN_S3_FILENAME}").isSuccessful()) {
throw IllegalStateException(
"Cannot save dataset because some resources (eg. avatars, attachments) are stored in S3. " +
"To save a dataset, ensure all resources are stored in the Jira home."
)
}
}
}

override fun toString(): String {
return "CustomDatasetSource(jiraHome=$jiraHome, database=$database)"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ import java.util.concurrent.ConcurrentHashMap
*
* @since 2.15.0
*/
class AwsCli {
class AwsCli (val cliVersion: String = "2.9.12") {
Copy link
Author

@MatthewCochrane MatthewCochrane May 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed the default version to 2.9.12. The bulk copying in SharedHomeFormula.kt wasn't reliable with the V1 CLI because it only supports legacy retry mode not standard retry mode. The new code can install a V1 or V2 cli and I've tested both, though it uses 2.9.12 by default everywhere now which I'm not sure if you'll be ok with or not. Also note that I've added tests around this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's drop old AWS cli support, unnecessary complexity. Preserve one constructor without parameters AwsCli()

private val versionRegex = Regex("""([0-9]+)\.[0-9]+\.[0-9]+""")
init {
require( versionRegex.matches(cliVersion)) {
"$cliVersion is not a valid aws cli version string."
}
}

private companion object {
private val LOCKS = ConcurrentHashMap<String, Any>()
}
Expand All @@ -21,21 +28,50 @@ class AwsCli {
val lock = LOCKS.computeIfAbsent(ssh.getHost().ipAddress) { Object() }
synchronized(lock) {
val awsCliExecutionResult = ssh.safeExecute("aws --version", Duration.ofSeconds(30), Level.TRACE, Level.TRACE)
if (!awsCliExecutionResult.isSuccessful()) {
if (awsCliExecutionResult.isSuccessful()) {
val combinedOutput = "${awsCliExecutionResult.output}${awsCliExecutionResult.errorOutput}"
require(combinedOutput.contains("aws-cli/$cliVersion")) {
"Aws Cli version $cliVersion requested but different version is already installed: '${combinedOutput}'."
}
} else {
Ubuntu().install(ssh, listOf("zip", "python"), Duration.ofMinutes(3))
ssh.execute(
cmd = "curl --silent https://s3.amazonaws.com/aws-cli/awscli-bundle-1.15.51.zip -o awscli-bundle.zip",
timeout = Duration.ofSeconds(50)
)
ssh.execute("unzip -n -q awscli-bundle.zip")
ssh.execute(
cmd = "sudo ./awscli-bundle/install -i /usr/local/aws -b /usr/local/bin/aws",
timeout = Duration.ofSeconds(60)
)
val majorVersion = versionRegex.find(cliVersion)?.groupValues?.get(1)
when (majorVersion) {
"1" -> installV1Cli(ssh)
else -> installV2Cli(ssh)
}
}
}
}

private fun installV1Cli(ssh: SshConnection) {
ssh.execute(
cmd = "curl --silent https://s3.amazonaws.com/aws-cli/awscli-bundle-$cliVersion.zip -o awscli-bundle.zip",
timeout = Duration.ofSeconds(50)
)
ssh.execute("unzip -n -q awscli-bundle.zip")
ssh.execute(
cmd = "sudo ./awscli-bundle/install -i /usr/local/aws -b /usr/local/bin/aws",
timeout = Duration.ofSeconds(60)
)
}

/**
* Instructions for setting up a V2 CLI are from
* [link](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-version.html)
*/
private fun installV2Cli(ssh: SshConnection) {
ssh.execute(
cmd="curl --silent https://awscli.amazonaws.com/awscli-exe-linux-x86_64-$cliVersion.zip -o awscliv2.zip",
timeout = Duration.ofSeconds(50)
)
ssh.execute("unzip -n -q awscliv2.zip")
ssh.execute(
cmd = "sudo ./aws/install -i /usr/local/aws-cli -b /usr/local/bin",
timeout = Duration.ofSeconds(60)
)
}

fun download(
location: StorageLocation,
ssh: SshConnection,
Expand Down Expand Up @@ -98,4 +134,4 @@ class AwsCli {
timeout
)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.atlassian.performance.tools.awsinfrastructure.api.jira

import com.amazonaws.services.cloudformation.model.Parameter
import com.amazonaws.services.identitymanagement.model.GetInstanceProfileRequest
import com.atlassian.performance.tools.aws.api.*
import com.atlassian.performance.tools.awsinfrastructure.InstanceFilters
import com.atlassian.performance.tools.awsinfrastructure.TemplateBuilder
Expand All @@ -26,6 +27,7 @@ import com.atlassian.performance.tools.infrastructure.api.database.Database
import com.atlassian.performance.tools.infrastructure.api.distribution.ProductDistribution
import com.atlassian.performance.tools.infrastructure.api.jira.JiraHomeSource
import com.atlassian.performance.tools.infrastructure.api.jira.JiraNodeConfig
import com.atlassian.performance.tools.io.api.readResourceText
import com.atlassian.performance.tools.jvmtasks.api.TaskTimer.time
import com.atlassian.performance.tools.ssh.api.Ssh
import com.atlassian.performance.tools.ssh.api.SshHost
Expand All @@ -34,6 +36,7 @@ import org.apache.logging.log4j.CloseableThreadContext
import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.Logger
import java.time.Duration
import java.util.*
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
Expand All @@ -59,6 +62,7 @@ class DataCenterFormula private constructor(
private val databaseVolume: Volume,
private val accessRequester: AccessRequester,
private val adminPasswordPlainText: String,
private val jiraSharedStorageConfig: JiraSharedStorageConfig,
private val waitForUpgrades: Boolean
) : JiraFormula {
private val logger: Logger = LogManager.getLogger(this::class.java)
Expand Down Expand Up @@ -101,6 +105,7 @@ class DataCenterFormula private constructor(
roleProfile: String,
aws: Aws
): ProvisionedJira {
val s3StorageBucketName = "jpt-storage-${UUID.randomUUID()}"
val provisionedNetwork = NetworkFormula(investment, aws).reuseOrProvision(overriddenNetwork)
val network = provisionedNetwork.network
val template = TemplateBuilder("2-nodes-dc.yaml").adaptTo(configs)
Expand Down Expand Up @@ -142,6 +147,32 @@ class DataCenterFormula private constructor(
).provision()
}

if (jiraSharedStorageConfig.isAnyResourceStoredInS3()) {
val instanceProfileRoleArn = aws.iam.getInstanceProfile(
GetInstanceProfileRequest().withInstanceProfileName(roleProfile)
).instanceProfile.roles.first().arn
StackFormula(
investment = investment.copy(
// This stack contains a bucket with a lifecycle policy that expires the objects after one day. We
// want to wait for AWS to expire and delete the objects before the housekeeping plan tries to
// delete this stack as the large number of objects in the bucket will take a long time to delete
// and may cause the housekeeping plan to fail. AWS can take additional time to perform the deletion
// however there is no charge past the expiry time.
lifespan = Duration.ofDays(4)
),
aws = aws,
cloudformationTemplate = readResourceText("aws/object-storage.yaml"),
parameters = listOf(
Parameter()
.withParameterKey("S3StorageBucketName")
.withParameterValue(s3StorageBucketName),
Parameter()
.withParameterKey("BucketAccessRoleArn")
.withParameterValue(instanceProfileRoleArn)
)
).provision()
}

val uploadPlugins = executor.submitWithLogContext("upload plugins") {
apps.listFiles().forEach { pluginsTransport.upload(it) }
}
Expand Down Expand Up @@ -184,7 +215,9 @@ class DataCenterFormula private constructor(
pluginsTransport = pluginsTransport,
ip = sharedHomePrivateIp,
ssh = sharedHomeSsh,
computer = computer
computer = computer,
s3StorageBucketName = s3StorageBucketName,
jiraSharedStorageConfig = jiraSharedStorageConfig
).provision()
logger.info("Shared home is set up")
sharedHome
Expand Down Expand Up @@ -217,7 +250,10 @@ class DataCenterFormula private constructor(
),
nodeIndex = i,
sharedHome = sharedHome,
privateIpAddress = instance.privateIpAddress
privateIpAddress = instance.privateIpAddress,
s3StorageBucketName = s3StorageBucketName,
jiraSharedStorageConfig = jiraSharedStorageConfig,
awsRegion = aws.region.name
)
)
}
Expand Down Expand Up @@ -372,6 +408,7 @@ class DataCenterFormula private constructor(
private var databaseVolume: Volume = Volume(100)
private var accessRequester: AccessRequester = ForIpAccessRequester(LocalPublicIpv4Provider.Builder().build())
private var adminPasswordPlainText: String = "admin"
private var jiraSharedStorageConfig: JiraSharedStorageConfig = JiraSharedStorageConfig.Builder().build()
private var waitForUpgrades: Boolean = true

internal constructor(
Expand All @@ -392,6 +429,7 @@ class DataCenterFormula private constructor(
databaseVolume = formula.databaseVolume
accessRequester = formula.accessRequester
adminPasswordPlainText = formula.adminPasswordPlainText
jiraSharedStorageConfig = formula.jiraSharedStorageConfig
waitForUpgrades = formula.waitForUpgrades
}

Expand Down Expand Up @@ -420,6 +458,9 @@ class DataCenterFormula private constructor(

fun accessRequester(accessRequester: AccessRequester) = apply { this.accessRequester = accessRequester }

fun jiraSharedStorageConfig(jiraSharedStorageConfig: JiraSharedStorageConfig) : Builder =
apply { this.jiraSharedStorageConfig = jiraSharedStorageConfig }

/**
* Don't change when starting up multi-node Jira DC of version lower than 9.1.0
* See https://confluence.atlassian.com/jirakb/index-management-on-jira-start-up-1141500654.html for more details.
Expand All @@ -441,6 +482,7 @@ class DataCenterFormula private constructor(
databaseVolume = databaseVolume,
accessRequester = accessRequester,
adminPasswordPlainText = adminPasswordPlainText,
jiraSharedStorageConfig = jiraSharedStorageConfig,
waitForUpgrades = waitForUpgrades
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.atlassian.performance.tools.awsinfrastructure.api.jira

/**
* Configuration for how and where to store data in a Jira DC cluster.
*/
class JiraSharedStorageConfig private constructor(
val storeAvatarsInS3: Boolean,
val storeAttachmentsInS3: Boolean
){

fun isAnyResourceStoredInS3(): Boolean {
return storeAvatarsInS3 || storeAttachmentsInS3
}

override fun toString(): String {
return "JiraSharedStorageConfig(storeAvatarsInS3=$storeAvatarsInS3, storeAttachmentsInS3=$storeAttachmentsInS3)"
}

class Builder() {
private var storeAvatarsInS3: Boolean = false
private var storeAttachmentsInS3: Boolean = false

constructor(
jiraSharedStorageConfig: JiraSharedStorageConfig
) : this() {
storeAvatarsInS3 = jiraSharedStorageConfig.storeAvatarsInS3
storeAttachmentsInS3 = jiraSharedStorageConfig.storeAttachmentsInS3
}

fun storeAvatarsInS3(storeAvatarsInS3: Boolean) = apply { this.storeAvatarsInS3 = storeAvatarsInS3 }
fun storeAttachmentsInS3(storeAttachmentsInS3: Boolean) = apply { this.storeAttachmentsInS3 = storeAttachmentsInS3 }

fun build() = JiraSharedStorageConfig(
storeAvatarsInS3 = storeAvatarsInS3,
storeAttachmentsInS3 = storeAttachmentsInS3
)
}
}
Loading