From b9d442d131a23b4360bdbb11cf2e2e826c61d3ee Mon Sep 17 00:00:00 2001 From: ysp0606 Date: Thu, 23 May 2019 00:34:55 +0800 Subject: [PATCH] AlibabaCloud add docker registry, callcaching, glob supports --- CHANGELOG.md | 14 ++ core/src/main/resources/reference.conf | 1 + cromwell.example.backends/BCS.conf | 1 - .../cromwell/docker/DockerInfoActor.scala | 4 +- .../AlibabaCloudCRRegistry.scala | 136 +++++++++++++++++ .../docker/DockerImageIdentifierSpec.scala | 13 +- .../AlibabaCloudCRRegistrySpec.scala | 142 ++++++++++++++++++ docs/backends/BCS.md | 92 +++++++++--- docs/cromwell_features/CallCaching.md | 13 +- docs/tutorials/BCSIntro.md | 2 + .../cromwell/engine/io/nio/NioFlow.scala | 2 + .../filesystems/oss/OssPathBuilder.scala | 46 ++++-- .../oss/OssPathBuilderFactory.scala | 12 +- .../oss/batch/OssBatchCommandBuilder.scala | 32 ++++ .../oss/batch/OssBatchIoCommand.scala | 94 ++++++++++++ .../oss/nio/OssFileReadChannel.scala | 1 + .../nio/OssStorageFileAttributesView.scala | 12 +- .../oss/nio/OssStorageFileSystem.scala | 26 ++-- .../nio/OssStorageFileSystemProvider.scala | 10 +- .../oss/nio/TTLOssStorageConfiguration.scala | 47 ++++++ .../filesystems/oss/OssPathBuilderSpec.scala | 47 +++++- .../filesystems/oss/nio/OssNioUtilSpec.scala | 2 +- .../nio/TTLOssStorageConfigurationSpec.scala | 50 ++++++ project/Dependencies.scala | 14 +- .../BcsAsyncBackendJobExecutionActor.scala | 127 ++++++++++------ .../bcs/BcsBackendLifecycleActorFactory.scala | 18 +++ .../bcs/BcsClusterIdOrConfiguration.scala | 16 +- .../backend/impl/bcs/BcsConfiguration.scala | 25 ++- .../cromwell/backend/impl/bcs/BcsJob.scala | 48 ++++-- .../impl/bcs/BcsJobCachingActorHelper.scala | 22 ++- .../backend/impl/bcs/BcsJobPaths.scala | 3 +- .../cromwell/backend/impl/bcs/BcsMount.scala | 93 +++++++++--- .../impl/bcs/BcsRuntimeAttributes.scala | 59 ++++---- .../backend/impl/bcs/BcsWorkflowPaths.scala | 19 ++- .../BcsBackendCacheHitCopyingActor.scala | 69 +++++++++ .../BcsCacheHitDuplicationStrategy.scala | 6 + .../cromwell/backend/impl/bcs/worker.tar.gz | Bin 86319 -> 0 bytes .../bcs/src/test/resources/application.conf | 77 ++++++++++ .../bcs/BcsClusterIdOrConfigurationSpec.scala | 31 ++-- .../impl/bcs/BcsConfigurationSpec.scala | 11 +- .../backend/impl/bcs/BcsJobPathsSpec.scala | 2 +- .../backend/impl/bcs/BcsJobSpec.scala | 13 +- .../backend/impl/bcs/BcsMountSpec.scala | 16 +- .../impl/bcs/BcsRuntimeAttributesSpec.scala | 29 ++-- .../backend/impl/bcs/BcsTestUtilSpec.scala | 30 ++-- .../impl/bcs/BcsWorkflowPathsSpec.scala | 6 +- .../BcsBackendCacheHitCopyingActorSpec.scala | 88 +++++++++++ 47 files changed, 1372 insertions(+), 249 deletions(-) create mode 100644 dockerHashing/src/main/scala/cromwell/docker/registryv2/flows/alibabacloudcr/AlibabaCloudCRRegistry.scala create mode 100644 dockerHashing/src/test/scala/cromwell/docker/registryv2/AlibabaCloudCRRegistrySpec.scala create mode 100644 filesystems/oss/src/main/scala/cromwell/filesystems/oss/batch/OssBatchCommandBuilder.scala create mode 100644 filesystems/oss/src/main/scala/cromwell/filesystems/oss/batch/OssBatchIoCommand.scala create mode 100644 filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/TTLOssStorageConfiguration.scala create mode 100644 filesystems/oss/src/test/scala/cromwell/filesystems/oss/nio/TTLOssStorageConfigurationSpec.scala create mode 100644 supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/callcaching/BcsBackendCacheHitCopyingActor.scala create mode 100644 supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/callcaching/BcsCacheHitDuplicationStrategy.scala delete mode 100644 supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/worker.tar.gz create mode 100644 supportedBackends/bcs/src/test/resources/application.conf create mode 100644 supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/callcaching/BcsBackendCacheHitCopyingActorSpec.scala diff --git a/CHANGELOG.md b/CHANGELOG.md index d2abf9db9ef..b6e7a777db5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Cromwell Change Log +## 45 Release Notes + +### BCS backend new Features support + +#### New docker registry +Alibaba Cloud Container Registry is now supported for the `docker` runtime attribute, and the previous `dockerTag` +runtime attribute continues to be available for Alibaba Cloud OSS Registry. +#### Call caching +Cromwell now supports Call caching when using the BCS backend. +#### Workflow output glob +Globs can be used to define outputs for BCS backend. +#### NAS mount +Alibaba Cloud NAS is now supported for the `mounts` runtime attribute. + ## 44 Release Notes ### Improved PAPI v2 Preemptible VM Support diff --git a/core/src/main/resources/reference.conf b/core/src/main/resources/reference.conf index b11fa10b1af..489c4f653d1 100644 --- a/core/src/main/resources/reference.conf +++ b/core/src/main/resources/reference.conf @@ -376,6 +376,7 @@ docker { } dockerhub.num-threads = 10 quay.num-threads = 10 + alibabacloudcr.num-threads = 10 } } diff --git a/cromwell.example.backends/BCS.conf b/cromwell.example.backends/BCS.conf index 7dfcc765519..e910947d5e0 100644 --- a/cromwell.example.backends/BCS.conf +++ b/cromwell.example.backends/BCS.conf @@ -45,7 +45,6 @@ backend { #reserveOnFail: true #autoReleaseJob: true #verbose: false - #workerPath: "oss://bcs-bucket/workflow/worker.tar.gz" #systemDisk: "cloud 50" #dataDisk: "cloud 250 /home/data/" #timeout: 3000 diff --git a/dockerHashing/src/main/scala/cromwell/docker/DockerInfoActor.scala b/dockerHashing/src/main/scala/cromwell/docker/DockerInfoActor.scala index fda13b4a135..032dbb9e370 100644 --- a/dockerHashing/src/main/scala/cromwell/docker/DockerInfoActor.scala +++ b/dockerHashing/src/main/scala/cromwell/docker/DockerInfoActor.scala @@ -12,6 +12,7 @@ import common.validation.Validation._ import cromwell.core.actor.StreamIntegration.{BackPressure, StreamContext} import cromwell.core.{Dispatcher, DockerConfiguration} import cromwell.docker.DockerInfoActor._ +import cromwell.docker.registryv2.flows.alibabacloudcrregistry._ import cromwell.docker.registryv2.DockerRegistryV2Abstract import cromwell.docker.registryv2.flows.dockerhub.DockerHubRegistry import cromwell.docker.registryv2.flows.gcr.GcrRegistry @@ -237,7 +238,8 @@ object DockerInfoActor { List( ("dockerhub", { c: DockerRegistryConfig => new DockerHubRegistry(c) }), ("gcr", gcrConstructor), - ("quay", { c: DockerRegistryConfig => new QuayRegistry(c) }) + ("quay", { c: DockerRegistryConfig => new QuayRegistry(c) }), + ("alibabacloudcr", {c: DockerRegistryConfig => new AlibabaCloudCRRegistry(c)}) ).traverse[ErrorOr, DockerRegistry]({ case (configPath, constructor) => DockerRegistryConfig.fromConfig(config.as[Config](configPath)).map(constructor) }).unsafe("Docker registry configuration") diff --git a/dockerHashing/src/main/scala/cromwell/docker/registryv2/flows/alibabacloudcr/AlibabaCloudCRRegistry.scala b/dockerHashing/src/main/scala/cromwell/docker/registryv2/flows/alibabacloudcr/AlibabaCloudCRRegistry.scala new file mode 100644 index 00000000000..5924eeb2a38 --- /dev/null +++ b/dockerHashing/src/main/scala/cromwell/docker/registryv2/flows/alibabacloudcr/AlibabaCloudCRRegistry.scala @@ -0,0 +1,136 @@ +package cromwell.docker.registryv2.flows.alibabacloudcrregistry + +import akka.stream._ +import cats.effect.IO +import com.aliyuncs.DefaultAcsClient +import com.aliyuncs.auth.{AlibabaCloudCredentials, BasicCredentials, BasicSessionCredentials} +import com.aliyuncs.cr.model.v20160607.GetRepoTagsRequest +import com.aliyuncs.profile.DefaultProfile +import com.aliyuncs.profile.IClientProfile +import cromwell.docker.DockerHashResult +import cromwell.docker.DockerInfoActor._ +import cromwell.docker._ +import cromwell.docker.registryv2.DockerRegistryV2Abstract +import org.http4s.Header +import org.http4s.client.Client +import scala.util.matching.Regex +import scala.util.{Failure, Success, Try} +import spray.json.DefaultJsonProtocol._ +import spray.json._ + +class AlibabaCloudCRRegistry(config: DockerRegistryConfig) extends DockerRegistryV2Abstract(config) { + val ProductName = "cr" + val HashAlg = "sha256" + val regionPattern = """[^\s]+""" + val validAlibabaCloudCRHosts: Regex = s"""registry.($regionPattern).aliyuncs.com""".r + + + def isValidAlibabaCloudCRHost(host: Option[String]): Boolean = { + host.exists { + _ match { + case validAlibabaCloudCRHosts(_) => true + case _ => false + } + } + } + + override def accepts(dockerImageIdentifier: DockerImageIdentifier): Boolean = isValidAlibabaCloudCRHost(dockerImageIdentifier.host) + + override protected def getToken(dockerInfoContext: DockerInfoContext)(implicit client: Client[IO]): IO[Option[String]] = { + IO.pure(None) + } + + override protected def registryHostName(dockerImageIdentifier: DockerImageIdentifier): String = "" + override protected def authorizationServerHostName(dockerImageIdentifier: DockerImageIdentifier): String = "" + override protected def buildTokenRequestHeaders(dockerInfoContext: DockerInfoContext): List[Header] = List.empty + + override protected def getDockerResponse(token: Option[String], dockerInfoContext: DockerInfoContext)(implicit client: Client[IO]): IO[DockerInfoSuccessResponse] = { + getManifest(dockerInfoContext) match { + case success: DockerInfoSuccessResponse => IO(success) + case fail: DockerInfoFailedResponse => IO.raiseError(new Exception(fail.reason)) + case other => IO.raiseError(new Exception(s"Get manifest failed, $other")) + } + } + + private def getManifest(context: DockerInfoContext): DockerInfoResponse = { + + val regionId = context.dockerImageID.host match { + case Some(validAlibabaCloudCRHosts(region)) => region + case _ => throw new Exception(s"The host ${context.dockerImageID.host} does not have the expected region id") + } + + val endpoint = ProductName + "." + regionId + ".aliyuncs.com" + DefaultProfile.addEndpoint(regionId, ProductName, endpoint) + + val profile: IClientProfile = getAliyunCredentialFromContext(context) match { + case Some(cred: BasicCredentials) => DefaultProfile.getProfile(regionId, cred.getAccessKeyId(), cred.getAccessKeySecret()) + case Some(sCred: BasicSessionCredentials) => DefaultProfile.getProfile(regionId, sCred.getAccessKeyId(), sCred.getAccessKeySecret(), sCred.getSessionToken()) + case _ => throw new Exception(s"Invalid credential from context, ${context}") + } + + val client: DefaultAcsClient = new DefaultAcsClient(profile) + val request: GetRepoTagsRequest = new GetRepoTagsRequest() + val dockerImageID = context.dockerImageID + request.setRepoName(dockerImageID.image) + dockerImageID.repository foreach { repository => request.setRepoNamespace(repository) } + + manifestResponseHandler(client, request, context) + .getOrElse(new Exception(s"handle response fail, please make sure the image id is correct: ${context.dockerImageID}")) match { + case succ: DockerInfoSuccessResponse => succ + case fail: DockerInfoFailedResponse => fail + case ex: Exception => throw new Exception(s"Get AliyunCr manifest failed, ${ex.getMessage}") + } + } + + private[alibabacloudcrregistry] def getAliyunCredentialFromContext(context: DockerInfoContext): Option[AlibabaCloudCredentials] = { + context.credentials find { + _.isInstanceOf[AlibabaCloudCredentials] + } match { + case Some(cred: BasicCredentials) => Some(cred) + case Some(sCred: BasicSessionCredentials) => Some(sCred) + case _ => None + } + } + + private def matchTag(jsObject: JsObject, dockerHashContext: DockerInfoContext): Boolean = { + val tag = dockerHashContext.dockerImageID.reference + jsObject.fields.get("tag") match { + case Some(tagObj: JsString) if tagObj.value == tag => true + case _ => false + } + } + + private[alibabacloudcrregistry] def extractDigestFromBody(jsObject: JsObject, dockerHashContext: DockerInfoContext): DockerInfoResponse = { + val tags = jsObject.fields.get("data") match { + case Some(data) => data.asJsObject().convertTo[Map[String, JsValue]].get("tags") match { + case Some(tag) => tag.convertTo[Seq[JsObject]] + case None => throw new Exception(s"Manifest response did not contain a tags field, ${jsObject}") + } + case None => throw new Exception(s"Manifest response did not contain a data field, Please make sure the existence of image, ${jsObject}") + } + + tags find { matchTag(_, dockerHashContext)} match { + case Some(tagObj) => + tagObj.fields.get("digest") match { + case Some(digest: JsString) => + DockerHashResult.fromString(HashAlg + ":" + digest.value) match { + case Success(r) => DockerInfoSuccessResponse(DockerInformation(r, None), dockerHashContext.request) + case Failure(t) => DockerInfoFailedResponse(t, dockerHashContext.request) + } + case Some(_) => DockerInfoFailedResponse((new Exception(s"Manifest response contains a non-string digest field, ${jsObject}")), dockerHashContext.request) + case None => DockerInfoFailedResponse((new Exception(s"Manifest response did not contain a digest field, ${jsObject}")), dockerHashContext.request) + } + case None => DockerInfoFailedResponse((new Exception(s"Manifest response did not contain a expected tag: ${dockerHashContext.dockerImageID.reference}, ${jsObject}")), dockerHashContext.request) + } + } + + private def manifestResponseHandler(client: DefaultAcsClient, request: GetRepoTagsRequest, dockerHashContext: DockerInfoContext): Try[DockerInfoResponse] = { + for { + response <- Try(client.doAction(request)) + jsObj <- Try(if (response.isSuccess) response.getHttpContentString.parseJson.asJsObject() + else throw new Exception(s"Get manifest request not success: ${response}")) + dockInfoRes <- Try(extractDigestFromBody(jsObj, dockerHashContext)) + } yield dockInfoRes + } +} + diff --git a/dockerHashing/src/test/scala/cromwell/docker/DockerImageIdentifierSpec.scala b/dockerHashing/src/test/scala/cromwell/docker/DockerImageIdentifierSpec.scala index 58962860562..6c67d2ea262 100644 --- a/dockerHashing/src/test/scala/cromwell/docker/DockerImageIdentifierSpec.scala +++ b/dockerHashing/src/test/scala/cromwell/docker/DockerImageIdentifierSpec.scala @@ -11,17 +11,18 @@ class DockerImageIdentifierSpec extends FlatSpec with Matchers with TableDrivenP ("sourceString", "host", "repo", "image", "reference"), // Without tags -> latest ("ubuntu", None, None, "ubuntu", "latest"), - ("broad/cromwell", None, Some("broad"), "cromwell", "latest"), + ("broad/cromwell", None, Option("broad"), "cromwell", "latest"), ("index.docker.io/ubuntu", Option("index.docker.io"), None, "ubuntu", "latest"), - ("broad/cromwell/submarine", None, Some("broad/cromwell"), "submarine", "latest"), - ("gcr.io/google/slim", Option("gcr.io"), Some("google"), "slim", "latest"), + ("broad/cromwell/submarine", None, Option("broad/cromwell"), "submarine", "latest"), + ("gcr.io/google/slim", Option("gcr.io"), Option("google"), "slim", "latest"), // With tags ("ubuntu:latest", None, None, "ubuntu", "latest"), ("ubuntu:1235-SNAP", None, None, "ubuntu", "1235-SNAP"), ("ubuntu:V3.8-5_1", None, None, "ubuntu", "V3.8-5_1"), - ("index.docker.io:9999/ubuntu:170904", Some("index.docker.io:9999"), None, "ubuntu", "170904"), - ("localhost:5000/capture/transwf:170904", Some("localhost:5000"), Some("capture"), "transwf", "170904"), - ("quay.io/biocontainers/platypus-variant:0.8.1.1--htslib1.5_0", Option("quay.io"), Some("biocontainers"), "platypus-variant", "0.8.1.1--htslib1.5_0") + ("index.docker.io:9999/ubuntu:170904", Option("index.docker.io:9999"), None, "ubuntu", "170904"), + ("localhost:5000/capture/transwf:170904", Option("localhost:5000"), Option("capture"), "transwf", "170904"), + ("quay.io/biocontainers/platypus-variant:0.8.1.1--htslib1.5_0", Option("quay.io"), Option("biocontainers"), "platypus-variant", "0.8.1.1--htslib1.5_0"), + ("registry.cn-shanghai.aliyuncs.com/batchcompute/ubuntu:0.2", Option("registry.cn-shanghai.aliyuncs.com"), Option("batchcompute"), "ubuntu", "0.2") ) forAll(valid) { (dockerString, host, repo, image, reference) => diff --git a/dockerHashing/src/test/scala/cromwell/docker/registryv2/AlibabaCloudCRRegistrySpec.scala b/dockerHashing/src/test/scala/cromwell/docker/registryv2/AlibabaCloudCRRegistrySpec.scala new file mode 100644 index 00000000000..34124cc4ae1 --- /dev/null +++ b/dockerHashing/src/test/scala/cromwell/docker/registryv2/AlibabaCloudCRRegistrySpec.scala @@ -0,0 +1,142 @@ +package cromwell.docker.registryv2.flows.alibabacloudcrregistry + +import com.aliyuncs.auth.{BasicCredentials, BasicSessionCredentials} +import cromwell.docker.DockerInfoActor.{DockerInfoContext, DockerInfoFailedResponse, DockerInfoSuccessResponse, DockerInformation} +import cromwell.docker.{DockerHashResult, DockerImageIdentifier, DockerInfoRequest, DockerRegistryConfig} + +import net.ceedubs.ficus.Ficus._ +import com.typesafe.config.{Config, ConfigFactory} +import cromwell.core.TestKitSuite +import org.scalatest.{BeforeAndAfter, FlatSpecLike, Matchers} +import org.scalatest.mockito.MockitoSugar +import spray.json._ + +object AlibabaCloudCRRegistrySpec { + + val AlibabaCloudCRRegistryConfigString = + s""" + |enable = true + |# How should docker hashes be looked up. Possible values are "local" and "remote" + |# "local": Lookup hashes on the local docker daemon using the cli + |# "remote": Lookup hashes on docker hub and gcr + |method = "remote" + |alibabacloudcr { + | num-threads = 5 + | auth { + | access-id = "test-access-id" + | access-key = "test-access-key" + | security-token = "test-security-token" + | } + |} + | + """.stripMargin + + val AlibabaCloudCRRegistryConfig = ConfigFactory.parseString(AlibabaCloudCRRegistryConfigString) +} + +class AlibabaCloudCRRegistrySpec extends TestKitSuite with FlatSpecLike with Matchers with MockitoSugar with BeforeAndAfter { + behavior of "AlibabaCloudCRRegistry" + + val hashValue = "fcf39ed78ef0fa27bcc74713b85259alop1b12e6a201e3083af50fd8eda1cbe1" + val tag = "0.2" + val notExistTag = "0.3" + val CRResponse = + s""" + |{ + | "data": { + | "total": 2, + | "pageSize": 30, + | "page": 1, + | "tags": [ + | { + | "imageUpdate": 1514432549000, + | "imageId": "2842876c9b98f8c7607c1123ks18ff040b76a1d932c6d60c96aa3c283bd221cd", + | "digest": "83414d2c3b04e0lo1q7693e31aeca95b82c61949ea8de858579bf16bd92490c6", + | "imageSize": 715764, + | "tag": "0.1", + | "imageCreate": 1514432549000, + | "status": "NORMAL" + | }, + | { + | "imageUpdate": 1514372113000, + | "imageId": "414e6daa772a8cd5dfloqpe503e6e313c372d2e15958ab649709daf9b1065479", + | "digest": "$hashValue", + | "imageSize": 715653, + | "tag": "$tag", + | "imageCreate": 1514372044000, + | "status": "NORMAL" + | } + | ] + | }, + | "requestId": "9AFB52D3-6631-4B00-A857-932492097726" + |}""".stripMargin + + + it should "have correct Alibaba Cloud CR image" in { + val configPath = "alibabacloudcr" + val registry = new AlibabaCloudCRRegistry(DockerRegistryConfig.fromConfig(AlibabaCloudCRRegistrySpec.AlibabaCloudCRRegistryConfig.as[Config](configPath)).getOrElse(DockerRegistryConfig.default)) + + val testCRDockerImage = s"registry.cn-shanghai.aliyuncs.com/batchcompute/ubuntu:$tag" + val testInvalidCRDockerImage = "registry.cn-not-exist.aliyuncs.com/batchcompute/ubuntu:0.2" + registry.accepts(DockerImageIdentifier.fromString(testCRDockerImage).get) shouldEqual true + registry.isValidAlibabaCloudCRHost(Some(testInvalidCRDockerImage)) shouldEqual false + registry.isValidAlibabaCloudCRHost(None) shouldEqual false + } + + it should "successfully extract digest from body" in { + val configPath = "alibabacloudcr" + val registry = new AlibabaCloudCRRegistry(DockerRegistryConfig.fromConfig(AlibabaCloudCRRegistrySpec.AlibabaCloudCRRegistryConfig.as[Config](configPath)).getOrElse(DockerRegistryConfig.default)) + + val testCRDockerImage = s"registry.cn-shanghai.aliyuncs.com/batchcompute/ubuntu:$tag" + + val expectedDockerHashResult = DockerHashResult("sha256", hashValue) + val expectedDockerInfomation = DockerInformation(expectedDockerHashResult, None) + val dockerRequest = DockerInfoRequest(DockerImageIdentifier.fromString(testCRDockerImage).get, List.empty) + val expectedDockerResponse = DockerInfoSuccessResponse(expectedDockerInfomation, dockerRequest) + + val context: DockerInfoContext = DockerInfoContext(dockerRequest, null) + registry.extractDigestFromBody(CRResponse.parseJson.asJsObject(), context) shouldEqual expectedDockerResponse + } + + it should "NOT successfully extract digest from body" in { + val configPath = "alibabacloudcr" + val registry = new AlibabaCloudCRRegistry(DockerRegistryConfig.fromConfig(AlibabaCloudCRRegistrySpec.AlibabaCloudCRRegistryConfig.as[Config](configPath)).getOrElse(DockerRegistryConfig.default)) + + val testCRDockerImageTagNotExist = s"registry.cn-shanghai.aliyuncs.com/batchcompute/ubuntu:$notExistTag" + + val dockerRequest = DockerInfoRequest(DockerImageIdentifier.fromString(testCRDockerImageTagNotExist).get, List.empty) + val context: DockerInfoContext = DockerInfoContext(dockerRequest, null) + + val cRResponseJsObj = CRResponse.parseJson.asJsObject() + registry.extractDigestFromBody(cRResponseJsObj, context) match { + case DockerInfoFailedResponse(t, _) => t.getMessage should be(s"Manifest response did not contain a expected tag: $notExistTag, ${cRResponseJsObj}") + case _ => fail("Failed to get a DockerInfoFailedResponse result.") + } + } + + it should "successfully get the correct credentials from context" in { + val configPath = "alibabacloudcr" + val registry = new AlibabaCloudCRRegistry(DockerRegistryConfig.fromConfig(AlibabaCloudCRRegistrySpec.AlibabaCloudCRRegistryConfig.as[Config](configPath)).getOrElse(DockerRegistryConfig.default)) + + val testCRDockerImageTagNotExist = s"registry.cn-shanghai.aliyuncs.com/batchcompute/ubuntu:$tag" + val access_id = "test-access-id" + val access_key = "test-access-key" + val security_token = "test-token" + + val basicCredential = new BasicCredentials(access_id, access_key) + val sessionCredential = new BasicSessionCredentials(access_id, access_key, security_token) + + val dockerRequest = DockerInfoRequest(DockerImageIdentifier.fromString(testCRDockerImageTagNotExist).get, List(basicCredential)) + val context: DockerInfoContext = DockerInfoContext(dockerRequest, null) + registry.getAliyunCredentialFromContext(context) shouldEqual Some(basicCredential) + + val sessionDockerRequest = DockerInfoRequest(DockerImageIdentifier.fromString(testCRDockerImageTagNotExist).get, List(sessionCredential)) + val sessionContext: DockerInfoContext = DockerInfoContext(sessionDockerRequest, null) + registry.getAliyunCredentialFromContext(sessionContext) shouldEqual Some(sessionCredential) + + val invalidDockerRequest = DockerInfoRequest(DockerImageIdentifier.fromString(testCRDockerImageTagNotExist).get, List.empty) + val invalidContext: DockerInfoContext = DockerInfoContext(invalidDockerRequest, null) + registry.getAliyunCredentialFromContext(invalidContext) shouldEqual None + } + +} diff --git a/docs/backends/BCS.md b/docs/backends/BCS.md index 04c322c056e..32b722a3a32 100644 --- a/docs/backends/BCS.md +++ b/docs/backends/BCS.md @@ -73,6 +73,7 @@ the configuration key `backend.providers.BCS.config.filesystems.auth` in order t - `` - API endpoint to access OSS bucket ``. - `` - Access ID to access Alibaba Cloud services through restful API. - `` - Access key to access Alibaba Cloud services through restful API. +- `` - The interval of auth refreshing if you are using an STS(Alibaba Cloud Security Token Service) way to access the OSS filesystem. ```hocon backend { @@ -88,6 +89,7 @@ backend { access-id = "" access-key = "" } + refresh-interval = 1800 } } @@ -115,12 +117,12 @@ backend { default-runtime-attributes { cluster: "OnDemand ecs.sn1ne.large " mounts: "oss:///inputs/ /home/inputs/ false" - docker: "ubuntu/latest oss:///registry/ubuntu/" + dockerTag: "ubuntu/latest oss:///registry/ubuntu/" + docker: "registry.cn-shanghai.aliyuncs.com/batchcompute/myubuntu:0.2" userData: "key value" reserveOnFail: true autoReleaseJob: true verbose: false - workerPath: "oss:///cromwell_test/worker.tar.gz" systemDisk: "cloud 50" dataDisk: "cloud 250 /home/data/" timeout: 3000 @@ -158,11 +160,13 @@ There are two different ways of specifying an Alibaba Cloud BatchCompute cluster #### mounts -BCS jobs can mount an OSS object or an OSS prefix to local filesystem as a file or a directory in VM. +BCS jobs can mount both OSS and [Alibaba Cloud NAS](https://www.aliyun.com/product/nas) to local filesystem as a file or a directory in VM. It uses distribute-caching and lazy-load techniques to optimize concurrently read requests of the OSS file system. You can mount your OSS objects to VM like this: -- `` - An OSS object path or OSS prefix to mount from. +- `` - An OSS object path or OSS prefix or NAS address to mount from, such as + `oss:///inputs/ /home/inputs/ false` for OSS + and `nas://0266e49fea-yio75.cn-beijing.nas.aliyuncs.com:/ /home/nas/ true` for NAS. See the [NAS mount](https://www.alibabacloud.com/help/doc-detail/50494.htm) for more details of NAS mount. - `` - An unix file path or directory path to mount to in VM. - `` - Writable for mount destination, only works for directory. @@ -176,17 +180,28 @@ default-runtime-attributes { #### docker -This backend supports docker images pulled from OSS registry. +This backend supports docker images pulled from OSS registry or Alibaba Cloud Container Registry. +##### OSS registry ```hocon default-runtime-attributes { - docker: " " + dockerTag: " " } ``` - `` - Docker image name such as: ubuntu:latest. - `` - Image path in OSS filesyetem where you pushed your docker image. + +##### Alibaba Cloud Container Registry + +```hocon +default-runtime-attributes { + docker: "" +} +``` +- `docker-image-with-tag` - Docker image stored in Alibaba Cloud Container Registry, such as `registry.cn-shanghai.aliyuncs.com/batchcompute/myubuntu:0.2`. + #### userData If a runtime cluster is specified, it's possible to pass some environment variables to VM when running BCS jobs. @@ -210,19 +225,6 @@ finishes by setting `autoReleaseJob` to `false`: } ``` -#### workerPath - -This backend needs a worker package to run workflow job. We have prepared it, but it's still necessary for you to upload it to OSS and -specify the object path as the value of runtime attributes key `workerPath`: - -- `` - The oss object path which you upload worker package to. A string like `oss:///worker.tar.gz` - -```hocon - default-runtime-attributes { - workerPath: "" - } -``` - #### systemDisk If it's necessary to run a job with a particular system disk type or disk size, a runtime attribute named `systemDisk` can be used to @@ -250,3 +252,55 @@ The system disk size can support up to 500GB. One can mount another data disk in dataDisk: " " } ``` + +###CallCaching +BCS supports CallCaching feature when the docker image is from Alibaba Cloud Container Registry. +The configuration file will look like the following: +```hocon +call-caching { + enabled = true + invalidate-bad-cache-results = true + +} + +docker { + hash-lookup { + enabled = true + method = "remote" + alibabacloudcr { + num-threads = 5 + auth { + access-id = xxxx + access-key = yyyy + security-token = zzzz + } + } + } +} + +backend { + providers { + BCS { + config { + # BCS and OSS related configurations mentioned above + filesystems { + oss { + caching { + duplication-strategy = "reference" + invalidate-bad-cache-results = true + } + # ... to be filled in + } + } + default-runtime-attributes { + docker: "registry.cn-shanghai.aliyuncs.com/batchcompute/myubuntu:0.2" + # ... to be filled in + } + } + } + } +} +``` + +- `docker.hash-lookup.method` - BCS only supports `remote` method for hash-lookup +- `filesystems.oss.caching.duplication-strategy` - BCS only supports `reference` for duplication strategy. \ No newline at end of file diff --git a/docs/cromwell_features/CallCaching.md b/docs/cromwell_features/CallCaching.md index da0111057b0..57ac984fac0 100644 --- a/docs/cromwell_features/CallCaching.md +++ b/docs/cromwell_features/CallCaching.md @@ -123,12 +123,13 @@ Cromwell provides two methods to lookup a Docker hash from a Docker tag: Docker registry and access levels supported by Cromwell for docker digest lookup in "remote" mode: - | | DockerHub || GCR || ECR || - |:-----:|:---------:|:-------:|:------:|:-------:|:------:|:-------:| - | | Public | Private | Public | Private | Public | Private | - | Pipelines API | X | X | X | X | | | - | AWS Batch | X | | X | | | | - | Other | X | | X | | | | + | | DockerHub || GCR || ECR || AlibabaCloudCR || + |:-----:|:---------:|:-------:|:------:|:-------:|:------:|:-------:|:------:|:-------:| + | | Public | Private | Public | Private | Public | Private | Public | Private | + | Pipelines API | X | X | X | X | | | | | + | AWS Batch | X | | X | | | | | | + | BCS | | | | | | | | X | + | Other | X | | X | | | | | | **Runtime Attributes** diff --git a/docs/tutorials/BCSIntro.md b/docs/tutorials/BCSIntro.md index 6ba9a0b9aa2..11b55075a6d 100644 --- a/docs/tutorials/BCSIntro.md +++ b/docs/tutorials/BCSIntro.md @@ -91,6 +91,8 @@ backend { } default-runtime-attributes { + failOnStderr: false + continueOnReturnCode: 0 cluster: "OnDemand ecs.sn1ne.large img-ubuntu" vpc: "192.168.0.0/16" } diff --git a/engine/src/main/scala/cromwell/engine/io/nio/NioFlow.scala b/engine/src/main/scala/cromwell/engine/io/nio/NioFlow.scala index e5bc8a2b135..cb57f69eff0 100644 --- a/engine/src/main/scala/cromwell/engine/io/nio/NioFlow.scala +++ b/engine/src/main/scala/cromwell/engine/io/nio/NioFlow.scala @@ -15,6 +15,7 @@ import cromwell.engine.io.{IoAttempts, IoCommandContext} import cromwell.filesystems.drs.DrsPath import cromwell.filesystems.gcs.GcsPath import cromwell.filesystems.s3.S3Path +import cromwell.filesystems.oss.OssPath import cromwell.util.TryWithResource._ import scala.concurrent.ExecutionContext @@ -111,6 +112,7 @@ class NioFlow(parallelism: Int, case gcsPath: GcsPath => IO { gcsPath.cloudStorage.get(gcsPath.blob).getCrc32c } case drsPath: DrsPath => getFileHashForDrsPath(drsPath) case s3Path: S3Path => IO { s3Path.eTag } + case ossPath: OssPath => IO { ossPath.eTag} case path => IO.fromEither( tryWithResource(() => path.newInputStream) { inputStream => diff --git a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/OssPathBuilder.scala b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/OssPathBuilder.scala index a17d9371d29..8792194f804 100644 --- a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/OssPathBuilder.scala +++ b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/OssPathBuilder.scala @@ -3,10 +3,15 @@ package cromwell.filesystems.oss import java.net.URI import com.google.common.net.UrlEscapers +import com.typesafe.config.Config +import net.ceedubs.ficus.Ficus._ +import cats.syntax.apply._ +import com.aliyun.oss.OSSClient +import common.validation.Validation._ import cromwell.core.WorkflowOptions import cromwell.core.path.{NioPath, Path, PathBuilder} import cromwell.filesystems.oss.OssPathBuilder._ -import cromwell.filesystems.oss.nio.{OssStorageConfiguration, OssStorageFileSystem, OssStoragePath} +import cromwell.filesystems.oss.nio._ import scala.language.postfixOps import scala.util.matching.Regex @@ -78,16 +83,30 @@ object OssPathBuilder { nioPath.getFileSystem.provider().getScheme.equalsIgnoreCase(URI_SCHEME) } - def fromConfiguration(endpoint: String, - accessId: String, - accessKey: String, - securityToken: Option[String], + def fromConfiguration(configuration: OssStorageConfiguration, options: WorkflowOptions): OssPathBuilder = { - - val configuration = OssStorageConfiguration(endpoint, accessId, accessKey, securityToken) - OssPathBuilder(configuration) } + + def fromConfig(config: Config, options: WorkflowOptions): OssPathBuilder = { + val refresh = config.as[Option[Long]](TTLOssStorageConfiguration.RefreshInterval) + + val (endpoint, accessId, accessKey, securityToken) = ( + validate { config.as[String]("auth.endpoint") }, + validate { config.as[String]("auth.access-id") }, + validate { config.as[String]("auth.access-key") }, + validate { config.as[Option[String]]("auth.security-token") } + ).tupled.unsafe("OSS filesystem configuration is invalid") + + refresh match { + case None => + val cfg = DefaultOssStorageConfiguration(endpoint, accessId, accessKey, securityToken) + fromConfiguration(cfg, options) + case Some(_) => + val cfg = TTLOssStorageConfiguration(config) + fromConfiguration(cfg, options) + } + } } final case class OssPathBuilder(ossStorageConfiguration: OssStorageConfiguration) extends PathBuilder { @@ -95,8 +114,8 @@ final case class OssPathBuilder(ossStorageConfiguration: OssStorageConfiguration validateOssPath(string) match { case ValidFullOssPath(bucket, path) => Try { - val nioPath = OssStorageFileSystem(bucket, ossStorageConfiguration).getPath(path) - OssPath(nioPath) + val ossStorageFileSystem = OssStorageFileSystem(bucket, ossStorageConfiguration) + OssPath(ossStorageFileSystem.getPath(path), ossStorageFileSystem.provider.ossClient) } case PossiblyValidRelativeOssPath => Failure(new IllegalArgumentException(s"$string does not have a oss scheme")) case invalid: InvalidOssPath => Failure(new IllegalArgumentException(invalid.errorMessage)) @@ -108,10 +127,11 @@ final case class OssPathBuilder(ossStorageConfiguration: OssStorageConfiguration final case class BucketAndObj(bucket: String, obj: String) -final case class OssPath private[oss](nioPath: NioPath) extends Path { +final case class OssPath private[oss](nioPath: NioPath, + ossClient: OSSClient) extends Path { override protected def newPath(path: NioPath): OssPath = { - OssPath(path) + OssPath(path, ossClient) } override def pathAsString: String = ossStoragePath.pathAsString @@ -128,6 +148,8 @@ final case class OssPath private[oss](nioPath: NioPath) extends Path { ossStoragePath.key } + lazy val eTag = ossClient.getSimplifiedObjectMeta(bucket, key).getETag + def ossStoragePath: OssStoragePath = nioPath match { case ossPath: OssStoragePath => ossPath case _ => throw new RuntimeException(s"Internal path was not a cloud storage path: $nioPath") diff --git a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/OssPathBuilderFactory.scala b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/OssPathBuilderFactory.scala index a0afe1f9438..666ba1c4849 100644 --- a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/OssPathBuilderFactory.scala +++ b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/OssPathBuilderFactory.scala @@ -1,24 +1,14 @@ package cromwell.filesystems.oss import akka.actor.ActorSystem -import cats.syntax.apply._ import com.typesafe.config.Config -import common.validation.Validation._ import cromwell.core.WorkflowOptions import cromwell.core.path.PathBuilderFactory -import net.ceedubs.ficus.Ficus._ import scala.concurrent.{ExecutionContext, Future} final case class OssPathBuilderFactory(globalConfig: Config, instanceConfig: Config) extends PathBuilderFactory { - val (endpoint, accessId, accessKey, securityToken) = ( - validate { instanceConfig.as[String]("auth.endpoint") }, - validate { instanceConfig.as[String]("auth.access-id") }, - validate { instanceConfig.as[String]("auth.access-key") }, - validate { instanceConfig.as[Option[String]]("auth.security-token") } - ).tupled.unsafe("OSS filesystem configuration is invalid") - def withOptions(options: WorkflowOptions)(implicit as: ActorSystem, ec: ExecutionContext) = { - Future.successful(OssPathBuilder.fromConfiguration(endpoint, accessId, accessKey, securityToken, options)) + Future.successful(OssPathBuilder.fromConfig(instanceConfig, options)) } } diff --git a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/batch/OssBatchCommandBuilder.scala b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/batch/OssBatchCommandBuilder.scala new file mode 100644 index 00000000000..70abe012431 --- /dev/null +++ b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/batch/OssBatchCommandBuilder.scala @@ -0,0 +1,32 @@ +package cromwell.filesystems.oss.batch + +import cromwell.core.io._ +import cromwell.filesystems.oss.OssPath + +private case object PartialOssBatchCommandBuilder extends PartialIoCommandBuilder { + override def sizeCommand = { + case ossPath: OssPath => OssBatchSizeCommand(ossPath) + } + + override def deleteCommand = { + case (ossPath: OssPath, swallowIoExceptions) => OssBatchDeleteCommand(ossPath, swallowIoExceptions) + } + + override def copyCommand = { + case (ossSrc: OssPath, ossDest: OssPath, overwrite) => OssBatchCopyCommand(ossSrc, ossDest, overwrite) + } + + override def hashCommand = { + case ossPath: OssPath => OssBatchEtagCommand(ossPath) + } + + override def touchCommand = { + case ossPath: OssPath => OssBatchTouchCommand(ossPath) + } + + override def existsCommand = { + case ossPath: OssPath => OssBatchExistsCommand(ossPath) + } +} + +case object OssBatchCommandBuilder extends IoCommandBuilder(List(PartialOssBatchCommandBuilder)) diff --git a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/batch/OssBatchIoCommand.scala b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/batch/OssBatchIoCommand.scala new file mode 100644 index 00000000000..c4f2643a5fd --- /dev/null +++ b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/batch/OssBatchIoCommand.scala @@ -0,0 +1,94 @@ +package cromwell.filesystems.oss.batch + +import com.aliyun.oss.OSSException +import com.aliyun.oss.model._ + +import com.google.api.client.http.HttpHeaders + +import cromwell.core.io._ +import cromwell.filesystems.oss._ + +/** + * Io commands with OSS paths and some logic enabling batching of request. + * @tparam T Return type of the IoCommand + * @tparam U Return type of the OSS response + */ +sealed trait OssBatchIoCommand[T, U] extends IoCommand[T] { + /** + * StorageRequest operation to be executed by this command + */ + def operation: Any + + /** + * Maps the Oss response of type U to the Cromwell Io response of type T + */ + protected def mapOssResponse(response: U): T + + /** + * Method called in the success callback of a batched request to decide what to do next. + * Returns an Either[T, OssBatchIoCommand[T, U]] + * Left(value) means the command is complete, and the result can be sent back to the sender. + * Right(newCommand) means the command is not complete and needs another request to be executed. + * Most commands will reply with Left(value). + */ + def onSuccess(response: U, httpHeaders: HttpHeaders): Either[T, OssBatchIoCommand[T, U]] = { + Left(mapOssResponse(response)) + } + + /** + * Override to handle a failure differently and potentially return a successful response. + */ + def onFailure(ossError: OSSException): Option[Either[T, OssBatchIoCommand[T, U]]] = None +} + +case class OssBatchCopyCommand( + override val source: OssPath, + override val destination: OssPath, + override val overwrite: Boolean + ) extends IoCopyCommand(source, destination, overwrite) with OssBatchIoCommand[Unit, CopyObjectResult] { + override def operation: GenericResult = { + val getObjectRequest = new CopyObjectRequest(source.bucket, source.key, destination.bucket, destination.key) + // TODO: Copy other attributes (encryption, metadata, etc.) + source.ossClient.copyObject(getObjectRequest) + } + + + override def mapOssResponse(response: CopyObjectResult): Unit = () +} + +case class OssBatchDeleteCommand( + override val file: OssPath, + override val swallowIOExceptions: Boolean + ) extends IoDeleteCommand(file, swallowIOExceptions) with OssBatchIoCommand[Unit, Void] { + def operation = file.ossClient.deleteObject(file.bucket, file.key) + override protected def mapOssResponse(response: Void): Unit = () +} + +/** + * Base trait for commands that use the headObject() operation. (e.g: size, crc32, ...) + */ +sealed trait OssBatchHeadCommand[T] extends OssBatchIoCommand[T, ObjectMetadata] { + def file: OssPath + + override def operation: ObjectMetadata = file.ossClient.getObjectMetadata(file.bucket, file.key) +} + +case class OssBatchSizeCommand(override val file: OssPath) extends IoSizeCommand(file) with OssBatchHeadCommand[Long] { + override def mapOssResponse(response: ObjectMetadata): Long = response.getContentLength +} + +case class OssBatchEtagCommand(override val file: OssPath) extends IoHashCommand(file) with OssBatchHeadCommand[String] { + override def mapOssResponse(response: ObjectMetadata): String = response.getETag +} + +case class OssBatchTouchCommand(override val file: OssPath) extends IoTouchCommand(file) with OssBatchHeadCommand[Unit] { + override def mapOssResponse(response: ObjectMetadata): Unit = () +} + +case class OssBatchExistsCommand(override val file: OssPath) extends IoExistsCommand(file) with OssBatchIoCommand[Boolean, Boolean] { + override def operation: Boolean = { + file.ossClient.doesObjectExist(file.bucket, file.key) + } + + override def mapOssResponse(response: Boolean): Boolean = response +} diff --git a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssFileReadChannel.scala b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssFileReadChannel.scala index 3bd38efa317..1fb283b85e0 100644 --- a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssFileReadChannel.scala +++ b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssFileReadChannel.scala @@ -61,6 +61,7 @@ final case class OssFileReadChannel(ossClient: OSSClient, pos: Long, path: OssSt val channel = Channels.newChannel(in) val amt = channel.read(dst) + channel.close() internalPosition += amt amt } diff --git a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileAttributesView.scala b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileAttributesView.scala index 49993e221ce..cba65649a98 100644 --- a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileAttributesView.scala +++ b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileAttributesView.scala @@ -2,9 +2,10 @@ package cromwell.filesystems.oss.nio import java.nio.file.NoSuchFileException import java.nio.file.attribute.{BasicFileAttributeView, FileTime} +import java.util.Date import com.aliyun.oss.OSSClient -import com.aliyun.oss.model.GenericRequest +import com.aliyun.oss.model.{CopyObjectRequest, CopyObjectResult, GenericRequest} import scala.util.Try @@ -33,5 +34,12 @@ final case class OssStorageFileAttributesView(ossClient: OSSClient, path: OssSto OssStorageObjectAttributes(objectMeta, path) } - override def setTimes(lastModifiedTime: FileTime, lastAccessTime: FileTime, createTime: FileTime): Unit = throw new UnsupportedOperationException("OSS object is immutable") + override def setTimes(lastModifiedTime: FileTime, lastAccessTime: FileTime, createTime: FileTime): Unit = { + val meta = ossClient.getObjectMetadata(path.bucket, path.key) + meta.setLastModified(new Date(lastModifiedTime.toMillis)) + + val copyReq = new CopyObjectRequest(path.bucket, path.key, path.bucket, path.key) + copyReq.setNewObjectMetadata(meta) + val _: CopyObjectResult = ossClient.copyObject(copyReq) + } } diff --git a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileSystem.scala b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileSystem.scala index bb1199b2317..aa704561156 100644 --- a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileSystem.scala +++ b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileSystem.scala @@ -8,6 +8,7 @@ import java.{lang, util} import com.aliyun.oss.common.auth.DefaultCredentialProvider import com.aliyun.oss.{ClientConfiguration, OSSClient} +import cromwell.filesystems.oss.nio.OssStorageConfiguration.{ACCESS_ID_KEY, ACCESS_KEY_KEY, ENDPOINT_KEY, SECURITY_TOKEN_KEY} import scala.collection.JavaConverters._ @@ -52,7 +53,7 @@ object OssStorageConfiguration { case _ => None } - new OssStorageConfiguration(endpoint, accessId, accessKey, securityToken) + new DefaultOssStorageConfiguration(endpoint, accessId, accessKey, securityToken) } def getClient(map: Map[String, String]): OSSClient = { @@ -63,26 +64,28 @@ object OssStorageConfiguration { accessId: String, accessKey: String, stsToken: Option[String]): OSSClient = { - OssStorageConfiguration(endpoint, accessId, accessKey, stsToken).newOssClient() + DefaultOssStorageConfiguration(endpoint, accessId, accessKey, stsToken).newOssClient() } } -final case class OssStorageConfiguration(endpoint: String, - accessId: String, - accessKey: String, - stsToken: Option[String] = None - ) { - import OssStorageConfiguration._ +trait OssStorageConfiguration { + def endpoint: String + + def accessId: String + + def accessKey: String + + def securityToken: Option[String] def toMap: Map[String, String] = { val ret = Map(ENDPOINT_KEY -> endpoint, ACCESS_ID_KEY -> accessId, ACCESS_KEY_KEY -> accessKey) - val token = stsToken map {token => SECURITY_TOKEN_KEY -> token} + val token = securityToken map {token => SECURITY_TOKEN_KEY -> token} ret ++ token } def newOssClient() = { - val credentialsProvider = stsToken match { + val credentialsProvider = securityToken match { case Some(token: String) => new DefaultCredentialProvider(accessId, accessKey, token) case None => @@ -91,8 +94,11 @@ final case class OssStorageConfiguration(endpoint: String, val clientConfiguration = new ClientConfiguration new OSSClient(endpoint, credentialsProvider, clientConfiguration) } + } +case class DefaultOssStorageConfiguration(endpoint: String, accessId: String, accessKey: String, securityToken: Option[String] = None) extends OssStorageConfiguration {} + case class OssStorageFileSystem(bucket: String, config: OssStorageConfiguration) extends FileSystem { var internalProvider: OssStorageFileSystemProvider = OssStorageFileSystemProvider(config) diff --git a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileSystemProvider.scala b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileSystemProvider.scala index 25ecbbd3cda..0f416b0f920 100644 --- a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileSystemProvider.scala +++ b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/OssStorageFileSystemProvider.scala @@ -18,7 +18,7 @@ import collection.mutable.ArrayBuffer final case class OssStorageFileSystemProvider(config: OssStorageConfiguration) extends FileSystemProvider { - lazy val ossClient: OSSClient = config.newOssClient() + def ossClient: OSSClient = config.newOssClient() class PathIterator(ossClient: OSSClient, prefix: OssStoragePath, filter: DirectoryStream.Filter[_ >: Path]) extends AbstractIterator[Path] { var nextMarker: Option[String] = None @@ -139,7 +139,7 @@ final case class OssStorageFileSystemProvider(config: OssStorageConfiguration) e for (opt <- options.asScala) { opt match { case StandardOpenOption.READ => - case StandardOpenOption.WRITE => throw new IllegalArgumentException("WRITE byte channel not allowed currently") + case StandardOpenOption.WRITE => throw new IllegalArgumentException(s"WRITE byte channel not allowed currently, $path") case StandardOpenOption.SPARSE | StandardOpenOption.TRUNCATE_EXISTING => case StandardOpenOption.APPEND | StandardOpenOption.CREATE | StandardOpenOption.DELETE_ON_CLOSE | StandardOpenOption.CREATE_NEW | StandardOpenOption.DSYNC | StandardOpenOption.SYNC => throw new UnsupportedOperationException() @@ -149,6 +149,12 @@ final case class OssStorageFileSystemProvider(config: OssStorageConfiguration) e OssFileReadChannel(ossClient, 0, path.asInstanceOf[OssStoragePath]) } + def doesObjectExist(bucket: String, name: String): Boolean = { + val req = new GenericRequest(bucket, name) + req.setLogEnabled(false) + ossClient.doesBucketExist(req) + } + override def createDirectory(dir: Path, attrs: FileAttribute[_]*): Unit = {} override def deleteIfExists(path: Path): Boolean = { diff --git a/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/TTLOssStorageConfiguration.scala b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/TTLOssStorageConfiguration.scala new file mode 100644 index 00000000000..e87f59c25bb --- /dev/null +++ b/filesystems/oss/src/main/scala/cromwell/filesystems/oss/nio/TTLOssStorageConfiguration.scala @@ -0,0 +1,47 @@ +package cromwell.filesystems.oss.nio + +import com.aliyun.oss.OSSClient +import com.typesafe.config.Config +import net.ceedubs.ficus.Ficus._ + +object TTLOssStorageConfiguration { + def currentTimestamp = System.currentTimeMillis / 1000 + + def defaultRefreshInterval: Long = 30 * 60 + + val RefreshInterval = "refresh-interval" + + def apply(config: Config): TTLOssStorageConfiguration = new TTLOssStorageConfiguration(config) +} + +/* Unsupported. For test purposes only. */ +class TTLOssStorageConfiguration(config: Config) extends OssStorageConfiguration { + + override def endpoint: String = config.as[Option[String]](authPath(OssStorageConfiguration.ENDPOINT_KEY)) getOrElse("") + + override def accessId: String = config.as[Option[String]](authPath(OssStorageConfiguration.ACCESS_ID_KEY)) getOrElse("") + + override def accessKey: String = config.as[Option[String]](authPath(OssStorageConfiguration.ACCESS_KEY_KEY)) getOrElse("") + + override def securityToken: Option[String] = config.as[Option[String]](authPath(OssStorageConfiguration.SECURITY_TOKEN_KEY)) + + def refreshInterval: Long = config.as[Option[Long]](TTLOssStorageConfiguration.RefreshInterval).getOrElse(TTLOssStorageConfiguration.defaultRefreshInterval) + + private def authPath(key: String): String = s"auth.$key" + private var lastClientUpdateTime: Long = 0 + + private var oldClient: Option[OSSClient] = None + + override def newOssClient(): OSSClient = { + val current = TTLOssStorageConfiguration.currentTimestamp + synchronized { + if (lastClientUpdateTime == 0 || current - lastClientUpdateTime > refreshInterval) { + oldClient = Option(super.newOssClient()) + lastClientUpdateTime = current + } + } + + oldClient getOrElse(throw new IllegalArgumentException("Non oss client")) + } +} + diff --git a/filesystems/oss/src/test/scala/cromwell/filesystems/oss/OssPathBuilderSpec.scala b/filesystems/oss/src/test/scala/cromwell/filesystems/oss/OssPathBuilderSpec.scala index c333494581e..4678f28d784 100644 --- a/filesystems/oss/src/test/scala/cromwell/filesystems/oss/OssPathBuilderSpec.scala +++ b/filesystems/oss/src/test/scala/cromwell/filesystems/oss/OssPathBuilderSpec.scala @@ -1,13 +1,47 @@ package cromwell.filesystems.oss +import com.typesafe.config.ConfigFactory import cromwell.core.TestKitSuite import cromwell.filesystems.oss.nio.OssNioUtilSpec import org.scalatest.{BeforeAndAfter, FlatSpecLike, Matchers} import org.scalatest.TryValues._ +object OssPathBuilderSpec { + + val BcsBackendConfigWithRefreshString = + s""" + | refresh-interval = 1800 + | auth { + | endpoint = "oss-cn-shanghai.aliyuncs.com" + | access-id = "test-access-id" + | access-key = "test-access-key" + | security-token = "test-security-token" + | } + | caching { + | duplication-strategy = "reference" + | } + """.stripMargin + + val BcsBackendConfigWithRefresh = ConfigFactory.parseString(BcsBackendConfigWithRefreshString) + + val BcsBackendConfigWithoutRefreshString = + s""" + | auth { + | endpoint = "oss-cn-shanghai.aliyuncs.com" + | access-id = "test-access-id" + | access-key = "test-access-key" + | } + | caching { + | duplication-strategy = "reference" + | } + """.stripMargin + + val BcsBackendConfigWithoutRefresh = ConfigFactory.parseString(BcsBackendConfigWithoutRefreshString) +} + class OssPathBuilderSpec extends TestKitSuite with FlatSpecLike with Matchers with OssNioUtilSpec with BeforeAndAfter { - behavior of s"OssPathBuilerSpec" + behavior of "OssPathBuilerSpec" val testPathBuiler = OssPathBuilder(mockOssConf) it should "throw when no bucket in URI" in { @@ -32,4 +66,15 @@ class OssPathBuilderSpec extends TestKitSuite with FlatSpecLike with Matchers wi path.pathAsString shouldBe s"oss://$bucket$fileName" path.pathWithoutScheme shouldBe s"$bucket$fileName" } + + it should "success from config" in { + val ossPathBuilder = OssPathBuilder.fromConfig(OssPathBuilderSpec.BcsBackendConfigWithRefresh, null) + ossPathBuilder.build(s"oss://$bucket").success.value.bucket shouldBe bucket + ossPathBuilder.build(s"oss://$bucket").success.value.key shouldBe empty + + val ossPathBuilderWithoutRefresh = OssPathBuilder.fromConfig(OssPathBuilderSpec.BcsBackendConfigWithoutRefresh, null) + ossPathBuilderWithoutRefresh.build(s"oss://$bucket").success.value.bucket shouldBe bucket + ossPathBuilderWithoutRefresh.build(s"oss://$bucket").success.value.key shouldBe empty + } + } diff --git a/filesystems/oss/src/test/scala/cromwell/filesystems/oss/nio/OssNioUtilSpec.scala b/filesystems/oss/src/test/scala/cromwell/filesystems/oss/nio/OssNioUtilSpec.scala index 93536fb2ed1..41e25ba58dd 100644 --- a/filesystems/oss/src/test/scala/cromwell/filesystems/oss/nio/OssNioUtilSpec.scala +++ b/filesystems/oss/src/test/scala/cromwell/filesystems/oss/nio/OssNioUtilSpec.scala @@ -54,7 +54,7 @@ trait OssNioUtilSpec extends FlatSpecLike with MockitoSugar with Matchers { OssStorageConfiguration.parseMap(ossInfo) } getOrElse(throw new IllegalArgumentException("you should supply oss info before testing oss related operation")) - lazy val mockOssConf: OssStorageConfiguration = new OssStorageConfiguration("mock-endpoint", "mock-id", "mock-key", None) + lazy val mockOssConf: OssStorageConfiguration = new DefaultOssStorageConfiguration("mock-endpoint", "mock-id", "mock-key", None) lazy val ossProvider = OssStorageFileSystemProvider(ossConf) lazy val mockProvider = OssStorageFileSystemProvider(mockOssConf) diff --git a/filesystems/oss/src/test/scala/cromwell/filesystems/oss/nio/TTLOssStorageConfigurationSpec.scala b/filesystems/oss/src/test/scala/cromwell/filesystems/oss/nio/TTLOssStorageConfigurationSpec.scala new file mode 100644 index 00000000000..afddcfb0edb --- /dev/null +++ b/filesystems/oss/src/test/scala/cromwell/filesystems/oss/nio/TTLOssStorageConfigurationSpec.scala @@ -0,0 +1,50 @@ +package cromwell.filesystems.oss.nio + +import java.net.URI +import com.typesafe.config.ConfigFactory +import cromwell.core.TestKitSuite +import org.scalatest.{BeforeAndAfter, FlatSpecLike, Matchers} +import org.scalatest.mockito.MockitoSugar + + +object TTLOssStorageConfigurationSpec { + + val BcsBackendConfigString = + s""" + | auth { + | endpoint = "oss-cn-shanghai.aliyuncs.com" + | access-id = "test-access-id" + | access-key = "test-access-key" + | security-token = "test-security-token" + | } + | caching { + | duplication-strategy = "reference" + | } + """.stripMargin + + val BcsBackendConfig = ConfigFactory.parseString(BcsBackendConfigString) +} + +class TTLOssStorageConfigurationSpec extends TestKitSuite with FlatSpecLike with Matchers with MockitoSugar with BeforeAndAfter { + val expectedEndpoint = "oss-cn-shanghai.aliyuncs.com" + val expectedAccessId = "test-access-id" + val expectedAccessKey = "test-access-key" + val expectedToken = Some("test-security-token") + val expectedFullEndpoint = URI.create("http://oss-cn-shanghai.aliyuncs.com") + + behavior of "TTLOssStorageConfiguration" + + + it should "have correct OSS credential info" in { + + val ossConfig = TTLOssStorageConfiguration(TTLOssStorageConfigurationSpec.BcsBackendConfig) + + ossConfig.endpoint shouldEqual expectedEndpoint + ossConfig.accessId shouldEqual expectedAccessId + ossConfig.accessKey shouldEqual expectedAccessKey + ossConfig.securityToken shouldEqual expectedToken + + ossConfig.newOssClient().getEndpoint shouldEqual expectedFullEndpoint + + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index a375ca2b47e..0a909cb675a 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -4,8 +4,9 @@ object Dependencies { private val akkaHttpCirceIntegrationV = "1.24.3" private val akkaHttpV = "10.1.7" private val akkaV = "2.5.19" - private val aliyunBcsV = "6.0.6" + private val aliyunBcsV = "6.1.0" private val aliyunCoreV = "4.3.2" + private val aliyunCrV = "3.0.0" private val aliyunOssV = "3.4.0" private val ammoniteOpsV = "1.6.3" private val apacheCommonNetV = "3.6" @@ -321,6 +322,15 @@ object Dependencies { exclude("jakarta.activation", "jakarta.activation-api"), ) + private val aliyunCrDependencies = List( + "com.aliyun" % "aliyun-java-sdk-cr" % aliyunCrV, + "com.aliyun" % "aliyun-java-sdk-core" % aliyunCoreV + exclude("javax.xml.bind", "jaxb-api") + exclude("com.sun.xml.bind", "jaxb-core") + exclude("javax.activation", "activation"), + "com.typesafe.akka" %% "akka-http-spray-json" % akkaHttpV + ) + private val dbmsDependencies = List( "org.hsqldb" % "hsqldb" % hsqldbV, "org.mariadb.jdbc" % "mariadb-java-client" % mariadbV, @@ -446,7 +456,7 @@ object Dependencies { val databaseMigrationDependencies = liquibaseDependencies ++ dbmsDependencies - val dockerHashingDependencies = http4sDependencies ++ circeDependencies + val dockerHashingDependencies = http4sDependencies ++ circeDependencies ++ aliyunCrDependencies val cromwellApiClientDependencies = List( "org.scalaz" %% "scalaz-core" % scalazV, diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsAsyncBackendJobExecutionActor.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsAsyncBackendJobExecutionActor.scala index 38757b6e735..5322d91b405 100644 --- a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsAsyncBackendJobExecutionActor.scala +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsAsyncBackendJobExecutionActor.scala @@ -1,11 +1,10 @@ package cromwell.backend.impl.bcs -import java.io.FileNotFoundException - import better.files.File.OpenOptions import com.aliyuncs.batchcompute.main.v20151111.BatchComputeClient import com.aliyuncs.exceptions.{ClientException, ServerException} import common.collections.EnhancedCollections._ +import common.util.StringUtil._ import cromwell.backend._ import cromwell.backend.async.{ExecutionHandle, FailedNonRetryableExecutionHandle, PendingExecutionHandle} import cromwell.backend.impl.bcs.RunStatus.{Finished, TerminalRunStatus} @@ -15,10 +14,12 @@ import cromwell.core.retry.SimpleExponentialBackoff import cromwell.core.ExecutionEvent import cromwell.filesystems.oss.OssPath import wom.callable.Callable.OutputDefinition +import wom.callable.RuntimeEnvironment import wom.core.FullyQualifiedName import wom.expression.NoIoFunctionSet import wom.types.WomSingleFileType import wom.values._ +import mouse.all._ import scala.concurrent.Future import scala.concurrent.duration._ @@ -65,8 +66,8 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa val tmp = DefaultPathBuilder.get("/" + ossPath.pathWithoutScheme) val dir = tmp.getParent val local = BcsJobPaths.BcsTempInputDirectory.resolve(dir.pathAsString.md5SumShort).resolve(tmp.getFileName) - val ret = BcsInputMount(ossPath, local, writeSupport = false) - if (!inputMounts.exists(mount => mount.src == ossPath && mount.dest == local)) { + val ret = BcsInputMount(Left(ossPath), Left(local), writeSupport = false) + if (!inputMounts.exists(mount => mount.src == Left(ossPath) && mount.dest == Left(local))) { inputMounts :+= ret } @@ -74,7 +75,7 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa } private[bcs] def womFileToMount(file: WomFile): Option[BcsInputMount] = file match { - case path if userDefinedMounts exists(bcsMount => path.valueString.startsWith(bcsMount.src.pathAsString)) => None + case path if userDefinedMounts exists(bcsMount => path.valueString.startsWith(BcsMount.toString(bcsMount.src))) => None case path => PathFactory.buildPath(path.valueString, initializationData.pathBuilders) match { case ossPath: OssPath => Some(ossPathToMount(ossPath)) case _ => None @@ -146,8 +147,8 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa callRawOutputFiles.flatMap(_.flattenFiles).distinct flatMap { womFile => womFile match { case singleFile: WomSingleFile => List(generateBcsSingleFileOutput(singleFile)) - case _: WomGlobFile => throw new RuntimeException(s"glob output not supported currently") - case _: WomUnlistedDirectory => throw new RuntimeException(s"directory output not supported currently") + case globFile: WomGlobFile => generateBcsGlobFileOutputs(globFile) + case unlistedDirectory: WomUnlistedDirectory => generateUnlistedDirectoryOutputs(unlistedDirectory) } } } @@ -161,7 +162,34 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa val src = relativePath(wdlFile.valueString) - BcsOutputMount(src, destination, writeSupport = false) + BcsOutputMount(Left(src), Left(destination), writeSupport = false) + } + + protected def generateBcsGlobFileOutputs(womFile: WomGlobFile): List[BcsOutputMount] = { + val globName = GlobFunctions.globName(womFile.value) + val globDirectory = globName + "/" + val globListFile = globName + ".list" + val bcsGlobDirectoryDestinationPath = callRootPath.resolve(globDirectory) + val bcsGlobListFileDestinationPath = callRootPath.resolve(globListFile) + + // We need both the glob directory and the glob list: + List( + BcsOutputMount(Left(relativePath(globDirectory)), Left(bcsGlobDirectoryDestinationPath), writeSupport = false), + BcsOutputMount(Left(relativePath(globListFile)), Left(bcsGlobListFileDestinationPath), writeSupport = false) + ) + } + + private def generateUnlistedDirectoryOutputs(womFile: WomUnlistedDirectory): List[BcsOutputMount] = { + val directoryPath = womFile.value.ensureSlashed + val directoryListFile = womFile.value.ensureUnslashed + ".list" + val bcsDirDestinationPath = callRootPath.resolve(directoryPath) + val bcsListDestinationPath = callRootPath.resolve(directoryListFile) + + // We need both the collection directory and the collection list: + List( + BcsOutputMount(Left(relativePath(directoryPath)), Left(bcsDirDestinationPath), writeSupport = false), + BcsOutputMount(Left(relativePath(directoryListFile)), Left(bcsListDestinationPath), writeSupport = false) + ) } private[bcs] def getOssFileName(ossPath: OssPath): String = { @@ -174,17 +202,23 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa private[bcs] def localizeOssPath(ossPath: OssPath): String = { if (isOutputOssFileString(ossPath.pathAsString) && !ossPath.isAbsolute) { if (ossPath.exists) { - ossPathToMount(ossPath).dest.normalize().pathAsString + ossPathToMount(ossPath).dest match { + case Left(p) => p.normalize().pathAsString + case _ => throw new RuntimeException("only support oss") + } } else { commandDirectory.resolve(getOssFileName(ossPath)).normalize().pathAsString } } else { userDefinedMounts collectFirst { - case bcsMount: BcsMount if ossPath.pathAsString.startsWith(bcsMount.src.pathAsString) => - bcsMount.dest.resolve(ossPath.pathAsString.stripPrefix(bcsMount.src.pathAsString)).pathAsString + case bcsMount: BcsMount if ossPath.pathAsString.startsWith(BcsMount.toString(bcsMount.src)) => + bcsMount.dest match { + case Left(p) => p.resolve(ossPath.pathAsString.stripPrefix(BcsMount.toString(bcsMount.src))).pathAsString + case _ => throw new RuntimeException("only support oss") + } } getOrElse { val mount = ossPathToMount(ossPath) - mount.dest.pathAsString + BcsMount.toString(mount.dest) } } } @@ -197,7 +231,7 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa } } - override def mapCommandLineWomFile(womFile: WomFile): WomFile = { + private[bcs] def mapWomFile(womFile: WomFile): WomFile = { getPath(womFile.valueString) match { case Success(ossPath: OssPath) => WomFile(WomSingleFileType, localizeOssPath(ossPath)) @@ -207,6 +241,24 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa } } + override def preProcessWomFile(womFile: WomFile): WomFile = mapWomFile(womFile) + + override def mapCommandLineWomFile(womFile: WomFile): WomFile = mapWomFile(womFile) + + override def runtimeEnvironmentPathMapper(env: RuntimeEnvironment): RuntimeEnvironment = { + def localize(path: String): String = (WomSingleFile(path) |> mapRuntimeEnvs).valueString + env.copy(outputPath = env.outputPath |> localize, tempPath = env.tempPath |> localize) + } + + private[bcs] def mapRuntimeEnvs(womFile: WomSingleFile): WomFile = { + getPath(womFile.valueString) match { + case Success(ossPath: OssPath) => + WomFile(WomSingleFileType, BcsJobPaths.BcsCommandDirectory.resolve(ossPath.pathWithoutScheme).pathAsString) + case _ => womFile + } + + } + override def isTerminal(runStatus: RunStatus): Boolean = { runStatus match { case _ : TerminalRunStatus => true @@ -246,33 +298,17 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa } private[bcs] lazy val rcBcsOutput = BcsOutputMount( - commandDirectory.resolve(bcsJobPaths.returnCodeFilename), bcsJobPaths.returnCode, writeSupport = false) + Left(commandDirectory.resolve(bcsJobPaths.returnCodeFilename)), Left(bcsJobPaths.returnCode), writeSupport = false) private[bcs] lazy val stdoutBcsOutput = BcsOutputMount( - commandDirectory.resolve(bcsJobPaths.defaultStdoutFilename), standardPaths.output, writeSupport = false) + Left(commandDirectory.resolve(bcsJobPaths.defaultStdoutFilename)), Left(standardPaths.output), writeSupport = false) private[bcs] lazy val stderrBcsOutput = BcsOutputMount( - commandDirectory.resolve(bcsJobPaths.defaultStderrFilename), standardPaths.error, writeSupport = false) + Left(commandDirectory.resolve(bcsJobPaths.defaultStderrFilename)), Left(standardPaths.error), writeSupport = false) private[bcs] lazy val uploadBcsWorkerPackage = { - getPath(runtimeAttributes.workerPath.getOrElse(bcsJobPaths.workerFileName)) match { - case Success(ossPath: OssPath) => - if (ossPath.notExists) { - throw new FileNotFoundException(s"$ossPath") - } - ossPath - case Success(path: Path) => - if (path.notExists) { - throw new FileNotFoundException(s"$path") - } + bcsJobPaths.workerPath.writeByteArray(BcsJobCachingActorHelper.workerScript.getBytes)(OpenOptions.default) - if (bcsJobPaths.workerPath.notExists) { - val content = path.byteArray - bcsJobPaths.workerPath.writeByteArray(content)(OpenOptions.default) - } - - bcsJobPaths.workerPath - case _ => throw new RuntimeException(s"Invalid worker packer path") - } + bcsJobPaths.workerPath } override def executeAsync(): Future[ExecutionHandle] = { @@ -283,13 +319,15 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa setBcsVerbose() + val envs = bcsEnvs + val bcsJob = new BcsJob( jobName, jobTag, bcsCommandLine, uploadBcsWorkerPackage, bcsMounts, - bcsEnvs, + envs, runtimeAttributes, Some(bcsJobPaths.bcsStdoutPath), Some(bcsJobPaths.bcsStderrPath), @@ -315,7 +353,7 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa private[bcs] def wdlFileToOssPath(bcsOutputs: Seq[BcsMount])(wdlFile: WomFile): WomFile = { bcsOutputs collectFirst { - case bcsOutput if bcsOutput.src.pathAsString.endsWith(wdlFile.valueString) => WomFile(WomSingleFileType, bcsOutput.dest.pathAsString) + case bcsOutput if BcsMount.toString(bcsOutput.src).endsWith(wdlFile.valueString) => WomFile(WomSingleFileType, BcsMount.toString(bcsOutput.dest)) } getOrElse wdlFile } @@ -353,6 +391,7 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa throwable match { case _: ServerException => true case e: ClientException if e.getErrCode == "InternalError" => true + case e: ClientException if e.getErrCode.startsWith("Throttling") => true case _ => false } } @@ -364,16 +403,16 @@ final class BcsAsyncBackendJobExecutionActor(override val standardParams: Standa } } - private[bcs] lazy val bcsEnvs: Map[String, String] = Map( + private[bcs] lazy val bcsEnvs: Map[String, String] = { + val mount = ossPathToMount(bcsJobPaths.script.asInstanceOf[OssPath]) + + Map( BcsJobPaths.BcsEnvCwdKey -> commandDirectory.pathAsString, - BcsJobPaths.BcsEnvExecKey -> bcsJobPaths.script.pathAsString, + BcsJobPaths.BcsEnvExecKey -> BcsMount.toString(mount.dest), BcsJobPaths.BcsEnvStdoutKey -> commandDirectory.resolve(bcsJobPaths.defaultStdoutFilename).pathAsString, - BcsJobPaths.BcsEnvStderrKey -> commandDirectory.resolve(bcsJobPaths.defaultStderrFilename).pathAsString, - BcsConfiguration.OssEndpointKey -> bcsConfiguration.ossEndpoint, - BcsConfiguration.OssIdKey -> bcsConfiguration.ossAccessId, - BcsConfiguration.OssSecretKey -> bcsConfiguration.ossAccessKey, - BcsConfiguration.OssTokenKey -> bcsConfiguration.ossSecurityToken - ) + BcsJobPaths.BcsEnvStderrKey -> commandDirectory.resolve(bcsJobPaths.defaultStderrFilename).pathAsString + ) + } private[bcs] lazy val bcsMounts: Seq[BcsMount] ={ generateBcsInputs(jobDescriptor) diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsBackendLifecycleActorFactory.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsBackendLifecycleActorFactory.scala index ad6dece2f80..46cd4d13cc4 100644 --- a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsBackendLifecycleActorFactory.scala +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsBackendLifecycleActorFactory.scala @@ -3,8 +3,14 @@ package cromwell.backend.impl.bcs import akka.actor.ActorRef import cromwell.backend.{BackendConfigurationDescriptor, BackendWorkflowDescriptor} import cromwell.backend.standard._ +import cromwell.backend.BackendInitializationData +import cromwell.backend.impl.bcs.callcaching.BcsBackendCacheHitCopyingActor +import cromwell.backend.standard.callcaching.StandardCacheHitCopyingActor import wom.graph.CommandCallNode +import scala.util.{Success, Try} + + final case class BcsBackendLifecycleActorFactory(val name: String, val configurationDescriptor: BackendConfigurationDescriptor) extends StandardLifecycleActorFactory { override lazy val initializationActorClass: Class[_ <: StandardInitializationActor] = classOf[BcsInitializationActor] @@ -17,4 +23,16 @@ final case class BcsBackendLifecycleActorFactory(val name: String, val configura override def workflowInitializationActorParams(workflowDescriptor: BackendWorkflowDescriptor, ioActor: ActorRef, calls: Set[CommandCallNode], serviceRegistryActor: ActorRef, restarting: Boolean): StandardInitializationActorParams = { BcsInitializationActorParams(workflowDescriptor, calls, bcsConfiguration, serviceRegistryActor) } + + override lazy val cacheHitCopyingActorClassOption: Option[Class[_ <: StandardCacheHitCopyingActor]] = { + Option(classOf[BcsBackendCacheHitCopyingActor]) + } + + override def dockerHashCredentials(workflowDescriptor: BackendWorkflowDescriptor, initializationData: Option[BackendInitializationData]) = { + Try(BackendInitializationData.as[BcsBackendInitializationData](initializationData)) match { + case Success(bcsData) => + List(bcsData.bcsConfiguration.dockerCredentials).flatten + case _ => List.empty[Any] + } + } } diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsClusterIdOrConfiguration.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsClusterIdOrConfiguration.scala index 8043491d4c4..15b12a72de8 100644 --- a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsClusterIdOrConfiguration.scala +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsClusterIdOrConfiguration.scala @@ -8,7 +8,8 @@ final case class AutoClusterConfiguration(resourceType: String, instanceType: String, imageId: String, spotStrategy: Option[String] = None, - spotPriceLimit: Option[Float] = None) + spotPriceLimit: Option[Float] = None, + clusterId: Option[String] = None) object BcsClusterIdOrConfiguration { @@ -33,14 +34,23 @@ object BcsClusterIdOrConfiguration { val spotPattern = s"""$resourceAndInstanceAndImagePattern\\s+$spotStrategyPattern\\s+$spotPriceLimitPattern""".r + val attachClusterSimplePattern = s"""$instanceAndImagePattern\\s+$idPattern""".r + + val attachClusterPattern = s"""$resourceAndInstanceAndImagePattern\\s+$idPattern""".r + + val attachCllusterSpotPattern = s"""$spotPattern\\s+$idPattern""".r + def parse(cluster: String): Try[BcsClusterIdOrConfiguration] = { cluster match { case idPattern(clusterId) => Success(Left(clusterId)) case instanceAndImagePattern(instanceType, imageId) => Success(Right(AutoClusterConfiguration(defaultResourceType, instanceType, imageId))) + case attachClusterSimplePattern(instanceType, imageId, clusterId) =>Success(Right(AutoClusterConfiguration(defaultResourceType, instanceType, imageId, clusterId=Option(clusterId)))) case resourceAndInstanceAndImagePattern(resourceType, instanceType, imageId) => Success(Right(AutoClusterConfiguration(resourceType, instanceType, imageId))) - case spotPattern(resourceType, instanceType, imageId, spotStrategy, spotPriceLimit) => Success(Right(AutoClusterConfiguration(resourceType, instanceType, imageId, Some(spotStrategy), Some(spotPriceLimit.toFloat)))) - case _ => Failure(new IllegalArgumentException("must be some string like 'cls-xxxx' or 'OnDemand ecs.s1.large img-ubuntu'")) + case attachClusterPattern(resourceType, instanceType, imageId, clusterId) => Success(Right(AutoClusterConfiguration(resourceType, instanceType, imageId, clusterId = Option(clusterId)))) + case spotPattern(resourceType, instanceType, imageId, spotStrategy, spotPriceLimit) => Success(Right(AutoClusterConfiguration(resourceType, instanceType, imageId, Option(spotStrategy), Option(spotPriceLimit.toFloat)))) + case attachCllusterSpotPattern(resourceType, instanceType, imageId, spotStrategy, spotPriceLimit, clusterId) => Success(Right(AutoClusterConfiguration(resourceType, instanceType, imageId, Option(spotStrategy), Option(spotPriceLimit.toFloat), Option(clusterId)))) + case _ => Failure(new IllegalArgumentException("must be some string like 'cls-xxxx' or 'OnDemand ecs.s1.large img-ubuntu' or 'OnDemand ecs.s1.large img-ubuntu cls-xxxx'")) } } } diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsConfiguration.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsConfiguration.scala index a154e39863f..f0de1049e6e 100644 --- a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsConfiguration.scala +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsConfiguration.scala @@ -1,9 +1,11 @@ package cromwell.backend.impl.bcs -import com.aliyuncs.batchcompute.main.v20151111.{BatchComputeClient} +import com.aliyuncs.auth.BasicCredentials +import com.aliyuncs.batchcompute.main.v20151111.BatchComputeClient import cromwell.backend.BackendConfigurationDescriptor import net.ceedubs.ficus.Ficus._ - +import cromwell.backend.impl.bcs.callcaching.{CopyCachedOutputs, UseOriginalCachedOutputs} +import cromwell.core.DockerConfiguration object BcsConfiguration{ val OssEndpointKey = "ossEndpoint" @@ -31,6 +33,25 @@ final class BcsConfiguration(val configurationDescriptor: BackendConfigurationDe val ossAccessKey = configurationDescriptor.backendConfig.as[Option[String]]("filesystems.oss.auth.access-key").getOrElse("") val ossSecurityToken = configurationDescriptor.backendConfig.as[Option[String]]("filesystems.oss.auth.security-token").getOrElse("") + val duplicationStrategy = { + configurationDescriptor.backendConfig.as[Option[String]]("filesystems.oss.caching.duplication-strategy").getOrElse("reference") match { + case "copy" => CopyCachedOutputs + case "reference" => UseOriginalCachedOutputs + case other => throw new IllegalArgumentException(s"Unrecognized caching duplication strategy: $other. Supported strategies are copy and reference. See reference.conf for more details.") + } + } + + lazy val dockerHashAccessId = DockerConfiguration.dockerHashLookupConfig.as[Option[String]]("alibabacloudcr.auth.access-id") + lazy val dockerHashAccessKey = DockerConfiguration.dockerHashLookupConfig.as[Option[String]]("alibabacloudcr.auth.access-key") + lazy val dockerHashSecurityToken = DockerConfiguration.dockerHashLookupConfig.as[Option[String]]("alibabacloudcr.auth.security-token") + + val dockerCredentials = { + for { + id <- dockerHashAccessId + key <- dockerHashAccessKey + } yield new BasicCredentials(id, key) + } + val bcsClient: Option[BatchComputeClient] = { val userDefinedRegion = for { region <- bcsUserDefinedRegion diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJob.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJob.scala index 62880d49aff..2dd005556c2 100644 --- a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJob.scala +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJob.scala @@ -7,7 +7,7 @@ import cromwell.core.ExecutionEvent import cromwell.core.path.Path import collection.JavaConverters._ -import scala.util.Try +import scala.util.{Failure, Success, Try} object BcsJob{ val BcsDockerImageEnvKey = "BATCH_COMPUTE_DOCKER_IMAGE" @@ -40,7 +40,7 @@ final case class BcsJob(name: String, jobId } - def getStatus(jobId: String): Try[RunStatus] = { + def getStatus(jobId: String): Try[RunStatus] = Try{ val request: GetJobRequest = new GetJobRequest request.setJobId(jobId) val response: GetJobResponse = batchCompute.getJob(request) @@ -48,7 +48,10 @@ final case class BcsJob(name: String, val status = job.getState val message = job.getMessage val eventList = Seq[ExecutionEvent]() - RunStatusFactory.getStatus(jobId, status, Some(message), Some(eventList)) + RunStatusFactory.getStatus(jobId, status, Some(message), Some(eventList)) match { + case Success(status) => status + case Failure(e) => throw e + } } def cancel(jobId: String): Unit = { @@ -129,6 +132,7 @@ final case class BcsJob(name: String, lazyCmd.setEnvVars(environments.asJava) lazyCmd.setCommandLine(commandString) + dockers foreach {docker => lazyCmd.setDocker(docker)} stdoutPath foreach {path => parames.setStdoutRedirectPath(path.normalize().pathAsString + "/")} stderrPath foreach {path => parames.setStderrRedirectPath(path.normalize().pathAsString + "/")} @@ -136,10 +140,26 @@ final case class BcsJob(name: String, parames } - private[bcs] def environments: Map[String, String] = runtime.docker match { - case Some(docker: BcsDockerWithoutPath) => envs + (BcsJob.BcsDockerImageEnvKey -> docker.image) - case Some(docker: BcsDockerWithPath) => envs + (BcsJob.BcsDockerPathEnvKey -> docker.path) + (BcsJob.BcsDockerImageEnvKey -> docker.image) + private[bcs] def environments: Map[String, String] = { + runtime.docker match { + case None => + runtime.dockerTag match { + case Some(docker: BcsDockerWithoutPath) => envs + (BcsJob.BcsDockerImageEnvKey -> docker.image) + case Some(docker: BcsDockerWithPath) => envs + (BcsJob.BcsDockerPathEnvKey -> docker.path) + (BcsJob.BcsDockerImageEnvKey -> docker.image) + case _ => envs + } case _ => envs + } + } + + val dockers: Option[Command.Docker] = { + runtime.docker match { + case Some(docker: BcsDockerWithoutPath) => + val dockers = new Command.Docker + dockers.setImage(docker.image) + Some(dockers) + case _ => None + } } private[bcs] def jobDesc: JobDescription = { @@ -166,21 +186,20 @@ final case class BcsJob(name: String, val cluster = runtime.cluster getOrElse(throw new IllegalArgumentException("cluster id or auto cluster configuration is mandatory")) cluster.fold(handleClusterId, handleAutoCluster) + val mnts = new Mounts mounts foreach { case input: BcsInputMount => - var destStr = input.dest.pathAsString - if (input.src.pathAsString.endsWith("/") && !destStr.endsWith("/")) { - destStr += "/" - } - lazyTask.addInputMapping(input.src.pathAsString, destStr) + mnts.addEntries(input.toBcsMountEntry) case output: BcsOutputMount => - var srcStr = output.src.pathAsString - if (output.dest.pathAsString.endsWith("/") && !srcStr.endsWith("/")) { + var srcStr = BcsMount.toString(output.src) + if (BcsMount.toString(output.dest).endsWith("/") && !srcStr.endsWith("/")) { srcStr += "/" } - lazyTask.addOutputMapping(srcStr, output.dest.pathAsString) + lazyTask.addOutputMapping(srcStr, BcsMount.toString(output.dest)) } + lazyTask.setMounts(mnts) + lazyTask } @@ -192,6 +211,7 @@ final case class BcsJob(name: String, config.spotStrategy foreach {strategy => autoCluster.setSpotStrategy(strategy)} config.spotPriceLimit foreach {priceLimit => autoCluster.setSpotPriceLimit(priceLimit)} + config.clusterId foreach {clusterId => autoCluster.setClusterId(clusterId)} runtime.reserveOnFail foreach {reserve => autoCluster.setReserveOnFail(reserve)} val userData = runtime.userData map {datas => Map(datas map {data => data.key -> data.value}: _*)} userData foreach {datas => autoCluster.setUserData(datas.asJava)} diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJobCachingActorHelper.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJobCachingActorHelper.scala index 1209b4cc256..e4aa2413f73 100644 --- a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJobCachingActorHelper.scala +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJobCachingActorHelper.scala @@ -5,13 +5,31 @@ import cromwell.backend.standard.StandardCachingActorHelper import cromwell.core.logging.JobLogging import cromwell.core.path.Path +object BcsJobCachingActorHelper { + val workerScript: String = + s"""|#!/bin/bash + |export script=$$cwd/$$(basename $$exec) + |export rc=$$cwd/rc + | + |( + |mkdir -p $$cwd + |cp -rf $$exec $$script + |cd $$cwd + |/bin/bash -c $$script + |) + """.stripMargin +} + trait BcsJobCachingActorHelper extends StandardCachingActorHelper { this: Actor with JobLogging => + + bcsWorkflowPaths.tag = runtimeAttributes.tag.getOrElse("") + lazy val initializationData: BcsBackendInitializationData = { backendInitializationDataAs[BcsBackendInitializationData] } - lazy val bcsClient = initializationData.bcsConfiguration.bcsClient.getOrElse(throw new RuntimeException("no bcs client available")) + def bcsClient = initializationData.bcsConfiguration.bcsClient.getOrElse(throw new RuntimeException("no bcs client available")) lazy val bcsWorkflowPaths: BcsWorkflowPaths = workflowPaths.asInstanceOf[BcsWorkflowPaths] @@ -30,5 +48,5 @@ trait BcsJobCachingActorHelper extends StandardCachingActorHelper { lazy val bcsStderrFile: Path = standardPaths.error //lazy val bcsCommandLine = "bash -c $(pwd)/cromwell_bcs && sync" - lazy val bcsCommandLine = "python -u cromwell_bcs.py" + lazy val bcsCommandLine = "./worker" } diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJobPaths.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJobPaths.scala index ea30faacfcd..d0951aee579 100644 --- a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJobPaths.scala +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsJobPaths.scala @@ -20,8 +20,7 @@ final case class BcsJobPaths(workflowPaths: BcsWorkflowPaths, jobKey: BackendJob import BcsJobPaths._ - // alibaba cloud's batchcompute service can only support tar.gz formatted package. - val workerFileName = "worker.tar.gz" + val workerFileName = "worker" val workerPath = callRoot.resolve(workerFileName) val bcsStdoutPath = callRoot.resolve(BcsStdoutRedirectPath) val bcsStderrPath = callRoot.resolve(BcsStderrRedirectPath) diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsMount.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsMount.scala index da66778a722..757405d23fb 100644 --- a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsMount.scala +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsMount.scala @@ -3,29 +3,44 @@ package cromwell.backend.impl.bcs import cats.data.Validated._ import cats.syntax.apply._ import cats.syntax.validated._ +import com.aliyuncs.batchcompute.pojo.v20151111.MountEntry import common.exception.MessageAggregation import common.validation.ErrorOr._ -import cromwell.core.path.{DefaultPathBuilder, Path, PathBuilder, PathFactory} +import cromwell.backend.impl.bcs.BcsMount.PathType +import cromwell.core.path.{Path, PathBuilder, PathFactory} -import scala.util.Try +import scala.util.{Success, Try} import scala.util.matching.Regex object BcsMount { + type PathType = Either[Path, String] + + def toString(p: PathType): String = { + p match { + case Left(p) => + p.pathAsString + case Right(s) => + return s + } + } + + val supportFileSystemTypes = List("oss", "nas", "smb", "lustre").mkString("|") + var pathBuilders: List[PathBuilder] = List() - val ossPrefix = """oss://[^\s]+""" + val remotePrefix = s"""(?:$supportFileSystemTypes)""" + """://[^\s]+""" val localPath = """/[^\s]+""" val writeSupport = """true|false""" - val inputMountPattern: Regex = s"""($ossPrefix)\\s+($localPath)\\s+($writeSupport)""".r - val outputMountPattern: Regex = s"""($localPath)\\s+($ossPrefix)\\s+($writeSupport)""".r + val inputMountPattern: Regex = s"""($remotePrefix)\\s+($localPath)\\s+($writeSupport)""".r + val outputMountPattern: Regex = s"""($localPath)\\s+($remotePrefix)\\s+($writeSupport)""".r def parse(s: String): Try[BcsMount] = { val validation: ErrorOr[BcsMount] = s match { - case inputMountPattern(oss, local, writeSupport) => - (validateOss(oss), validateLocal(oss, local), validateBoolean(writeSupport)) mapN { (src, dest, ws) => new BcsInputMount(src, dest, ws)} + case inputMountPattern(remote, local, writeSupport) => + (validateRemote(remote), validateLocal(remote, local), validateBoolean(writeSupport)) mapN { (src, dest, ws) => new BcsInputMount(src, dest, ws)} case outputMountPattern(local, oss, writeSupport) => - (validateLocal(oss, local), validateOss(oss), validateBoolean(writeSupport)) mapN { (src, dest, ws) => new BcsOutputMount(src, dest, ws)} + (validateLocal(oss, local), validateRemote(oss), validateBoolean(writeSupport)) mapN { (src, dest, ws) => new BcsOutputMount(src, dest, ws)} case _ => s"Mount strings should be of the format 'oss://my-bucket/inputs/ /home/inputs/ true' or '/home/outputs/ oss://my-bucket/outputs/ false'".invalidNel } @@ -39,12 +54,22 @@ object BcsMount { }) } - private def validateOss(value: String): ErrorOr[Path] = { - PathFactory.buildPath(value, pathBuilders).validNel + private def validateRemote(value: String): ErrorOr[PathType] = { + Try(PathFactory.buildPath(value, pathBuilders)) match { + case Success(p) => + Left(p).validNel + case _ => + Right(value).validNel + } } - private def validateLocal(oss: String, local: String): ErrorOr[Path] = { - if (oss.endsWith("/") == local.endsWith("/")) { - DefaultPathBuilder.get(local).validNel + private def validateLocal(remote: String, local: String): ErrorOr[PathType] = { + if (remote.endsWith("/") == local.endsWith("/")) { + Try(PathFactory.buildPath(local, pathBuilders)) match { + case Success(p) => + Left(p).validNel + case _=> + Right(local).validNel + } } else { "oss and local path type not match".invalidNel } @@ -57,14 +82,46 @@ object BcsMount { case _: IllegalArgumentException => s"$value not convertible to a Boolean".invalidNel } } - } trait BcsMount { - var src: Path - var dest: Path + import BcsMount._ + var src: PathType + var dest: PathType var writeSupport: Boolean + + def toBcsMountEntry: MountEntry } -final case class BcsInputMount(var src: Path, var dest: Path, var writeSupport: Boolean) extends BcsMount -final case class BcsOutputMount(var src: Path, var dest: Path, var writeSupport: Boolean) extends BcsMount \ No newline at end of file +final case class BcsInputMount(var src: PathType, var dest: PathType, var writeSupport: Boolean) extends BcsMount { + def toBcsMountEntry: MountEntry = { + var destStr = BcsMount.toString(dest) + if (BcsMount.toString(src).endsWith("/") && !destStr.endsWith("/")) { + destStr += "/" + } + + val entry = new MountEntry + entry.setSource(BcsMount.toString(src)) + entry.setDestination(destStr) + entry.setWriteSupport(writeSupport) + + entry + } + +} +final case class BcsOutputMount(var src: PathType, var dest: PathType, var writeSupport: Boolean) extends BcsMount { + def toBcsMountEntry: MountEntry = { + var srcStr = BcsMount.toString(src) + if (BcsMount.toString(dest).endsWith("/") && !srcStr.endsWith("/")) { + srcStr += "/" + } + + + val entry = new MountEntry + entry.setSource(srcStr) + entry.setDestination(BcsMount.toString(dest)) + entry.setWriteSupport(writeSupport) + + entry + } +} \ No newline at end of file diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsRuntimeAttributes.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsRuntimeAttributes.scala index a2b5ff1e0ac..8f6d02e99af 100644 --- a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsRuntimeAttributes.scala +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsRuntimeAttributes.scala @@ -28,6 +28,7 @@ trait OptionalWithDefault[A] { } final case class BcsRuntimeAttributes(continueOnReturnCode: ContinueOnReturnCode, + dockerTag: Option[BcsDocker], docker: Option[BcsDocker], failOnStderr: Boolean, mounts: Option[Seq[BcsMount]], @@ -37,7 +38,6 @@ final case class BcsRuntimeAttributes(continueOnReturnCode: ContinueOnReturnCode dataDisk: Option[BcsDataDisk], reserveOnFail: Option[Boolean], autoReleaseJob: Option[Boolean], - workerPath: Option[String], timeout: Option[Int], verbose: Option[Boolean], vpc: Option[BcsVpcConfiguration], @@ -68,7 +68,9 @@ object BcsRuntimeAttributes { private def clusterValidation(runtimeConfig: Option[Config]): OptionalRuntimeAttributesValidation[BcsClusterIdOrConfiguration] = ClusterValidation.optionalWithDefault(runtimeConfig) + private def dockerTagValidation(runtimeConfig: Option[Config]): OptionalRuntimeAttributesValidation[BcsDocker] = DockerTagValidation.optionalWithDefault(runtimeConfig) private def dockerValidation(runtimeConfig: Option[Config]): OptionalRuntimeAttributesValidation[BcsDocker] = DockerValidation.optionalWithDefault(runtimeConfig) + private def userDataValidation(runtimeConfig: Option[Config]): OptionalRuntimeAttributesValidation[Seq[BcsUserData]] = UserDataValidation.optionalWithDefault(runtimeConfig) private def systemDiskValidation(runtimeConfig: Option[Config]): OptionalRuntimeAttributesValidation[BcsSystemDisk] = SystemDiskValidation.optionalWithDefault(runtimeConfig) @@ -80,8 +82,6 @@ object BcsRuntimeAttributes { private def mountsValidation(runtimeConfig: Option[Config]): OptionalRuntimeAttributesValidation[Seq[BcsMount]] = MountsValidation.optionalWithDefault(runtimeConfig) - private def workerPathValidation(runtimeConfig: Option[Config]): OptionalRuntimeAttributesValidation[String] = WorkerPathValidation.optionalWithDefault(runtimeConfig) - private def timeoutValidation(runtimeConfig: Option[Config]): OptionalRuntimeAttributesValidation[Int] = TimeoutValidation.optionalWithDefault(runtimeConfig) private def verboseValidation(runtimeConfig: Option[Config]): OptionalRuntimeAttributesValidation[Boolean] = VerboseValidation.optionalWithDefault(runtimeConfig) @@ -90,6 +90,7 @@ object BcsRuntimeAttributes { private def tagValidation(runtimeConfig: Option[Config]): OptionalRuntimeAttributesValidation[String] = TagValidation.optionalWithDefault(runtimeConfig) + def runtimeAttributesBuilder(backendRuntimeConfig: Option[Config]): StandardValidatedRuntimeAttributesBuilder = { val defaults = StandardValidatedRuntimeAttributesBuilder.default(backendRuntimeConfig).withValidation( mountsValidation(backendRuntimeConfig), @@ -99,7 +100,6 @@ object BcsRuntimeAttributes { dataDiskValidation(backendRuntimeConfig), reserveOnFailValidation(backendRuntimeConfig), autoReleaseJobValidation(backendRuntimeConfig), - workerPathValidation(backendRuntimeConfig), timeoutValidation(backendRuntimeConfig), verboseValidation(backendRuntimeConfig), vpcValidation(backendRuntimeConfig), @@ -110,7 +110,10 @@ object BcsRuntimeAttributes { if (backendRuntimeConfig.exists(_.getOrElse("ignoreDocker", false))) { defaults } else { - defaults.withValidation(dockerValidation(backendRuntimeConfig)) + defaults.withValidation( + dockerTagValidation(backendRuntimeConfig), + dockerValidation(backendRuntimeConfig) + ) } } @@ -123,13 +126,13 @@ object BcsRuntimeAttributes { val userData: Option[Seq[BcsUserData]] = RuntimeAttributesValidation.extractOption(userDataValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) val cluster: Option[BcsClusterIdOrConfiguration] = RuntimeAttributesValidation.extractOption(clusterValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) + val dockerTag: Option[BcsDocker] = RuntimeAttributesValidation.extractOption(dockerTagValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) val docker: Option[BcsDocker] = RuntimeAttributesValidation.extractOption(dockerValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) val systemDisk: Option[BcsSystemDisk] = RuntimeAttributesValidation.extractOption(systemDiskValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) val dataDisk: Option[BcsDataDisk] = RuntimeAttributesValidation.extractOption(dataDiskValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) val reserveOnFail: Option[Boolean] = RuntimeAttributesValidation.extractOption(reserveOnFailValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) val autoReleaseJob: Option[Boolean] = RuntimeAttributesValidation.extractOption(autoReleaseJobValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) - val workerPath: Option[String] = RuntimeAttributesValidation.extractOption(workerPathValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) val timeout: Option[Int] = RuntimeAttributesValidation.extractOption(timeoutValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) val verbose: Option[Boolean] = RuntimeAttributesValidation.extractOption(verboseValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) val vpc: Option[BcsVpcConfiguration] = RuntimeAttributesValidation.extractOption(vpcValidation(backendRuntimeConfig).key, validatedRuntimeAttributes) @@ -137,6 +140,7 @@ object BcsRuntimeAttributes { new BcsRuntimeAttributes( continueOnReturnCode, + dockerTag, docker, failOnStderr, mounts, @@ -146,7 +150,6 @@ object BcsRuntimeAttributes { dataDisk, reserveOnFail, autoReleaseJob, - workerPath, timeout, verbose, vpc, @@ -202,6 +205,8 @@ object UserDataValidation { class UserDataValidation(override val config: Option[Config]) extends RuntimeAttributesValidation[Seq[BcsUserData]] with OptionalWithDefault[Seq[BcsUserData]]{ override def key: String = BcsRuntimeAttributes.UserDataKey + override def usedInCallCaching: Boolean = true + override def coercion: Traversable[WomType] = Set(WomStringType, WomArrayType(WomStringType)) override protected def validateValue: PartialFunction[WomValue, ErrorOr[Seq[BcsUserData]]] = { @@ -235,24 +240,6 @@ class UserDataValidation(override val config: Option[Config]) extends RuntimeAtt s"Expecting $key runtime attribute to be a comma separated String or Array[String]" } -object WorkerPathValidation { - def optionalWithDefault(config: Option[Config]): OptionalRuntimeAttributesValidation[String] = new WorkerPathValidation(config).optional -} - -class WorkerPathValidation(override val config: Option[Config]) extends StringRuntimeAttributesValidation("workerPath") with OptionalWithDefault[String] { - override protected def usedInCallCaching: Boolean = false - - override protected def missingValueMessage: String = "Can't find an attribute value for key worker path" - - override protected def invalidValueMessage(value: WomValue): String = super.missingValueMessage - - override protected def validateValue: PartialFunction[WomValue, ErrorOr[String]] = { - case WomString(value) => value.validNel - } -} - - - object ReserveOnFailValidation { def optionalWithDefault(config: Option[Config]): OptionalRuntimeAttributesValidation[Boolean] = new ReserveOnFailValidation(config).optional } @@ -286,6 +273,8 @@ class ClusterValidation(override val config: Option[Config]) extends RuntimeAttr { override def key: String = "cluster" + override def usedInCallCaching: Boolean = true + override def coercion: Traversable[WomType] = Set(WomStringType) override def validateValue: PartialFunction[WomValue, ErrorOr[BcsClusterIdOrConfiguration]] = { @@ -328,22 +317,32 @@ class DataDiskValidation(override val config: Option[Config]) extends RuntimeAtt } } -object DockerValidation { - def optionalWithDefault(config: Option[Config]): OptionalRuntimeAttributesValidation[BcsDocker] = new DockerValidation(config).optional +object DockerTagValidation { + def optionalWithDefault(config: Option[Config]): OptionalRuntimeAttributesValidation[BcsDocker] = new DockerTagValidation(config).optional } -class DockerValidation(override val config: Option[Config]) extends RuntimeAttributesValidation[BcsDocker] with OptionalWithDefault[BcsDocker] +class DockerTagValidation(override val config: Option[Config]) extends RuntimeAttributesValidation[BcsDocker] with OptionalWithDefault[BcsDocker] { - override def key: String = "docker" + override def key: String = "dockerTag" override def coercion: Traversable[WomType] = Set(WomStringType) override def validateValue: PartialFunction[WomValue, ErrorOr[BcsDocker]] = { case WomString(s) => BcsDocker.parse(s.toString) match { case Success(docker: BcsDocker) => docker.validNel - case _ => s"docker must be 'dockeImage dockerPath' like".invalidNel + case _ => s"docker must be 'dockerImage dockerPath' like".invalidNel } } } +object DockerValidation { + def optionalWithDefault(config: Option[Config]): OptionalRuntimeAttributesValidation[BcsDocker] = new DockerValidation(config).optional +} + +class DockerValidation(override val config: Option[Config]) extends DockerTagValidation(config) +{ + override def key: String = "docker" + override def usedInCallCaching: Boolean = true +} + object VpcValidation { def optionalWithDefault(config: Option[Config]): OptionalRuntimeAttributesValidation[BcsVpcConfiguration] = new VpcValidation(config).optional } diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsWorkflowPaths.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsWorkflowPaths.scala index 11ad805788d..1082acaa0f9 100644 --- a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsWorkflowPaths.scala +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/BcsWorkflowPaths.scala @@ -3,23 +3,36 @@ package cromwell.backend.impl.bcs import com.typesafe.config.Config import cromwell.backend.io.WorkflowPaths import cromwell.backend.{BackendJobDescriptorKey, BackendWorkflowDescriptor} -import cromwell.core.path.{PathBuilder} +import cromwell.core.path.{Path, PathBuilder} +object BcsWorkflowPaths { + val WorkFlowTagKey = "bcs_workflow_tag" +} case class BcsWorkflowPaths(override val workflowDescriptor: BackendWorkflowDescriptor, override val config: Config, override val pathBuilders: List[PathBuilder] = WorkflowPaths.DefaultPathBuilders) extends WorkflowPaths { - + import BcsWorkflowPaths._ override def toJobPaths(workflowPaths: WorkflowPaths, jobKey: BackendJobDescriptorKey): BcsJobPaths = { new BcsJobPaths(workflowPaths.asInstanceOf[BcsWorkflowPaths], jobKey) } override protected def withDescriptor(workflowDescriptor: BackendWorkflowDescriptor): WorkflowPaths = this.copy(workflowDescriptor = workflowDescriptor) + override protected def workflowPathBuilder(root: Path): Path = { + workflowDescriptor.breadCrumbs.foldLeft(root)((acc, breadCrumb) => { + breadCrumb.toPath(acc) + }).resolve(workflowDescriptor.callable.name).resolve(tag).resolve(workflowDescriptor.id.toString + "/") + } + + var tag: String = { + workflowDescriptor.workflowOptions.get(WorkFlowTagKey).getOrElse("") + } + private[bcs] def getWorkflowInputMounts: BcsInputMount = { val src = workflowRoot val dest = BcsJobPaths.BcsTempInputDirectory.resolve(src.pathWithoutScheme) - BcsInputMount(src, dest, true) + BcsInputMount(Left(src), Left(dest), true) } } diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/callcaching/BcsBackendCacheHitCopyingActor.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/callcaching/BcsBackendCacheHitCopyingActor.scala new file mode 100644 index 00000000000..b225f32aa08 --- /dev/null +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/callcaching/BcsBackendCacheHitCopyingActor.scala @@ -0,0 +1,69 @@ +package cromwell.backend.impl.bcs.callcaching + +import com.google.cloud.storage.contrib.nio.CloudStorageOptions +import common.util.TryUtil +import cromwell.backend.BackendInitializationData +import cromwell.backend.impl.bcs.BcsBackendInitializationData +import cromwell.backend.io.JobPaths +import cromwell.backend.standard.callcaching.{StandardCacheHitCopyingActor, StandardCacheHitCopyingActorParams} +import cromwell.core.CallOutputs +import cromwell.core.io.{IoCommand, IoTouchCommand} +import cromwell.core.path.Path +import cromwell.core.simpleton.{WomValueBuilder, WomValueSimpleton} +import cromwell.filesystems.oss.batch.{OssBatchCommandBuilder} +import wom.values.WomFile + +import scala.language.postfixOps +import scala.util.Try + +class BcsBackendCacheHitCopyingActor(standardParams: StandardCacheHitCopyingActorParams) extends StandardCacheHitCopyingActor(standardParams) { + override protected val commandBuilder = OssBatchCommandBuilder + private val cachingStrategy = BackendInitializationData + .as[BcsBackendInitializationData](standardParams.backendInitializationDataOption) + .bcsConfiguration.duplicationStrategy + + override def processSimpletons(womValueSimpletons: Seq[WomValueSimpleton], sourceCallRootPath: Path) = cachingStrategy match { + case CopyCachedOutputs => super.processSimpletons(womValueSimpletons, sourceCallRootPath) + case UseOriginalCachedOutputs => + val touchCommands: Seq[Try[IoTouchCommand]] = womValueSimpletons collect { + case WomValueSimpleton(_, wdlFile: WomFile) => getPath(wdlFile.value) map OssBatchCommandBuilder.touchCommand + } + + TryUtil.sequence(touchCommands) map { + WomValueBuilder.toJobOutputs(jobDescriptor.taskCall.outputPorts, womValueSimpletons) -> _.toSet + } + } + + override def processDetritus(sourceJobDetritusFiles: Map[String, String]) = cachingStrategy match { + case CopyCachedOutputs => super.processDetritus(sourceJobDetritusFiles) + case UseOriginalCachedOutputs => + // apply getPath on each detritus string file + val detritusAsPaths = detritusFileKeys(sourceJobDetritusFiles).toSeq map { key => + key -> getPath(sourceJobDetritusFiles(key)) + } toMap + + // Don't forget to re-add the CallRootPathKey that has been filtered out by detritusFileKeys + TryUtil.sequenceMap(detritusAsPaths, "Failed to make paths out of job detritus") map { newDetritus => + (newDetritus + (JobPaths.CallRootPathKey -> destinationCallRootPath)) -> newDetritus.values.map(OssBatchCommandBuilder.touchCommand).toSet + } + } + + override protected def additionalIoCommands(sourceCallRootPath: Path, + originalSimpletons: Seq[WomValueSimpleton], + newOutputs: CallOutputs, + originalDetritus: Map[String, String], + newDetritus: Map[String, Path]): List[Set[IoCommand[_]]] = { + cachingStrategy match { + case UseOriginalCachedOutputs => + val content = + s""" + |This directory does not contain any output files because this job matched an identical job that was previously run, thus it was a cache-hit. + |Cromwell is configured to not copy outputs during call caching. To change this, edit the filesystems.gcs.caching.duplication-strategy field in your backend configuration. + |The original outputs can be found at this location: ${sourceCallRootPath.pathAsString} + """.stripMargin + + List(Set(OssBatchCommandBuilder.writeCommand(jobPaths.callExecutionRoot / "call_caching_placeholder.txt", content, Seq(CloudStorageOptions.withMimeType("text/plain"))))) + case CopyCachedOutputs => List.empty + } + } +} diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/callcaching/BcsCacheHitDuplicationStrategy.scala b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/callcaching/BcsCacheHitDuplicationStrategy.scala new file mode 100644 index 00000000000..55ba639d6fd --- /dev/null +++ b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/callcaching/BcsCacheHitDuplicationStrategy.scala @@ -0,0 +1,6 @@ +package cromwell.backend.impl.bcs.callcaching + +sealed trait BcsCacheHitDuplicationStrategy + +case object CopyCachedOutputs extends BcsCacheHitDuplicationStrategy +case object UseOriginalCachedOutputs extends BcsCacheHitDuplicationStrategy diff --git a/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/worker.tar.gz b/supportedBackends/bcs/src/main/scala/cromwell/backend/impl/bcs/worker.tar.gz deleted file mode 100644 index 3e1328f741743d56deec3a3340e83542b7a91177..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 86319 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 z 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%0AtmVwm%{j|?oo+s3IVb7nvz7x* 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 z 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

