Skip to content

Commit

Permalink
Use provided credentials first
Browse files Browse the repository at this point in the history
  • Loading branch information
pawelpasterz committed Dec 23, 2020
1 parent f3f2455 commit 4f37042
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 32 deletions.
4 changes: 4 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,10 @@ to have a unique non-shared credential. A service account is still recommended f
Follow the [test lab docs](https://firebase.google.com/docs/test-lab/android/continuous) to create a service account.
- Save the credential to `$HOME/.config/gcloud/application_default_credentials.json` or set `GOOGLE_APPLICATION_CREDENTIALS` when using a custom path.
- Set the project id in flank.yml or set the `GOOGLE_CLOUD_PROJECT` environment variable.
- (Since 21.01) if `projectId` is not set in a config yml file, flank uses the first available project ID among the following sources:
1. The project ID specified in the JSON credentials file pointed by the GOOGLE_APPLICATION_CREDENTIALS environment variable [fladle](https://runningcode.github.io/fladle/configuration/#serviceaccountcredentials)
1. The project ID specified by the GOOGLE_CLOUD_PROJECT environment variable
1. The project ID specified in the JSON credentials file `$HOME/.config/gcloud/application_default_credentials.json`
For continuous integration, base64 encode the credential as `GCLOUD_KEY`. Then write the file using a shell script. Note that gcloud CLI does not need to be installed. Flank works without any dependency on gcloud CLI.
Expand Down
56 changes: 31 additions & 25 deletions test_runner/src/main/kotlin/ftl/args/ArgsHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import ftl.shard.createShardsByShardCount
import ftl.shard.shardCountByTime
import ftl.util.FlankTestMethod
import ftl.util.assertNotEmpty
import ftl.util.getGACPathOrEmpty
import java.io.File
import java.net.URI
import java.nio.file.Files
Expand All @@ -44,6 +45,8 @@ object ArgsHelper {
YamlObjectMapper().registerKotlinModule()
}

private var projectIdSource: String? = null

fun assertFileExists(file: String, name: String) {
if (!file.exist()) throw FlankGeneralError("'$file' $name doesn't exist")
}
Expand Down Expand Up @@ -137,7 +140,7 @@ object ArgsHelper {
// Make best effort to list/create the bucket.
// Due to permission issues, the user may not be able to list or create buckets.
fun createGcsBucket(projectId: String, bucket: String): String {
if (bucket.isBlank()) return GcToolResults.getDefaultBucket(projectId)
if (bucket.isBlank()) return GcToolResults.getDefaultBucket(projectId, projectIdSource)
?: throw FlankGeneralError("Failed to make bucket for $projectId")
if (useMock) return bucket

Expand Down Expand Up @@ -178,29 +181,29 @@ object ArgsHelper {
return bucket
}

private fun serviceAccountProjectId(): String? {
try {
if (!defaultCredentialPath.toFile().exists()) return null

return JsonObjectParser(JSON_FACTORY).parseAndClose(
Files.newInputStream(defaultCredentialPath),
Charsets.UTF_8,
GenericJson::class.java
)["project_id"] as String
} catch (e: Exception) {
logLn("Parsing $defaultCredentialPath failed:")
logLn(e.printStackTrace())
}

return null
}

fun getDefaultProjectId(): String? {
if (useMock) return "mockProjectId"

// Allow users control over project by checking using Google's logic first before falling back to JSON.
return ServiceOptions.getDefaultProjectId() ?: serviceAccountProjectId()
}
fun getDefaultProjectIdOrNull(): String? = if (useMock) "mockProjectId"
// Allow users control over project by checking using Google's logic first before falling back to JSON.
else fromUserProvidedCredentials()
?: ServiceOptions.getDefaultProjectId()?.let { if (it.isBlank()) null else it }
?: fromDefaultCredentials()

private fun fromDefaultCredentials() = getProjectIdFromJson(defaultCredentialPath)

private fun fromUserProvidedCredentials() =
getProjectIdFromJson(Paths.get(getGACPathOrEmpty()))

private fun getProjectIdFromJson(jsonPath: Path): String? = if (!jsonPath.toFile().exists()) null
else runCatching {
projectIdSource = jsonPath.toAbsolutePath().toString()
JsonObjectParser(JSON_FACTORY).parseAndClose(
Files.newInputStream(jsonPath),
Charsets.UTF_8,
GenericJson::class.java
)["project_id"] as String
}.onFailure {
logLn("Parsing $jsonPath failed:")
logLn(it.printStackTrace())
}.getOrNull()

// https://stackoverflow.com/a/2821201/2450315
private val envRegex = Pattern.compile("\\$([a-zA-Z_]+[a-zA-Z0-9_]*)")
Expand Down Expand Up @@ -232,7 +235,10 @@ object ArgsHelper {
forcedShardCount: Int? = null
): CalculateShardsResult {
if (filteredTests.isEmpty()) {
return CalculateShardsResult(emptyList(), emptyList()) // Avoid unnecessary computing if we already know there aren't tests to run.
return CalculateShardsResult(
emptyList(),
emptyList()
) // Avoid unnecessary computing if we already know there aren't tests to run.
}
val (ignoredTests, testsToExecute) = filteredTests.partition { it.ignored }
val shards = if (args.disableSharding) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ data class CommonFlankConfig @JsonIgnore constructor(
const val defaultLocalResultsDir = "results"

fun default() = CommonFlankConfig().apply {
project = ArgsHelper.getDefaultProjectId() ?: ""
project = ArgsHelper.getDefaultProjectIdOrNull()
maxTestShards = 1
shardTime = -1
repeatTests = 1
Expand Down
8 changes: 4 additions & 4 deletions test_runner/src/main/kotlin/ftl/gc/GcToolResults.kt
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,12 @@ object GcToolResults {
).executeWithRetry()
}

fun getDefaultBucket(projectId: String): String? = try {
fun getDefaultBucket(projectId: String, source: String? = null): String? = try {
service.Projects().initializeSettings(projectId).executeWithRetry().defaultBucket
} catch (ftlProjectError: FTLProjectError) {
// flank needs to rewrap the exception with additional info about project
when (ftlProjectError) {
is PermissionDenied -> throw FlankGeneralError(permissionDeniedErrorMessage(projectId, ftlProjectError.message))
is PermissionDenied -> throw FlankGeneralError(permissionDeniedErrorMessage(projectId, source, ftlProjectError.message))
is ProjectNotFound -> throw FlankGeneralError(projectNotFoundErrorMessage(projectId, ftlProjectError.message))
is FailureToken -> UserAuth.throwAuthenticationError()
}
Expand Down Expand Up @@ -204,8 +204,8 @@ object GcToolResults {
.executeWithRetry()
}

private val permissionDeniedErrorMessage = { projectId: String, message: String? ->
"""Flank encountered a 403 error when running on project $projectId. Please verify this credential is authorized for the project and has the required permissions.
private val permissionDeniedErrorMessage = { projectId: String, projectIdSource: String?, message: String? ->
"""Flank encountered a 403 error when running on project $projectId${projectIdSource?.let {" (from $it)"} ?: ""}. Please verify this credential is authorized for the project and has the required permissions.
Consider authentication with a Service Account https://github.com/Flank/flank#authenticate-with-a-service-account
or with a Google account https://github.com/Flank/flank#authenticate-with-a-google-account
Expand Down
2 changes: 2 additions & 0 deletions test_runner/src/main/kotlin/ftl/util/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,5 @@ fun <T> KMutableProperty<T?>.require() =
getter.call() ?: throw FlankGeneralError(
"Invalid value for [${setter.annotations.filterIsInstance<JsonProperty>().first().value}]: no argument value found"
)

fun getGACPathOrEmpty(): String = System.getenv("GOOGLE_APPLICATION_CREDENTIALS").orEmpty()
2 changes: 1 addition & 1 deletion test_runner/src/test/kotlin/ftl/args/ArgsHelperTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ class ArgsHelperTest {

@Test
fun `getDefaultProjectId succeeds`() {
assertThat(ArgsHelper.getDefaultProjectId())
assertThat(ArgsHelper.getDefaultProjectIdOrNull())
.isEqualTo("mockProjectId")
}

Expand Down
75 changes: 75 additions & 0 deletions test_runner/src/test/kotlin/ftl/args/FetchProjectIdTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package ftl.args

import com.google.cloud.ServiceOptions
import com.google.common.truth.Truth.assertThat
import ftl.config.FtlConstants
import ftl.config.defaultCredentialPath
import ftl.util.getGACPathOrEmpty
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import org.junit.After
import org.junit.AfterClass
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File

class FetchProjectIdTest {

// mock server is not torn down between test classes
// this is workaround until better solution is implemented
companion object {
@JvmStatic
@BeforeClass
fun noUseMock() {
FtlConstants.useMock = false
}

@JvmStatic
@AfterClass
fun useMock() {
FtlConstants.useMock = true
}
}

@get:Rule
val folder = TemporaryFolder()

private lateinit var gac: File

private lateinit var def: File

@Before
fun setup() {
gac = folder.newFile("gap.json").also { it.writeText("""{"project_id": "id_from_gac"}""") }
def = folder.newFile("def.json").also { it.writeText("""{"project_id": "id_from_def"}""") }
}

@After
fun teardown() = unmockkAll()

@Test
fun `should fetch project id from GCLOUD_APLICATION_CREDENTIALS`() {
mockkStatic("ftl.util.Utils") {
every { getGACPathOrEmpty() } returns gac.absolutePath.toString()
assertThat(ArgsHelper.getDefaultProjectIdOrNull()).isEqualTo("id_from_gac")
}
}

@Test
fun `should fetch project id from default credentials`() {
mockkStatic(
"ftl.util.Utils",
"ftl.config.CredentialsKt",
ServiceOptions::class.qualifiedName ?: ""
) {
every { defaultCredentialPath } returns def.toPath()
every { getGACPathOrEmpty() } returns ""
every { ServiceOptions.getDefaultProjectId() } returns null
assertThat(ArgsHelper.getDefaultProjectIdOrNull()).isEqualTo("id_from_def")
}
}
}
47 changes: 46 additions & 1 deletion test_runner/src/test/kotlin/ftl/gc/GcToolResultsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class GcToolResultsTest {
}

@Test
fun `getDefaultBucket on 403 error should throw exception with specific message`() {
fun `getDefaultBucket on 403 error should throw exception with specific message - no source`() {
val expected = """
Flank encountered a 403 error when running on project $projectId. Please verify this credential is authorized for the project and has the required permissions.
Consider authentication with a Service Account https://github.com/Flank/flank#authenticate-with-a-service-account
Expand Down Expand Up @@ -100,6 +100,51 @@ class GcToolResultsTest {
}
}

@Test
fun `getDefaultBucket on 403 error should throw exception with specific message - with source`() {
val expected = """
Flank encountered a 403 error when running on project $projectId (from /Any/path/to/json.json). Please verify this credential is authorized for the project and has the required permissions.
Consider authentication with a Service Account https://github.com/Flank/flank#authenticate-with-a-service-account
or with a Google account https://github.com/Flank/flank#authenticate-with-a-google-account
Caused by: com.google.api.client.googleapis.json.GoogleJsonResponseException: 403 Forbidden
{
"code" : 403,
"errors" : [ {
"domain" : "global",
"message" : "The caller does not have permission",
"reason" : "forbidden"
} ],
"message" : "The caller does not have permission",
"status" : "PERMISSION_DENIED"
}
""".trimIndent()
mockkObject(GcToolResults) {
every { GcToolResults.service.applicationName } returns projectId

val exceptionBuilder = mockk<HttpResponseException.Builder>()
every { exceptionBuilder.message } returns """
403 Forbidden
{
"code" : 403,
"errors" : [ {
"domain" : "global",
"message" : "The caller does not have permission",
"reason" : "forbidden"
} ],
"message" : "The caller does not have permission",
"status" : "PERMISSION_DENIED"
}
""".trimIndent()
val mockJSonException = GoogleJsonResponseException(exceptionBuilder, null)
every { GcToolResults.service.Projects().initializeSettings(projectId) } throws PermissionDenied(
mockJSonException
)
val exception = getThrowable { GcToolResults.getDefaultBucket(projectId, "/Any/path/to/json.json") }
assertEquals(expected, exception.message)
}
}

@Test(expected = FlankGeneralError::class)
fun `getDefaultBucket on PermissionDenied error should throw FlankGeneralError`() {
mockkObject(GcToolResults) {
Expand Down

0 comments on commit 4f37042

Please sign in to comment.