diff --git a/.gitignore b/.gitignore index f06dfad6..3060674f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .gradle -build \ No newline at end of file +build +.idea \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb48b3b..3fb8d042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,13 @@ Dropping a requirement of a major version of a dependency is a new contract. ## [Unreleased] [Unreleased]: https://github.com/atlassian/infrastructure/compare/release-4.18.0...master +### Added +- `JiraUserPasswordOverridingDatabase` to support providing custom admin password during database setup [JPERF-729] + +[JPERF-729]: https://ecosystem.atlassian.net/browse/JPERF-729 + +### Deprecated +- `Database.setup(ssh: SshConnection): String` in favor of `Database.performSetup(ssh: SshConnection): DatabaseSetup` ## [4.18.0] - 2021-04-14 [4.18.0]: https://github.com/atlassian/infrastructure/compare/release-4.17.5...release-4.18.0 diff --git a/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/LicenseOverridingMysql.kt b/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/LicenseOverridingMysql.kt index e159901b..111acd64 100644 --- a/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/LicenseOverridingMysql.kt +++ b/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/LicenseOverridingMysql.kt @@ -7,8 +7,6 @@ import org.apache.logging.log4j.Logger import java.io.File import java.net.URI import java.nio.file.Files -import java.io.FileWriter -import java.io.BufferedWriter /** @@ -26,13 +24,12 @@ class LicenseOverridingMysql private constructor( @Deprecated(message = "Use the Builder and pass licenses as Files to reduce accidental leakage of the license") constructor( database: Database, - licenses: List) : this(database, LicenseCollection(licenses.map { + licenses: List + ) : this(database, LicenseCollection(licenses.map { createTempLicenseFile(it) })) - override fun setup( - ssh: SshConnection - ): String = database.setup(ssh) + override fun setup(ssh: SshConnection): String = database.setup(ssh) override fun start( jira: URI, @@ -89,4 +86,4 @@ internal fun createTempLicenseFile(license: String): File { .use { it.write(license) } return licenseFile -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/MySqlDatabase.kt b/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/MySqlDatabase.kt index 7746d94e..dfb899c5 100644 --- a/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/MySqlDatabase.kt +++ b/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/MySqlDatabase.kt @@ -17,6 +17,7 @@ class MySqlDatabase( private val source: DatasetPackage, private val maxConnections: Int ) : Database { + private val logger: Logger = LogManager.getLogger(this::class.java) private val image: DockerImage = DockerImage( @@ -36,13 +37,13 @@ class MySqlDatabase( ) override fun setup(ssh: SshConnection): String { - val mysqlData = source.download(ssh) + val mysqlDataLocation = source.download(ssh) image.run( ssh = ssh, - parameters = "-p 3306:3306 -v `realpath $mysqlData`:/var/lib/mysql", + parameters = "-p 3306:3306 -v `realpath $mysqlDataLocation`:/var/lib/mysql", arguments = "--skip-grant-tables --max_connections=$maxConnections" ) - return mysqlData + return mysqlDataLocation } override fun start(jira: URI, ssh: SshConnection) { diff --git a/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/CrowdEncryptedPasswordProvider.kt b/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/CrowdEncryptedPasswordProvider.kt new file mode 100644 index 00000000..e143d3d1 --- /dev/null +++ b/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/CrowdEncryptedPasswordProvider.kt @@ -0,0 +1,22 @@ +package com.atlassian.performance.tools.infrastructure.api.database.passwordoverride + +import com.atlassian.performance.tools.infrastructure.database.SshSqlClient +import com.atlassian.performance.tools.ssh.api.SshConnection + +class CrowdEncryptedPasswordProvider( + private val jiraDatabaseSchemaName: String, + private val passwordPlainText: String, + private val passwordEncryptedWithAtlassianSecurityPasswordEncoder: String, + private val sqlClient: SshSqlClient +) : JiraUserEncryptedPasswordProvider { + + override fun getEncryptedPassword(ssh: SshConnection): String { + val sqlResult = + sqlClient.runSql(ssh, "select attribute_value from ${jiraDatabaseSchemaName}.cwd_directory_attribute where attribute_name = 'user_encryption_method';").output + return when { + sqlResult.contains("plaintext") -> passwordPlainText + sqlResult.contains("atlassian-security") -> passwordEncryptedWithAtlassianSecurityPasswordEncoder + else -> throw RuntimeException("Unknown jira user password encryption type") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/JiraUserEncryptedPasswordProvider.kt b/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/JiraUserEncryptedPasswordProvider.kt new file mode 100644 index 00000000..88d4b8e0 --- /dev/null +++ b/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/JiraUserEncryptedPasswordProvider.kt @@ -0,0 +1,7 @@ +package com.atlassian.performance.tools.infrastructure.api.database.passwordoverride + +import com.atlassian.performance.tools.ssh.api.SshConnection + +interface JiraUserEncryptedPasswordProvider { + fun getEncryptedPassword(ssh: SshConnection): String +} diff --git a/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/JiraUserPasswordOverridingDatabase.kt b/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/JiraUserPasswordOverridingDatabase.kt new file mode 100644 index 00000000..91509f6f --- /dev/null +++ b/src/main/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/JiraUserPasswordOverridingDatabase.kt @@ -0,0 +1,82 @@ +package com.atlassian.performance.tools.infrastructure.api.database.passwordoverride + +import com.atlassian.performance.tools.infrastructure.api.database.Database +import com.atlassian.performance.tools.infrastructure.database.SshMysqlClient +import com.atlassian.performance.tools.infrastructure.database.SshSqlClient +import com.atlassian.performance.tools.ssh.api.SshConnection +import org.apache.logging.log4j.LogManager +import org.apache.logging.log4j.Logger +import java.net.URI + +class JiraUserPasswordOverridingDatabase private constructor( + private val databaseDelegate: Database, + private val sqlClient: SshSqlClient, + private val username: String, + private val schema: String, + private val userPasswordPlainText: String, + private val jiraUserEncryptedPasswordProvider: JiraUserEncryptedPasswordProvider +) : Database { + private val logger: Logger = LogManager.getLogger(this::class.java) + + override fun setup(ssh: SshConnection): String = databaseDelegate.setup(ssh) + + override fun start( + jira: URI, + ssh: SshConnection + ) { + databaseDelegate.start(jira, ssh) + val password = jiraUserEncryptedPasswordProvider.getEncryptedPassword(ssh) + sqlClient.runSql(ssh, "UPDATE ${schema}.cwd_user SET credential='$password' WHERE user_name='$username';") + logger.debug("Password for user '$username' updated to '${userPasswordPlainText}'") + } + + + class Builder( + private var databaseDelegate: Database, + private var plainTextPassword: String, + private var passwordEncrypted: String + ) { + private var sqlClient: SshSqlClient = SshMysqlClient() + private var schema: String = "jiradb" + private var username: String = "admin" + private var jiraUserEncryptedPasswordProvider: JiraUserEncryptedPasswordProvider? = null + + fun databaseDelegate(databaseDelegate: Database) = apply { this.databaseDelegate = databaseDelegate } + fun username(username: String) = apply { this.username = username } + fun plainTextPassword(userPasswordPlainText: String) = apply { this.plainTextPassword = userPasswordPlainText } + fun passwordEncrypted(userPasswordEncrypted: String) = apply { this.passwordEncrypted = userPasswordEncrypted } + fun sqlClient(sqlClient: SshSqlClient) = apply { this.sqlClient = sqlClient } + fun schema(jiraDatabaseSchemaName: String) = apply { this.schema = jiraDatabaseSchemaName } + fun jiraUserEncryptedPasswordProvider(jiraUserEncryptedPasswordProvider: JiraUserEncryptedPasswordProvider) = + apply { this.jiraUserEncryptedPasswordProvider = jiraUserEncryptedPasswordProvider } + + fun build() = JiraUserPasswordOverridingDatabase( + databaseDelegate = databaseDelegate, + sqlClient = sqlClient, + username = username, + userPasswordPlainText = plainTextPassword, + schema = schema, + jiraUserEncryptedPasswordProvider = jiraUserEncryptedPasswordProvider ?: CrowdEncryptedPasswordProvider( + jiraDatabaseSchemaName = schema, + passwordPlainText = plainTextPassword, + passwordEncryptedWithAtlassianSecurityPasswordEncoder = passwordEncrypted, + sqlClient = sqlClient + ) + ) + } + +} + +/** + * @param adminPasswordEncrypted Based on [retrieving-the-jira-administrator](https://confluence.atlassian.com/jira/retrieving-the-jira-administrator-192836.html) + * to encode the password in Jira format use [com.atlassian.crowd.password.encoder.AtlassianSecurityPasswordEncoder](https://docs.atlassian.com/atlassian-crowd/4.2.2/com/atlassian/crowd/password/encoder/AtlassianSecurityPasswordEncoder.html) + * from the [com.atlassian.crowd.crowd-password-encoders](https://mvnrepository.com/artifact/com.atlassian.crowd/crowd-password-encoders/4.2.2). + * + */ +fun Database.overrideAdminPassword(adminPasswordPlainText: String, adminPasswordEncrypted: String): JiraUserPasswordOverridingDatabase.Builder { + return JiraUserPasswordOverridingDatabase.Builder( + databaseDelegate = this, + plainTextPassword = adminPasswordPlainText, + passwordEncrypted = adminPasswordEncrypted + ) +} diff --git a/src/test/kotlin/com/atlassian/performance/tools/infrastructure/api/database/LicenseOverridingMysqlTest.kt b/src/test/kotlin/com/atlassian/performance/tools/infrastructure/api/database/LicenseOverridingMysqlTest.kt index c41a4867..b03f9f7a 100644 --- a/src/test/kotlin/com/atlassian/performance/tools/infrastructure/api/database/LicenseOverridingMysqlTest.kt +++ b/src/test/kotlin/com/atlassian/performance/tools/infrastructure/api/database/LicenseOverridingMysqlTest.kt @@ -1,7 +1,7 @@ package com.atlassian.performance.tools.infrastructure.api.database +import com.atlassian.performance.tools.infrastructure.mock.RememberingDatabase import com.atlassian.performance.tools.infrastructure.mock.RememberingSshConnection -import com.atlassian.performance.tools.ssh.api.SshConnection import org.assertj.core.api.Assertions.assertThat import org.junit.Test import java.io.File @@ -11,6 +11,8 @@ class LicenseOverridingMysqlTest { private val jira = URI("http://localhost/") + private fun Database.withLicenseString(licenses: List) = LicenseOverridingMysql.Builder(this).licenseStrings(licenses).build() + @Test fun shouldOverrideOneLicense() { val licenseStrings = listOf("the only license") @@ -18,7 +20,7 @@ class LicenseOverridingMysqlTest { testedDatabase.start(jira, ssh) - assertThat(underlyingDatabase.started) + assertThat(underlyingDatabase.isStarted) .`as`("underlying database started") .isTrue() assertSshCommands(ssh) @@ -76,10 +78,7 @@ class LicenseOverridingMysqlTest { val underlyingDatabase = RememberingDatabase() @Suppress("DEPRECATION") return DatabaseStartTest( - testedDatabase = LicenseOverridingMysql( - database = underlyingDatabase, - licenses = licenses - ), + testedDatabase = underlyingDatabase.withLicenseString(licenses), underlyingDatabase = underlyingDatabase, ssh = RememberingSshConnection() ) @@ -105,10 +104,7 @@ class LicenseOverridingMysqlTest { val underlyingDatabase = RememberingDatabase() @Suppress("DEPRECATION") return DatabaseStartTest( - testedDatabase = LicenseOverridingMysql - .Builder(underlyingDatabase) - .licenseStrings(licenses) - .build(), + testedDatabase = underlyingDatabase.withLicenseString(licenses), underlyingDatabase = underlyingDatabase, ssh = RememberingSshConnection() ) @@ -161,19 +157,4 @@ class LicenseOverridingMysqlTest { val underlyingDatabase: RememberingDatabase, val ssh: RememberingSshConnection ) - - private class RememberingDatabase : Database { - - var setup = false - var started = false - - override fun setup(ssh: SshConnection): String { - setup = true - return "." - } - - override fun start(jira: URI, ssh: SshConnection) { - started = true - } - } } diff --git a/src/test/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/CrowdEncryptedPasswordProviderTest.kt b/src/test/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/CrowdEncryptedPasswordProviderTest.kt new file mode 100644 index 00000000..da151e05 --- /dev/null +++ b/src/test/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/CrowdEncryptedPasswordProviderTest.kt @@ -0,0 +1,101 @@ +package com.atlassian.performance.tools.infrastructure.api.database.passwordoverride + +import com.atlassian.performance.tools.infrastructure.mock.MockSshSqlClient +import com.atlassian.performance.tools.infrastructure.mock.RememberingSshConnection +import com.atlassian.performance.tools.ssh.api.SshConnection +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test + +class CrowdEncryptedPasswordProviderTest { + private lateinit var sqlClient: MockSshSqlClient + private lateinit var sshConnection: RememberingSshConnection + private lateinit var tested: JiraUserEncryptedPasswordProvider + private val passwordPlainText = "abcde" + private val passwordEncrypted = "*****" + + @Before + fun setup() { + sqlClient = MockSshSqlClient() + sshConnection = RememberingSshConnection() + tested = CrowdEncryptedPasswordProvider( + jiraDatabaseSchemaName = "jiradb", + sqlClient = sqlClient, + passwordPlainText = passwordPlainText, + passwordEncryptedWithAtlassianSecurityPasswordEncoder = passwordEncrypted + ) + } + + @Test + fun shouldQueryEncryptionMethod() { + // given + sqlClient.queueReturnedSqlCommandResult( + SshConnection.SshResult( + exitStatus = 0, + output = """attribute_value + atlassian-security +""".trimMargin(), + errorOutput = "" + ) + ) + // when + tested.getEncryptedPassword(sshConnection) + // then + assertThat(sqlClient.getLog()) + .`as`("sql queries executed") + .containsExactly( + "select attribute_value from jiradb.cwd_directory_attribute where attribute_name = 'user_encryption_method';" + ) + } + + @Test + fun shouldThrowExceptionWhenUnknownEncryption() { + // when + var exception: RuntimeException? = null + try { + tested.getEncryptedPassword(sshConnection) + } catch (e: RuntimeException) { + exception = e + } + // then + assertThat(exception).isNotNull() + assertThat(exception!!.message).isEqualTo("Unknown jira user password encryption type") + } + + @Test + fun shouldReturnEncrypted() { + // given + sqlClient.queueReturnedSqlCommandResult( + SshConnection.SshResult( + exitStatus = 0, + output = """attribute_value + atlassian-security +""".trimMargin(), + errorOutput = "" + ) + ) + // when + val password = tested.getEncryptedPassword(sshConnection) + // then + assertThat(password).isEqualTo(passwordEncrypted) + } + + @Test + fun shouldReturnPlainText() { + // given + sqlClient.queueReturnedSqlCommandResult( + SshConnection.SshResult( + exitStatus = 0, + output = """attribute_value + plaintext +""".trimMargin(), + errorOutput = "" + ) + ) + // when + val password = tested.getEncryptedPassword(sshConnection) + // then + assertThat(password).isEqualTo(passwordPlainText) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/JiraUserPasswordOverridingDatabaseTest.kt b/src/test/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/JiraUserPasswordOverridingDatabaseTest.kt new file mode 100644 index 00000000..e01a3db0 --- /dev/null +++ b/src/test/kotlin/com/atlassian/performance/tools/infrastructure/api/database/passwordoverride/JiraUserPasswordOverridingDatabaseTest.kt @@ -0,0 +1,84 @@ +package com.atlassian.performance.tools.infrastructure.api.database.passwordoverride + +import com.atlassian.performance.tools.infrastructure.api.database.Database +import com.atlassian.performance.tools.infrastructure.mock.MockSshSqlClient +import com.atlassian.performance.tools.infrastructure.mock.RememberingDatabase +import com.atlassian.performance.tools.infrastructure.mock.RememberingSshConnection +import com.atlassian.performance.tools.ssh.api.SshConnection +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import java.net.URI + +class JiraUserPasswordOverridingDatabaseTest { + + private val jira = URI("http://localhost/") + private val samplePlainTextPassword = "plain text password" + private val expectedEncryptedPassword = "*******" + + data class TestContext( + val database: Database, + val underlyingDatabase: RememberingDatabase, + val sshConnection: RememberingSshConnection, + val sqlClient: MockSshSqlClient + ) + + fun setup(): TestContext { + val db = RememberingDatabase() + val sqlClient = MockSshSqlClient() + return TestContext( + underlyingDatabase = db, + sshConnection = RememberingSshConnection(), + sqlClient = sqlClient, + database = db + .overrideAdminPassword( + adminPasswordPlainText = samplePlainTextPassword, + adminPasswordEncrypted = expectedEncryptedPassword + ) + .sqlClient(sqlClient) + .schema("jira") + .jiraUserEncryptedPasswordProvider(object : JiraUserEncryptedPasswordProvider { + override fun getEncryptedPassword(ssh: SshConnection) = expectedEncryptedPassword + }) + .build() + ) + } + + @Test + fun shouldSetupUnderlyingDatabase() { + val testContext = setup() + with(testContext) { + // when + database.setup(sshConnection) + database.start(jira, sshConnection) + // then + assertThat(underlyingDatabase.isSetup).`as`("underlying database setup").isTrue() + } + } + + @Test + fun shouldStartUnderlyingDatabase() { + val testContext = setup() + with(testContext) { + // when + database.setup(sshConnection) + database.start(jira, sshConnection) + // then + assertThat(underlyingDatabase.isStarted).`as`("underlying database started").isTrue() + } + } + + @Test + fun shouldUpdatePassword() { + val testContext = setup() + with(testContext) { + // when + database.setup(sshConnection) + database.start(jira, sshConnection) + // then + assertThat(sqlClient.getLog()).`as`("sql queries executed").containsExactly( + "UPDATE jira.cwd_user SET credential='${expectedEncryptedPassword}' WHERE user_name='admin';" + ) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/atlassian/performance/tools/infrastructure/mock/MockSshSqlClient.kt b/src/test/kotlin/com/atlassian/performance/tools/infrastructure/mock/MockSshSqlClient.kt new file mode 100644 index 00000000..c8092914 --- /dev/null +++ b/src/test/kotlin/com/atlassian/performance/tools/infrastructure/mock/MockSshSqlClient.kt @@ -0,0 +1,34 @@ +package com.atlassian.performance.tools.infrastructure.mock + +import com.atlassian.performance.tools.infrastructure.database.SshSqlClient +import com.atlassian.performance.tools.ssh.api.SshConnection +import java.io.File +import java.util.* + +class MockSshSqlClient : SshSqlClient { + private val log = mutableListOf() + private val defaultSshResult = SshConnection.SshResult( + exitStatus = 0, + output = "", + errorOutput = "" + ) + private val returnedCommandResults = ArrayDeque() + + fun queueReturnedSqlCommandResult(result: SshConnection.SshResult) { + returnedCommandResults.add(result) + } + + override fun runSql( + ssh: SshConnection, + sql: String + ) = (if (returnedCommandResults.isEmpty()) defaultSshResult else returnedCommandResults.pop()) + .also { log.add(sql) } + + override fun runSql( + ssh: SshConnection, + sql: File + ) = (if (returnedCommandResults.isEmpty()) defaultSshResult else returnedCommandResults.pop()) + .also { log.add(sql.readText()) } + + fun getLog(): List = log +} \ No newline at end of file diff --git a/src/test/kotlin/com/atlassian/performance/tools/infrastructure/mock/RememberingDatabase.kt b/src/test/kotlin/com/atlassian/performance/tools/infrastructure/mock/RememberingDatabase.kt new file mode 100644 index 00000000..1e7138ac --- /dev/null +++ b/src/test/kotlin/com/atlassian/performance/tools/infrastructure/mock/RememberingDatabase.kt @@ -0,0 +1,20 @@ +package com.atlassian.performance.tools.infrastructure.mock + +import com.atlassian.performance.tools.infrastructure.api.database.Database +import com.atlassian.performance.tools.ssh.api.SshConnection +import java.net.URI + +class RememberingDatabase : Database { + + var isSetup = false + var isStarted = false + + override fun setup(ssh: SshConnection): String { + isSetup = true + return "." + } + + override fun start(jira: URI, ssh: SshConnection) { + isStarted = true + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/atlassian/performance/tools/infrastructure/mock/RememberinSshConnection.kt b/src/test/kotlin/com/atlassian/performance/tools/infrastructure/mock/RememberingSshConnection.kt similarity index 100% rename from src/test/kotlin/com/atlassian/performance/tools/infrastructure/mock/RememberinSshConnection.kt rename to src/test/kotlin/com/atlassian/performance/tools/infrastructure/mock/RememberingSshConnection.kt