*XV}de_mX}{P1i>U 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 diff --git a/supportedBackends/bcs/src/test/resources/application.conf b/supportedBackends/bcs/src/test/resources/application.conf new file mode 100644 index 00000000000..218d12ee470 --- /dev/null +++ b/supportedBackends/bcs/src/test/resources/application.conf @@ -0,0 +1,77 @@ +#include required(classpath("application")) + + +call-caching { + # Allows re-use of existing results for jobs you've already run + # (default: false) + enabled = true + + # Whether to invalidate a cache result forever if we cannot reuse them. Disable this if you expect some cache copies + # to fail for external reasons which should not invalidate the cache (e.g. auth differences between users): + # (default: true) + invalidate-bad-cache-results = true + +} + +docker { + hash-lookup { + enable = true + + # How should docker hashes be looked up. Possible values are "local" and "remote" + # "local": Lookup hashes on the local docker daemon using the cli + # "remote": Lookup hashes on docker hub and gcr + method = "remote" + alibabacloudcr { + num-threads = 5 + auth { + access-id = "test-access-id" + access-key = "test-access-key" + security-token = "test-security-token" + } + } + } +} + +backend { + default = "BCS" + + providers { + BCS { + actor-factory = "cromwell.backend.impl.bcs.BcsBackendLifecycleActorFactory" + config { + root = "oss://my-bucket/cromwell_dir" + region = "cn-shanghai" + access-id = "test-access-id" + access-key = "test-access-key" + security-token = "test-security-token" + + filesystems { + oss { + auth { + endpoint = "oss-cn-shanghai.aliyuncs.com" + access-id = "test-access-id" + access-key = "test-access-key" + security-token = "test-security-token" + } + + caching { + # When a cache hit is found, the following duplication strategy will be followed to use the cached outputs + # Possible values: "copy", "reference". Defaults to "copy" + # "copy": Copy the output files + # "reference": DO NOT copy the output files but point to the original output files instead. + # Will still make sure than all the original output files exist and are accessible before + # going forward with the cache hit. + duplication-strategy = "reference" + } + } + } + + default-runtime-attributes { + failOnStderr: false + continueOnReturnCode: 0 + vpc: "192.168.0.0/16" + } + } + } + } +} diff --git a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsClusterIdOrConfigurationSpec.scala b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsClusterIdOrConfigurationSpec.scala index 000a2556c7c..b57fa60344b 100644 --- a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsClusterIdOrConfigurationSpec.scala +++ b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsClusterIdOrConfigurationSpec.scala @@ -12,7 +12,7 @@ class BcsClusterIdOrConfigurationSpec extends BcsTestUtilSpec { val clusterIdTable = Table( ("unparsed", "parsed"), - ("cls-xxxx", Some("cls-xxxx")), + ("cls-xxxx", Option("cls-xxxx")), ("job-xxxx", None) ) @@ -24,8 +24,8 @@ class BcsClusterIdOrConfigurationSpec extends BcsTestUtilSpec { val resourceTypeTable = Table( ("unparsed", "parsed"), - ("OnDemand", Some("OnDemand")), - ("Spot", Some("Spot")), + ("OnDemand", Option("OnDemand")), + ("Spot", Option("Spot")), ("Other", None) ) @@ -38,8 +38,8 @@ class BcsClusterIdOrConfigurationSpec extends BcsTestUtilSpec { val instanceTypeTable = Table( ("unparsed", "parsed"), - ("ecs.s1.large", Some("ecs.s1.large")), - ("bcs.s1.large", Some("bcs.s1.large")) + ("ecs.s1.large", Option("ecs.s1.large")), + ("bcs.s1.large", Option("bcs.s1.large")) ) it should "parse correct instance type" in { @@ -50,8 +50,8 @@ class BcsClusterIdOrConfigurationSpec extends BcsTestUtilSpec { val spotStrategyTable = Table( ("unparsed", "parsed"), - ("SpotWithPriceLimit", Some("SpotWithPriceLimit")), - ("SpotAsPriceGo", Some("SpotAsPriceGo")) + ("SpotWithPriceLimit", Option("SpotWithPriceLimit")), + ("SpotAsPriceGo", Option("SpotAsPriceGo")) ) @@ -63,10 +63,10 @@ class BcsClusterIdOrConfigurationSpec extends BcsTestUtilSpec { val spotPriceLimitTable = Table( ("unparsed", "parsed"), - ("1.0", Some(1.0.toFloat)), - ("0.1", Some(0.1.toFloat)), - ("0.12", Some(0.12.toFloat)), - ("0.123", Some(0.123.toFloat)) + ("1.0", Option(1.0.toFloat)), + ("0.1", Option(0.1.toFloat)), + ("0.12", Option(0.12.toFloat)), + ("0.123", Option(0.123.toFloat)) ) it should "parse correct spot price limit" in { @@ -79,9 +79,14 @@ class BcsClusterIdOrConfigurationSpec extends BcsTestUtilSpec { ("unparsed", "parsed"), ("cls-id", Left("cls-id")), ("OnDemand ecs.s1.large img-test", Right(AutoClusterConfiguration("OnDemand", "ecs.s1.large", "img-test"))), + ("OnDemand ecs.s1.large img-test cls-test", Right(AutoClusterConfiguration("OnDemand", "ecs.s1.large", "img-test", clusterId = Option("cls-test")))), ("ecs.s1.large img-test", Right(AutoClusterConfiguration("OnDemand", "ecs.s1.large", "img-test"))), - ("Spot ecs.s1.large img-test SpotWithPriceLimit 0.1", Right(AutoClusterConfiguration("Spot", "ecs.s1.large", "img-test", Some("SpotWithPriceLimit"), Some(0.1.toFloat)))), - ("Spot ecs.s1.large img-test SpotAsPriceGo 0.1", Right(AutoClusterConfiguration("Spot", "ecs.s1.large", "img-test", Some("SpotAsPriceGo"), Some(0.1.toFloat)))), + ("ecs.s1.large img-test cls-test", Right(AutoClusterConfiguration("OnDemand", "ecs.s1.large", "img-test", clusterId = Option("cls-test")))), + ("Spot ecs.s1.large img-test SpotWithPriceLimit 0.1", Right(AutoClusterConfiguration("Spot", "ecs.s1.large", "img-test", Option("SpotWithPriceLimit"), Option(0.1.toFloat)))), + ("Spot ecs.s1.large img-test SpotWithPriceLimit 0.1 cls-test", Right(AutoClusterConfiguration("Spot", "ecs.s1.large", "img-test", Option("SpotWithPriceLimit"), Option(0.1.toFloat), Option("cls-test")))), + ("Spot ecs.s1.large img-test SpotAsPriceGo 0.1", Right(AutoClusterConfiguration("Spot", "ecs.s1.large", "img-test", Option("SpotAsPriceGo"), Option(0.1.toFloat)))), + ("Spot ecs.s1.large img-test SpotAsPriceGo 0.1 cls-test", Right(AutoClusterConfiguration("Spot", "ecs.s1.large", "img-test", Option("SpotAsPriceGo"), Option(0.1.toFloat), Option("cls-test")))), + ) diff --git a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsConfigurationSpec.scala b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsConfigurationSpec.scala index 7575a6e04fe..ad32eec7cce 100644 --- a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsConfigurationSpec.scala +++ b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsConfigurationSpec.scala @@ -1,9 +1,10 @@ package cromwell.backend.impl.bcs import com.typesafe.config.ConfigValueFactory +import cromwell.backend.impl.bcs.callcaching.UseOriginalCachedOutputs class BcsConfigurationSpec extends BcsTestUtilSpec { - behavior of s"BcsConfiguration" + behavior of "BcsConfiguration" type ValueOrDelete = Either[Boolean, AnyRef] def backendConfiguration = BcsTestUtilSpec.BcsBackendConfigurationDescriptor @@ -25,6 +26,14 @@ class BcsConfigurationSpec extends BcsTestUtilSpec { conf.bcsAccessKey shouldEqual Some(key) } + it should "have correct bcs callcaching strategy" in { + val region = "cn-hangzhou" + val configs = Map("region" -> Right(region)) + val conf = withConfig(configs) + conf.duplicationStrategy shouldEqual UseOriginalCachedOutputs + } + + private def withConfig(configs: Map[String, ValueOrDelete]) = { var descriptor = BcsTestUtilSpec.BcsBackendConfigurationDescriptor.copy() for ((key, value) <- configs) { diff --git a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsJobPathsSpec.scala b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsJobPathsSpec.scala index 6c1e610e22d..02339302d94 100644 --- a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsJobPathsSpec.scala +++ b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsJobPathsSpec.scala @@ -6,7 +6,7 @@ import org.mockito.Mockito.when class BcsJobPathsSpec extends BcsTestUtilSpec { behavior of s"BcsJobPathsSpec" - var root: OssPath = mockPathBuiler.build("oss://bcs-test/root/").getOrElse(throw new IllegalArgumentException()) + var root: OssPath = mockPathBuilder.build("oss://bcs-test/root/").getOrElse(throw new IllegalArgumentException()) var workflowPath = { val workflowPaths = mock[BcsWorkflowPaths] diff --git a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsJobSpec.scala b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsJobSpec.scala index 6e6490715fc..589b1860856 100644 --- a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsJobSpec.scala +++ b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsJobSpec.scala @@ -13,7 +13,7 @@ class BcsJobSpec extends BcsTestUtilSpec { val name = "cromwell" val description = name val command = "python main.py" - val packagePath = mockPathBuiler.build("oss://bcs-test/worker.tar.gz").get + val packagePath = mockPathBuilder.build("oss://bcs-test/worker.tar.gz").get val mounts = Seq.empty[BcsMount] val envs = Map.empty[String, String] @@ -45,7 +45,10 @@ class BcsJobSpec extends BcsTestUtilSpec { val dest = "/home/inputs/" val writeSupport = false val runtime = Map("mounts" -> WomString(s"$src $dest $writeSupport")) - taskWithRuntime(runtime).getInputMapping.get(src) shouldEqual dest + taskWithRuntime(runtime).getMounts().getEntries should have size(1) + taskWithRuntime(runtime).getMounts().getEntries.get(0).getSource shouldBe src + taskWithRuntime(runtime).getMounts().getEntries.get(0).getDestination shouldBe dest + taskWithRuntime(runtime).getMounts().getEntries.get(0).isWriteSupport shouldBe writeSupport } it should "have correct cluster id" in { @@ -57,9 +60,9 @@ class BcsJobSpec extends BcsTestUtilSpec { it should "have correct docker option" in { val dockerImage = "ubuntu/latest" val dockerPath = "oss://bcs-reg/ubuntu/"toLowerCase() - val runtime = Map("docker" -> WomString(s"$dockerImage $dockerPath")) - taskWithRuntime(runtime).getParameters.getCommand.getEnvVars.get(BcsJob.BcsDockerImageEnvKey) shouldEqual dockerImage - taskWithRuntime(runtime).getParameters.getCommand.getEnvVars.get(BcsJob.BcsDockerPathEnvKey) shouldEqual dockerPath + val runtime = Map("dockerTag" -> WomString(s"$dockerImage $dockerPath")) + taskWithRuntime(runtime).getParameters.getCommand.getEnvVars.get(BcsJob.BcsDockerImageEnvKey) shouldEqual null + taskWithRuntime(runtime).getParameters.getCommand.getEnvVars.get(BcsJob.BcsDockerPathEnvKey) shouldEqual null } it should "have correct auto cluster configuration" in { diff --git a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsMountSpec.scala b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsMountSpec.scala index 8ead4fe1d0a..c27d6488965 100644 --- a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsMountSpec.scala +++ b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsMountSpec.scala @@ -14,8 +14,8 @@ class BcsMountSpec extends BcsTestUtilSpec { entry shouldBe a [BcsInputMount] - entry.src.pathAsString shouldEqual ossObject - entry.dest.pathAsString shouldEqual localFile.stripSuffix("/") + BcsMount.toString(entry.src) shouldEqual ossObject + BcsMount.toString(entry.dest) shouldEqual localFile entry.writeSupport shouldEqual writeSupport writeSupport = false @@ -23,8 +23,8 @@ class BcsMountSpec extends BcsTestUtilSpec { entryString = s"$ossObject $localFile $writeSupport" entry = BcsMount.parse(entryString).success.value entry shouldBe a [BcsInputMount] - entry.src.pathAsString shouldEqual ossObject - entry.dest.pathAsString shouldEqual localFile.stripSuffix("/") + BcsMount.toString(entry.src) shouldEqual ossObject + BcsMount.toString(entry.dest) shouldEqual localFile entry.writeSupport shouldEqual writeSupport } @@ -35,8 +35,8 @@ class BcsMountSpec extends BcsTestUtilSpec { entry shouldBe a [BcsOutputMount] - entry.src.pathAsString shouldEqual localFile.stripSuffix("/") - entry.dest.pathAsString shouldEqual ossObject + BcsMount.toString(entry.src) shouldEqual localFile + BcsMount.toString(entry.dest) shouldEqual ossObject entry.writeSupport shouldEqual writeSupport writeSupport = false @@ -44,8 +44,8 @@ class BcsMountSpec extends BcsTestUtilSpec { entryString = s"$localFile $ossObject $writeSupport" entry = BcsMount.parse(entryString).success.value entry shouldBe a [BcsOutputMount] - entry.src.pathAsString shouldEqual localFile.stripSuffix("/") - entry.dest.pathAsString shouldEqual ossObject + BcsMount.toString(entry.src) shouldEqual localFile + BcsMount.toString(entry.dest) shouldEqual ossObject entry.writeSupport shouldEqual writeSupport } diff --git a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsRuntimeAttributesSpec.scala b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsRuntimeAttributesSpec.scala index 770cfa59175..399c9c854ca 100644 --- a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsRuntimeAttributesSpec.scala +++ b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsRuntimeAttributesSpec.scala @@ -1,10 +1,9 @@ package cromwell.backend.impl.bcs -import cromwell.core.path.DefaultPathBuilder import wom.values._ class BcsRuntimeAttributesSpec extends BcsTestUtilSpec { - behavior of s"BcsRuntimeAttributes" + behavior of "BcsRuntimeAttributes" it should "build correct default runtime attributes from config string" in { val runtime = Map.empty[String, WomValue] @@ -12,23 +11,29 @@ class BcsRuntimeAttributesSpec extends BcsTestUtilSpec { defaults shouldEqual expectedRuntimeAttributes } - it should "parse docker without docker path" in { - val runtime = Map("docker" -> WomString("ubuntu/latest")) - val expected = expectedRuntimeAttributes.copy(docker = Some(BcsDockerWithoutPath("ubuntu/latest"))) + it should "parse dockerTag without docker path" in { + val runtime = Map("dockerTag" -> WomString("ubuntu/latest")) + val expected = expectedRuntimeAttributes.copy(dockerTag = Some(BcsDockerWithoutPath("ubuntu/latest"))) createBcsRuntimeAttributes(runtime) shouldEqual(expected) } - it should "parse docker with path" in { - val runtime = Map("docker" -> WomString("centos/latest oss://bcs-dir/registry/")) - val expected = expectedRuntimeAttributes.copy(docker = Some(BcsDockerWithPath("centos/latest", "oss://bcs-dir/registry/"))) + it should "parse dockerTag with path" in { + val runtime = Map("dockerTag" -> WomString("centos/latest oss://bcs-dir/registry/")) + val expected = expectedRuntimeAttributes.copy(dockerTag = Some(BcsDockerWithPath("centos/latest", "oss://bcs-dir/registry/"))) createBcsRuntimeAttributes(runtime) shouldEqual(expected) } - it should "parse docker fail if an empty string value" in { - val runtime = Map("docker" -> WomString("")) + it should "parse dockerTag fail if an empty string value" in { + val runtime = Map("dockerTag" -> WomString("")) an [Exception] should be thrownBy createBcsRuntimeAttributes(runtime) } + it should "parse docker" in { + val runtime = Map("docker" -> WomString("registry.cn-beijing.aliyuncs.com/test/testubuntu:0.2")) + val expected = expectedRuntimeAttributes.copy(docker = Some(BcsDockerWithoutPath("registry.cn-beijing.aliyuncs.com/test/testubuntu:0.2"))) + createBcsRuntimeAttributes(runtime) shouldEqual(expected) + } + it should "parse correct user data" in { val runtime = Map("userData" -> WomString("key value1")) val expected = expectedRuntimeAttributes.copy(userData = Some(Vector(BcsUserData("key", "value1")))) @@ -42,13 +47,13 @@ class BcsRuntimeAttributesSpec extends BcsTestUtilSpec { it should "parse correct input mount" in { val runtime = Map("mounts" -> WomString("oss://bcs-dir/bcs-file /home/inputs/input_file false")) - val expected = expectedRuntimeAttributes.copy(mounts = Some(Vector(BcsInputMount(mockPathBuiler.build("oss://bcs-dir/bcs-file").get, DefaultPathBuilder.build("/home/inputs/input_file").get, false)))) + val expected = expectedRuntimeAttributes.copy(mounts = Some(Vector(BcsInputMount(Left(mockPathBuilder.build("oss://bcs-dir/bcs-file").get), Right("/home/inputs/input_file"), false)))) createBcsRuntimeAttributes(runtime) shouldEqual expected } it should "parse correct out mount" in { val runtime = Map("mounts" -> WomString("/home/outputs/ oss://bcs-dir/outputs/ true")) - val expected = expectedRuntimeAttributes.copy(mounts = Some(Vector(BcsOutputMount(DefaultPathBuilder.build("/home/outputs/").get, mockPathBuiler.build("oss://bcs-dir/outputs/").get, true)))) + val expected = expectedRuntimeAttributes.copy(mounts = Some(Vector(BcsOutputMount(Right("/home/outputs/"), Left(mockPathBuilder.build("oss://bcs-dir/outputs/").get), true)))) createBcsRuntimeAttributes(runtime) shouldEqual expected } diff --git a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsTestUtilSpec.scala b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsTestUtilSpec.scala index 57300cd69fa..7116d7826a4 100644 --- a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsTestUtilSpec.scala +++ b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsTestUtilSpec.scala @@ -3,12 +3,11 @@ package cromwell.backend.impl.bcs import com.typesafe.config.ConfigFactory import common.collections.EnhancedCollections._ import cromwell.backend.{BackendConfigurationDescriptor, BackendJobDescriptorKey, RuntimeAttributeDefinition} -import cromwell.backend.BackendSpec.{buildWdlWorkflowDescriptor} +import cromwell.backend.BackendSpec.buildWdlWorkflowDescriptor import cromwell.backend.validation.ContinueOnReturnCodeSet -import cromwell.core.path.DefaultPathBuilder import cromwell.core.{TestKitSuite, WorkflowOptions} import cromwell.filesystems.oss.OssPathBuilder -import cromwell.filesystems.oss.nio.OssStorageConfiguration +import cromwell.filesystems.oss.nio.DefaultOssStorageConfiguration import cromwell.util.SampleWdl import org.scalatest.{BeforeAndAfter, FlatSpecLike, Matchers} import org.scalatest.mockito.MockitoSugar @@ -25,12 +24,12 @@ object BcsTestUtilSpec { | continueOnReturnCode: 0 | cluster: "cls-mycluster" | mounts: "oss://bcs-bucket/bcs-dir/ /home/inputs/ false" - | docker: "ubuntu/latest oss://bcs-reg/ubuntu/" + | dockerTag: "ubuntu/latest oss://bcs-reg/ubuntu/" + | docker: "registry.cn-beijing.aliyuncs.com/test/testubuntu:0.1" | userData: "key value" | reserveOnFail: true | autoReleaseJob: true | verbose: false - | workerPath: "oss://bcs-bucket/workflow/worker.tar.gz" | systemDisk: "cloud 50" | dataDisk: "cloud 250 /home/data/" | timeout: 3000 @@ -57,6 +56,9 @@ object BcsTestUtilSpec { | access-key = "" | security-token = "" | } + | caching { + | duplication-strategy = "reference" + | } | } |} | @@ -114,13 +116,13 @@ object BcsTestUtilSpec { trait BcsTestUtilSpec extends TestKitSuite with FlatSpecLike with Matchers with MockitoSugar with BeforeAndAfter { before { - BcsMount.pathBuilders = List(mockPathBuiler) + BcsMount.pathBuilders = List(mockPathBuilder) } val jobId = "test-bcs-job" - val mockOssConf = OssStorageConfiguration("oss.aliyuncs.com", "test-id", "test-key") - val mockPathBuiler = OssPathBuilder(mockOssConf) - val mockPathBuilders = List(mockPathBuiler) + val mockOssConf = DefaultOssStorageConfiguration("oss.aliyuncs.com", "test-id", "test-key") + val mockPathBuilder = OssPathBuilder(mockOssConf) + val mockPathBuilders = List(mockPathBuilder) lazy val workflowDescriptor = buildWdlWorkflowDescriptor( SampleWdl.HelloWorld.workflowSource(), inputFileAsJson = Option(JsObject(SampleWdl.HelloWorld.rawInputs.safeMapValues(JsString.apply)).compactPrint) @@ -132,25 +134,25 @@ trait BcsTestUtilSpec extends TestKitSuite with FlatSpecLike with Matchers with val expectedContinueOnReturn = ContinueOnReturnCodeSet(Set(0)) - val expectedDocker = Some(BcsDockerWithPath("ubuntu/latest", "oss://bcs-reg/ubuntu/")) + val expectedDockerTag = Some(BcsDockerWithPath("ubuntu/latest", "oss://bcs-reg/ubuntu/")) + val expectedDocker = Some(BcsDockerWithoutPath("registry.cn-beijing.aliyuncs.com/test/testubuntu:0.1")) val expectedFailOnStderr = false val expectedUserData = Some(Vector(new BcsUserData("key", "value"))) - val expectedMounts = Some(Vector(new BcsInputMount(mockPathBuiler.build("oss://bcs-bucket/bcs-dir/").get, DefaultPathBuilder.build("/home/inputs/").get, false))) + val expectedMounts = Some(Vector(new BcsInputMount(Left(mockPathBuilder.build("oss://bcs-bucket/bcs-dir/").get), Right("/home/inputs/"), false))) val expectedCluster = Some(Left("cls-mycluster")) val expectedSystemDisk = Some(BcsSystemDisk("cloud", 50)) val expectedDataDsik = Some(BcsDataDisk("cloud", 250, "/home/data/")) val expectedReserveOnFail = Some(true) val expectedAutoRelease = Some(true) - val expectedWorkerPath = Some("oss://bcs-bucket/workflow/worker.tar.gz") val expectedTimeout = Some(3000) val expectedVerbose = Some(false) val expectedVpc = Some(BcsVpcConfiguration(Some("192.168.0.0/16"), Some("vpc-xxxx"))) val expectedTag = Some("jobTag") - val expectedRuntimeAttributes = new BcsRuntimeAttributes(expectedContinueOnReturn, expectedDocker, expectedFailOnStderr, expectedMounts, expectedUserData, expectedCluster, - expectedSystemDisk, expectedDataDsik, expectedReserveOnFail, expectedAutoRelease, expectedWorkerPath, expectedTimeout, expectedVerbose, expectedVpc, expectedTag) + val expectedRuntimeAttributes = new BcsRuntimeAttributes(expectedContinueOnReturn, expectedDockerTag, expectedDocker, expectedFailOnStderr, expectedMounts, expectedUserData, expectedCluster, + expectedSystemDisk, expectedDataDsik, expectedReserveOnFail, expectedAutoRelease, expectedTimeout, expectedVerbose, expectedVpc, expectedTag) protected def createBcsRuntimeAttributes(runtimeAttributes: Map[String, WomValue]): BcsRuntimeAttributes = { diff --git a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsWorkflowPathsSpec.scala b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsWorkflowPathsSpec.scala index a3909cb554a..4222055ab1a 100644 --- a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsWorkflowPathsSpec.scala +++ b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/BcsWorkflowPathsSpec.scala @@ -12,10 +12,10 @@ class BcsWorkflowPathsSpec extends BcsTestUtilSpec { val workflowInput = paths.getWorkflowInputMounts workflowInput shouldBe a[BcsInputMount] - workflowInput.src shouldEqual(paths.workflowRoot) - workflowInput.dest.pathAsString.startsWith(BcsJobPaths.BcsTempInputDirectory.pathAsString) shouldBe true + workflowInput.src shouldEqual(Left(paths.workflowRoot)) + BcsMount.toString(workflowInput.dest).startsWith(BcsJobPaths.BcsTempInputDirectory.pathAsString) shouldBe true // DefaultPathBuilder always remove ending '/' from directory path. - workflowInput.dest.pathAsString.endsWith(paths.workflowRoot.pathWithoutScheme.stripSuffix("/")) shouldBe true + BcsMount.toString(workflowInput.dest).endsWith(paths.workflowRoot.pathWithoutScheme.stripSuffix("/")) shouldBe true } it should "have correct job paths" in { diff --git a/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/callcaching/BcsBackendCacheHitCopyingActorSpec.scala b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/callcaching/BcsBackendCacheHitCopyingActorSpec.scala new file mode 100644 index 00000000000..3ee71686602 --- /dev/null +++ b/supportedBackends/bcs/src/test/scala/cromwell/backend/impl/bcs/callcaching/BcsBackendCacheHitCopyingActorSpec.scala @@ -0,0 +1,88 @@ +package cromwell.backend.impl.bcs.callcaching + + +import akka.actor.Props +import akka.testkit.TestActorRef +import com.typesafe.config.ConfigValueFactory +import cromwell.backend.impl.bcs.{BcsBackendInitializationData, BcsConfiguration, BcsRuntimeAttributes, BcsTestUtilSpec, BcsWorkflowPaths} +import cromwell.backend.standard.callcaching.StandardCacheHitCopyingActorParams +import cromwell.core.path.{Path} +import wom.values._ +import cromwell.backend.impl.bcs.BcsTestUtilSpec.BcsBackendConfig +import cromwell.backend.standard.callcaching.DefaultStandardCacheHitCopyingActorParams +import cromwell.core.simpleton.WomValueSimpleton +import cromwell.filesystems.oss.OssPath +import org.mockito.Mockito.when + +import scala.util.Try + + +class BcsBackendCacheHitCopyingActorSpec extends BcsTestUtilSpec { + behavior of "BcsBackendCacheHitCopyingActor" + type ValueOrDelete = Either[Boolean, AnyRef] + + val workflowPaths = BcsWorkflowPaths(workflowDescriptor, BcsBackendConfig, mockPathBuilders) + + + private def buildInitializationData(configuration: BcsConfiguration) = { + + val runtimeAttributesBuilder = BcsRuntimeAttributes.runtimeAttributesBuilder(BcsTestUtilSpec.BcsBackendConfigurationDescriptor.backendRuntimeAttributesConfig) + BcsBackendInitializationData(workflowPaths, runtimeAttributesBuilder, configuration, null) + } + + val runtimeAttributesBuilder = BcsRuntimeAttributes.runtimeAttributesBuilder(BcsTestUtilSpec.BcsBackendConfigurationDescriptor.backendRuntimeAttributesConfig) + + + private def withConfig(configs: Map[String, ValueOrDelete]) = { + var descriptor = BcsTestUtilSpec.BcsBackendConfigurationDescriptor.copy() + for ((key, value) <- configs) { + value match { + case Left(_) => descriptor = BcsTestUtilSpec.BcsBackendConfigurationDescriptor.copy(backendConfig = descriptor.backendConfig.withoutPath(key)) + case Right(v) => descriptor = BcsTestUtilSpec.BcsBackendConfigurationDescriptor.copy(backendConfig = descriptor.backendConfig.withValue(key, ConfigValueFactory.fromAnyRef(v))) + } + } + new BcsConfiguration(descriptor) + } + + + var cacheHitCopyingActorParams = { + val mockCacheHitCopyingActorParams = mock[DefaultStandardCacheHitCopyingActorParams] + val id = "test-access-id" + val key = "test-access-key" + val configs = Map("access-id" -> Right(id), "access-key" -> Right(key)) + val conf = withConfig(configs) + when(mockCacheHitCopyingActorParams.backendInitializationDataOption).thenReturn(Option(buildInitializationData(conf))) + mockCacheHitCopyingActorParams + } + + class TestableBcsCacheHitCopyingActor(params: StandardCacheHitCopyingActorParams) + extends BcsBackendCacheHitCopyingActor(params) { + + val id = "test-access-id" + val key = "test-access-key" + val configs = Map("access-id" -> Right(id), "access-key" -> Right(key)) + val conf = withConfig(configs) + + + def this() = { + this(cacheHitCopyingActorParams) + } + + override def getPath(str: String): Try[Path] = mockPathBuilder.build("oss://bcs-dir/outputs/") + override def destinationJobDetritusPaths: Map[String, Path] = Map("stdout" + -> mockPathBuilder.build("oss://my-bucket/cromwell_dir/wf_echo/14e5dcd2-0c94-4035-aa7b-b90d7008202c/call-echo/stdout.log").get) + } + + it should "process simpleton and detritus correctly" in { + val simpleton = WomValueSimpleton("txt_files", WomSingleFile("oss://my-bucket/cromwell_dir/wf_echo/14e5dcd2-0c94-4035-aa7b-b90d7008202c/call-echo/abc.log")) + val detritus = Map("stdout" -> "oss://my-bucket/cromwell_dir/wf_echo/14e5dcd2-0c94-4035-aa7b-b90d7008202c/call-echo/stdout.log") + val sourceCallRootPath: OssPath = mockPathBuilder.build("oss://bcs-test/root/abc.log").getOrElse(throw new IllegalArgumentException()) + + val props = Props(new TestableBcsCacheHitCopyingActor()) + val cacheHitActor = TestActorRef[TestableBcsCacheHitCopyingActor]( + props, "TestableBcsCacheHitCopyingActor") + + noException should be thrownBy cacheHitActor.underlyingActor.processSimpletons(List(simpleton), sourceCallRootPath) + noException should be thrownBy cacheHitActor.underlyingActor.processDetritus(detritus) + } +}