-
Notifications
You must be signed in to change notification settings - Fork 360
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
WX-1078 ACR support #7192
WX-1078 ACR support #7192
Changes from 9 commits
555c138
583ca74
e74544a
fa4278b
996d394
e2d2e52
06ef7fd
2a730bf
8107ca4
2968ac9
741ef28
56faf49
da23e0f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
package cromwell.docker.registryv2.flows.azure | ||
|
||
case class AcrAccessToken(access_token: String) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
package cromwell.docker.registryv2.flows.azure | ||
|
||
case class AcrRefreshToken(refresh_token: String) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
package cromwell.docker.registryv2.flows.azure | ||
|
||
import cats.data.Validated.{Invalid, Valid} | ||
import cats.effect.IO | ||
import com.typesafe.scalalogging.LazyLogging | ||
import common.validation.ErrorOr.ErrorOr | ||
import cromwell.cloudsupport.azure.AzureCredentials | ||
import cromwell.docker.DockerInfoActor.DockerInfoContext | ||
import cromwell.docker.{DockerImageIdentifier, DockerRegistryConfig} | ||
import cromwell.docker.registryv2.DockerRegistryV2Abstract | ||
import org.http4s.{Header, Request, Response, Status} | ||
import cromwell.docker.registryv2.flows.azure.AzureContainerRegistry.domain | ||
import org.http4s.circe.jsonOf | ||
import org.http4s.client.Client | ||
import io.circe.generic.auto._ | ||
import org.http4s._ | ||
|
||
|
||
class AzureContainerRegistry(config: DockerRegistryConfig) extends DockerRegistryV2Abstract(config) with LazyLogging { | ||
|
||
/** | ||
* (e.g registry-1.docker.io) | ||
*/ | ||
override protected def registryHostName(dockerImageIdentifier: DockerImageIdentifier): String = | ||
dockerImageIdentifier.host.getOrElse("") | ||
|
||
override def accepts(dockerImageIdentifier: DockerImageIdentifier): Boolean = | ||
dockerImageIdentifier.hostAsString.contains(domain) | ||
|
||
override protected def authorizationServerHostName(dockerImageIdentifier: DockerImageIdentifier): String = | ||
dockerImageIdentifier.host.getOrElse("") | ||
|
||
/** | ||
* In Azure, service name does not exist at the registry level, it varies per repo, e.g. `terrabatchdev.azurecr.io` | ||
*/ | ||
override def serviceName: Option[String] = | ||
throw new Exception("ACR service name is host of user-defined registry, must derive from `DockerImageIdentifier`") | ||
|
||
/** | ||
* Builds the list of headers for the token request | ||
*/ | ||
override protected def buildTokenRequestHeaders(dockerInfoContext: DockerInfoContext): List[Header] = { | ||
List(contentTypeHeader) | ||
} | ||
|
||
private val contentTypeHeader: Header = { | ||
import org.http4s.headers.`Content-Type` | ||
import org.http4s.MediaType | ||
|
||
`Content-Type`(MediaType.application.`x-www-form-urlencoded`) | ||
} | ||
|
||
private def getRefreshToken(authServerHostname: String, defaultAccessToken: String): IO[Request[IO]] = { | ||
import org.http4s.Uri.{Authority, Scheme} | ||
import org.http4s.client.dsl.io._ | ||
import org.http4s._ | ||
|
||
val uri = Uri.apply( | ||
scheme = Option(Scheme.https), | ||
authority = Option(Authority(host = Uri.RegName(authServerHostname))), | ||
path = "/oauth2/exchange", | ||
query = Query.empty | ||
) | ||
|
||
org.http4s.Method.POST( | ||
UrlForm( | ||
"service" -> authServerHostname, | ||
"access_token" -> defaultAccessToken, | ||
"grant_type" -> "access_token" | ||
), | ||
uri, | ||
List(contentTypeHeader): _* | ||
) | ||
} | ||
|
||
/* | ||
Unlike other repositories, Azure reserves `GET /oauth2/token` for Basic Authentication [0] | ||
In order to use Oauth we must `POST /oauth2/token` [1] | ||
|
||
[0] https://github.com/Azure/acr/blob/main/docs/Token-BasicAuth.md#using-the-token-api | ||
[1] https://github.com/Azure/acr/blob/main/docs/AAD-OAuth.md#calling-post-oauth2token-to-get-an-acr-access-token | ||
*/ | ||
private def getDockerAccessToken(hostname: String, repository: String, refreshToken: String): IO[Request[IO]] = { | ||
import org.http4s.Uri.{Authority, Scheme} | ||
import org.http4s.client.dsl.io._ | ||
import org.http4s._ | ||
|
||
val uri = Uri.apply( | ||
scheme = Option(Scheme.https), | ||
authority = Option(Authority(host = Uri.RegName(hostname))), | ||
path = "/oauth2/token", | ||
query = Query.empty | ||
) | ||
|
||
org.http4s.Method.POST( | ||
UrlForm( | ||
// Tricky behavior - invalid `repository` values return a 200 with a valid-looking token. | ||
// However, the token will cause 401s on all subsequent requests. | ||
"scope" -> s"repository:$repository:pull", | ||
"service" -> hostname, | ||
"refresh_token" -> refreshToken, | ||
"grant_type" -> "refresh_token" | ||
), | ||
uri, | ||
List(contentTypeHeader): _* // http4s adds `Content-Length` which ACR does not like (400 response) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How does this syntax prevent the Content-Length header from being added? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apologies, this is a comment based on old info. Removing. |
||
) | ||
} | ||
|
||
override protected def getToken(dockerInfoContext: DockerInfoContext)(implicit client: Client[IO]): IO[Option[String]] = { | ||
val hostname = authorizationServerHostName(dockerInfoContext.dockerImageID) | ||
val maybeAadAccessToken: ErrorOr[String] = AzureCredentials.getAccessToken(None) // AAD token suitable for get-refresh-token request | ||
val repository = dockerInfoContext.dockerImageID.image // ACR uses what we think of image name, as the repository | ||
|
||
// Top-level flow: AAD access token -> refresh token -> ACR access token | ||
maybeAadAccessToken match { | ||
case Valid(accessToken) => | ||
(for { | ||
refreshToken <- executeRequest(getRefreshToken(hostname, accessToken), parseRefreshToken) | ||
dockerToken <- executeRequest(getDockerAccessToken(hostname, repository, refreshToken), parseAccessToken) | ||
} yield dockerToken).map(Option.apply) | ||
case Invalid(errors) => | ||
IO.raiseError( | ||
new Exception(s"Could not obtain AAD token to exchange for ACR refresh token. Error(s): ${errors}") | ||
) | ||
} | ||
} | ||
|
||
implicit val refreshTokenDecoder: EntityDecoder[IO, AcrRefreshToken] = jsonOf[IO, AcrRefreshToken] | ||
implicit val accessTokenDecoder: EntityDecoder[IO, AcrAccessToken] = jsonOf[IO, AcrAccessToken] | ||
|
||
private def parseRefreshToken(response: Response[IO]): IO[String] = response match { | ||
case Status.Successful(r) => r.as[AcrRefreshToken].map(_.refresh_token) | ||
case r => | ||
r.as[String].flatMap(b => IO.raiseError(new Exception(s"Request failed with status ${r.status.code} and body $b"))) | ||
} | ||
|
||
private def parseAccessToken(response: Response[IO]): IO[String] = response match { | ||
case Status.Successful(r) => r.as[AcrAccessToken].map(_.access_token) | ||
case r => | ||
r.as[String].flatMap(b => IO.raiseError(new Exception(s"Request failed with status ${r.status.code} and body $b"))) | ||
} | ||
|
||
} | ||
|
||
object AzureContainerRegistry { | ||
|
||
def domain: String = "azurecr.io" | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,6 +2,7 @@ package cromwell.docker | |
|
||
import cromwell.core.Tags.IntegrationTest | ||
import cromwell.docker.DockerInfoActor._ | ||
import cromwell.docker.registryv2.flows.azure.AzureContainerRegistry | ||
import cromwell.docker.registryv2.flows.dockerhub.DockerHubRegistry | ||
import cromwell.docker.registryv2.flows.google.GoogleRegistry | ||
import cromwell.docker.registryv2.flows.quay.QuayRegistry | ||
|
@@ -18,7 +19,8 @@ class DockerInfoActorSpec extends DockerRegistrySpec with AnyFlatSpecLike with M | |
override protected lazy val registryFlows = List( | ||
new DockerHubRegistry(DockerRegistryConfig.default), | ||
new GoogleRegistry(DockerRegistryConfig.default), | ||
new QuayRegistry(DockerRegistryConfig.default) | ||
new QuayRegistry(DockerRegistryConfig.default), | ||
new AzureContainerRegistry(DockerRegistryConfig.default) | ||
) | ||
|
||
it should "retrieve a public docker hash" taggedAs IntegrationTest in { | ||
|
@@ -50,6 +52,16 @@ class DockerInfoActorSpec extends DockerRegistrySpec with AnyFlatSpecLike with M | |
hash should not be empty | ||
} | ||
} | ||
|
||
it should "retrieve a private docker hash on acr" taggedAs IntegrationTest in { | ||
dockerActor ! makeRequest("terrabatchdev.azurecr.io/postgres:latest") | ||
|
||
expectMsgPF(15 second) { | ||
case DockerInfoSuccessResponse(DockerInformation(DockerHashResult(alg, hash), _), _) => | ||
alg shouldBe "sha256" | ||
hash should not be empty | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Huh... doesn't this depend on auth? How is this passing in GHA? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The tests tagged There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TIL! |
||
} | ||
} | ||
|
||
it should "send image not found message back if the image does not exist" taggedAs IntegrationTest in { | ||
val notFound = makeRequest("ubuntu:nonexistingtag") | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any particular reason for having these imports embedded in these functions rather than at the top?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Personal preference really, they are extremely specific and only used in one place.