Skip to content

Commit

Permalink
[WIT-682] MWAA SP does not validate provisioning request
Browse files Browse the repository at this point in the history
# New features and improvements

- Test coverage for MRs is now exported
- Test coverage artifacts are now saved in the pipeline
- CI optimizations

# Bug fixes

- Implemented proper validation for v1/validate endpoint
- open-generator-cli uses a fixed version
- Enabled akka.loglevel to INFO

# Related issue

Closes WIT-682

# Definition of Done for Feature/Hotfixes

## All Developments

- [x] Feature was implemented as per the requirements
- [x] If some code parts are complex they must be commented with code documentation
- [x] CI/CD is successful
- [x] Code coverage is not reduced, any new code is covered
- [x] E2E/integration tests are successful (whether run locally or in CI/CD)
- [x] If dependencies were changed, be sure that they will not impact the project, that their license is compatible, and that they introduce no vulnerabilities
- [x] Documentation have been updated
  * Documentation has been updated with explanation of the new feature if it is user-facing (eg component now has additional setting) or it impacts him in some other way (eg optional field that becomes mandatory)
  * If it is a breaking change, we have documented it as such in the MR description in a "Breaking Changes" section
- [x] Check that you are not affecting any existing environments with these changes, especially the Sandbox/Playground. This means that merging it to master and deploying it to these environments will not break them and **no manual operations that are not reported in the documentation will be needed**
- [x] Check that nothing is out of order and nothing problematic is included in the changes (sensitive information, credentials, customer information or other intellectual property) as they could end up being public (we have Open Source SP already published and automatically mirrored)
- [x] Security, Authentication and Authorization have been considered. No SQL injection, tokens handling, RBAC integration. Common security vulnerabilities identified and resolved

## API Development

- [x] Semantic of API has been checked, it is comprehensible, meaningful, with no redundant information and user oriented
- [x] Meaningful unit and integration tests are present
- [x] API Parameters are checked and errors are handled
- [x] Returned errors are meaningful to the user
- [x] API contract has been defined and documented. Documentation means explaining the meaning of all fields and including at least one example
- [x] Exceptions and errors are handled, without letting the underlying framework to respond with a generic Internal Server Error
- [x] API Performance has been assessed and is good for real world use cases. Database accesses have been optimized.
- [x] API is logging in compliance with audit standards, presence of sensitive information for GDPR has been assessed and removed or managed in case is needed

## DB Development

- [x] The database schema is designed to accurately represent the data model and meet the requirements
- [x] Tables, relationships, and constraints (e.g. primary keys, foreign keys, unique constraints) are defined appropriately and following a common convention
- [x] Normalization principles are applied to eliminate data redundancy and ensure data integrity
- [x] Schema semantic is meaningful
- [x] Fields naming are following naming convention ( Ex. camelCase or snake_case)
- [x] No fields with mixed behaviors and meaning. If a field is representing an enum, enum values are strongly mutually exclusive
- [x] Data Types have been reviewed and they are a good fit for each field
- [x] Indexes are defined to optimize query performance for frequently accessed data, paying attention to do not affect too much write path and the overall complexity
- [x] Sensitive data is stored securely, encrypted if required, and access is restricted to authorized users
- [x] Backup and restore procedures have been updated to introduce new or changed tables
- [x] Migration scripts to upgrade and downgrade the database have been implemented and tested
  • Loading branch information
Cristian Astorino authored and Nicolò Bidotti committed Nov 9, 2023
1 parent 4271ae8 commit 5622332
Show file tree
Hide file tree
Showing 30 changed files with 821 additions and 218 deletions.
53 changes: 23 additions & 30 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,7 @@ include:
ref: 'main'
file: 'common/witboost.downstream.gitlab-ci.yml'

image: ubuntu:20.04

before_script:
- apt-get update -yqq
- DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get -y install tzdata
- apt-get install -yqq openjdk-17-jdk-headless
- apt-get install -yqq gpg
- echo "deb https://repo.scala-sbt.org/scalasbt/debian /" | tee -a /etc/apt/sources.list.d/sbt.list
- mkdir -p /root/.gnupg
- gpg --recv-keys --no-default-keyring --keyring gnupg-ring:/etc/apt/trusted.gpg.d/scalasbt-release.gpg --keyserver hkp://keyserver.ubuntu.com:80 2EE0EA64E40A89B84B2DF73499E82A75642AC823
- chmod 644 /etc/apt/trusted.gpg.d/scalasbt-release.gpg
- apt-get update -yqq
- apt-get install -yqq sbt
image: sbtscala/scala-sbt:eclipse-temurin-jammy-17.0.9_9_1.9.7_2.13.12

variables:
SBT_OPTS: "-Dsbt.global.base=sbt-cache/sbtboot -Dsbt.boot.directory=sbt-cache/boot -Dsbt.ivy.home=sbt-cache/ivy -Dsbt.ci=true"
Expand All @@ -33,7 +21,7 @@ cache:

stages:
- setup
- checkFormatting
- check
- test
- build
- package
Expand All @@ -52,12 +40,12 @@ setup:
dotenv: vars.env

checkFormatting:
stage: checkFormatting
stage: check
script:
- 'sbt scalafmtSbtCheck scalafmtCheckAll'

witboost.helm.checks:
stage: checkFormatting
stage: check
extends: .witboost.helm.base-job
before_script: []
cache: []
Expand All @@ -70,30 +58,35 @@ witboost.helm.checks:
test:
stage: test
script:
- apt-get install -yqq npm
- npm install @openapitools/openapi-generator-cli -g
- 'sbt clean generateCode coverage test multi-jvm:test coverageReport'
- apt-get update -yqq && apt-get install -yqq npm
- npm install @openapitools/[email protected] -g
- 'sbt clean generateCode coverage test coverageReport'
coverage: '/Statement coverage[A-Za-z\.*]\s*:\s*([^%]+)/'
artifacts:
paths:
- target/scala-2.13/scoverage-report/*
- target/scala-2.13/coverage-report/*
reports:
coverage_report:
coverage_format: cobertura
path: 'target/scala-2.13/coverage-report/cobertura.xml'

build:
services:
- docker:19.03.12-dind
- docker:24.0.5-dind
stage: build
variables:
DOCKER_HOST: tcp://docker:2375
script: |
apt-get install -yqq curl
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu focal stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update -yqq
apt-get install -yqq docker-ce-cli
apt-get install -yqq npm
npm install @openapitools/openapi-generator-cli -g
apt-get update -yqq && apt-get install -yqq ca-certificates curl gnupg npm
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg && chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update -yqq && apt-get install -yqq docker-ce-cli
npm install @openapitools/[email protected] -g
echo $VERSION
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
sbt clean generateCode compile k8tyGitlabCIPublish docker:publish
artifacts:
reports:
dotenv: vars.env
witboost.helm.deploy:
stage: package
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Designed by [Agile Lab](https://www.agilelab.it/), witboost is a versatile platform that addresses a wide range of sophisticated data engineering challenges. It enables businesses to discover, enhance, and productize their data, fostering the creation of automated data platforms that adhere to the highest standards of data governance. Want to know more about witboost? Check it out [here](https://www.agilelab.it/witboost) or [contact us!](https://www.agilelab.it/contacts).

This repository is part of our Open Source projects meant to showcase witboost's integration capabilities and provide a "batteries-included" product.
This repository is part of our [Starter Kit](https://github.com/agile-lab-dev/witboost-starter-kit) meant to showcase witboost's integration capabilities and provide a "batteries-included" product.

# MWAA Specific Provisioner

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,36 @@ import it.agilelab.datamesh.mwaaspecificprovisioner.s3.common.ShowableOps.showTh
trait S3GatewayError extends Exception with Product with Serializable

object S3GatewayError {
final case class S3GatewayInitError(error: Throwable) extends S3GatewayError
final case class ObjectExistsErr(bucket: String, key: String, error: Throwable) extends S3GatewayError
final case class CreateFolderErr(bucket: String, folder: String, error: Throwable) extends S3GatewayError
final case class CreateFileErr(bucket: String, key: String, error: Throwable) extends S3GatewayError
final case class GetObjectContentErr(bucket: String, key: String, error: Throwable) extends S3GatewayError
final case class ListObjectsErr(bucket: String, prefix: String, error: Throwable) extends S3GatewayError
final case class ListVersionsErr(bucket: String, prefix: String, error: Throwable) extends S3GatewayError
final case class ListDeleteMarkersErr(bucket: String, prefix: String, error: Throwable) extends S3GatewayError
final case class CopyObjectErr(source: String, dest: String, error: Throwable) extends S3GatewayError
final case class DeleteObjectErr(bucket: String, obj: String, error: Throwable) extends S3GatewayError
final case class S3GatewayInitError(error: Throwable) extends S3GatewayError {
override def getMessage: String = error.getMessage
}
final case class ObjectExistsErr(bucket: String, key: String, error: Throwable) extends S3GatewayError {
override def getMessage: String = error.getMessage
}
final case class CreateFolderErr(bucket: String, folder: String, error: Throwable) extends S3GatewayError {
override def getMessage: String = error.getMessage
}
final case class CreateFileErr(bucket: String, key: String, error: Throwable) extends S3GatewayError {
override def getMessage: String = error.getMessage
}
final case class GetObjectContentErr(bucket: String, key: String, error: Throwable) extends S3GatewayError {
override def getMessage: String = error.getMessage
}
final case class ListObjectsErr(bucket: String, prefix: String, error: Throwable) extends S3GatewayError {
override def getMessage: String = error.getMessage
}
final case class ListVersionsErr(bucket: String, prefix: String, error: Throwable) extends S3GatewayError {
override def getMessage: String = error.getMessage
}
final case class ListDeleteMarkersErr(bucket: String, prefix: String, error: Throwable) extends S3GatewayError {
override def getMessage: String = error.getMessage
}
final case class CopyObjectErr(source: String, dest: String, error: Throwable) extends S3GatewayError {
override def getMessage: String = error.getMessage
}
final case class DeleteObjectErr(bucket: String, obj: String, error: Throwable) extends S3GatewayError {
override def getMessage: String = error.getMessage
}

implicit val showS3GatewayError: Show[S3GatewayError] = Show.show {
case e: S3GatewayInitError => show"S3GatewayInitError(${e.error})"
Expand Down
7 changes: 3 additions & 4 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,15 @@ lazy val root = (project in file(".")).settings(
name := "datamesh.mwaaspecificprovisioner",
Test / parallelExecution := false,
dockerBuildOptions ++= Seq("--network=host"),
dockerBaseImage := "adoptopenjdk:11-jdk-hotspot",
dockerBaseImage := "eclipse-temurin:17-jre-jammy",
dockerUpdateLatest := true,
daemonUser := "daemon",
Docker / version := (ThisBuild / version).value,
Docker / packageName :=
s"registry.gitlab.com/agilefactory/witboost.mesh/provisioning/sandbox/witboost.mesh.provisioning.sandbox.mwaaspecificprovisioner",
Docker / dockerExposedPorts := Seq(8080),
Docker / dockerExposedPorts := Seq(8093),
onChangedBuildSource := ReloadOnSourceChanges,
scalafixOnCompile := true,
semanticdbEnabled := true,
semanticdbVersion := scalafixSemanticdb.revision
).aggregate(clientGenerated).dependsOn(serverGenerated, awsIntegration).enablePlugins(JavaAppPackaging, MultiJvmPlugin)
.configs(MultiJvm).setupBuildInfo
).aggregate(clientGenerated).dependsOn(serverGenerated, awsIntegration).enablePlugins(JavaAppPackaging).setupBuildInfo
2 changes: 1 addition & 1 deletion helm/files/application.conf
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
akka {
loglevel = "OFF"
loglevel = "INFO"
actor.warn-about-java-serializer-usage = on
actor.allow-java-serialization = off
coordinated-shutdown.exit-jvm = on
Expand Down
2 changes: 0 additions & 2 deletions project/plugins.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.2.0")

addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.9")

addSbtPlugin("com.typesafe.sbt" % "sbt-multi-jvm" % "0.4.0")

addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6")

addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.34")
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/reference.conf
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
akka {
loglevel = "OFF"
loglevel = "INFO"
actor.warn-about-java-serializer-usage = on
actor.allow-java-serialization = off
coordinated-shutdown.exit-jvm = on
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ class ProvisionerApiMarshallerImpl extends SpecificProvisionerApiMarshaller {
))
}

implicit def toEntityMarshallerReverseProvisioningRequest: ToEntityMarshaller[ReverseProvisioningRequest] =
marshaller[ReverseProvisioningRequest]

implicit def fromEntityUnmarshallerReverseProvisioningRequest: FromEntityUnmarshaller[ReverseProvisioningRequest] =
unmarshaller[ReverseProvisioningRequest]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ package it.agilelab.datamesh.mwaaspecificprovisioner.api.intepreter
import akka.http.scaladsl.marshalling.ToEntityMarshaller
import akka.http.scaladsl.server.Route
import akka.http.scaladsl.unmarshalling.FromEntityUnmarshaller
import cats.data.NonEmptyList
import cats.implicits.toShow
import cats.data.Validated.{Invalid, Valid}
import com.typesafe.scalalogging.LazyLogging
import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport.{marshaller, unmarshaller}
import it.agilelab.datamesh.mwaaspecificprovisioner.api.SpecificProvisionerApiService
import it.agilelab.datamesh.mwaaspecificprovisioner.model._
import it.agilelab.datamesh.mwaaspecificprovisioner.mwaa.{MwaaManager, MwaaManagerError}
import it.agilelab.datamesh.mwaaspecificprovisioner.s3.gateway.S3GatewayError
import it.agilelab.datamesh.mwaaspecificprovisioner.mwaa.MwaaManager

class ProvisionerApiServiceImpl(mwaaManager: MwaaManager) extends SpecificProvisionerApiService with LazyLogging {

Expand Down Expand Up @@ -59,23 +57,18 @@ class ProvisionerApiServiceImpl(mwaaManager: MwaaManager) extends SpecificProvis
toEntityMarshallerRequestValidationError: ToEntityMarshaller[RequestValidationError],
toEntityMarshallerSystemError: ToEntityMarshaller[SystemError],
toEntityMarshallerProvisioningStatus: ToEntityMarshaller[ProvisioningStatus]
): Route = ProvisioningRequestDescriptor(provisioningRequest.descriptor).flatMap(mwaaManager.executeProvision) match {
case Left(e: S3GatewayError) =>
logger.error(e.show)
provision500(SystemError(e.show))
case Left(e: MwaaManagerError) =>
logger.error(e.errorMessage)
provision500(SystemError(e.errorMessage))
case Left(e: NonEmptyList[_]) =>
logger.error(e.head.toString)
provision400(RequestValidationError(e.toList.map(_.toString)))
case Right(_) =>
logger.info("OK")
provision200(ProvisioningStatus(ProvisioningStatusEnums.StatusEnum.COMPLETED, "OK"))
case other =>
logger.error("Generic Error. Received {}", other)
provision500(SystemError("Generic Error"))
}
): Route =
try mwaaManager.executeProvision(provisioningRequest.descriptor) match {
case Valid(_) => provision200(ProvisioningStatus(ProvisioningStatusEnums.StatusEnum.COMPLETED, "OK"))
case Invalid(e) => provision400(RequestValidationError(e.toList.map(_.errorMessage)))
}
catch {
case t: Throwable =>
logger.error(s"Exception in provision", t)
provision500(SystemError(
s"An unexpected error occurred while processing the request. Please try again and if the problem persists contact the platform team. Details: ${t.getMessage}"
))
}

/** Code: 200, Message: It synchronously returns the request result, DataType: String
* Code: 400, Message: Invalid input, DataType: RequestValidationError
Expand All @@ -85,7 +78,20 @@ class ProvisionerApiServiceImpl(mwaaManager: MwaaManager) extends SpecificProvis
contexts: Seq[(String, String)],
toEntityMarshallerSystemError: ToEntityMarshaller[SystemError],
toEntityMarshallerValidationResult: ToEntityMarshaller[ValidationResult]
): Route = validate200(ValidationResult(valid = true))
): Route =
try mwaaManager.executeValidation(provisioningRequest.descriptor) match {
case Valid(_) => validate200(ValidationResult(valid = true))
case Invalid(e) =>
val errors = e.map(_.errorMessage).toList
validate200(ValidationResult(valid = false, error = Some(ValidationError(errors))))
}
catch {
case t: Throwable =>
logger.error(s"Exception in validate", t)
validate500(SystemError(
s"An unexpected error occurred while processing the request. Please try again and if the problem persists contact the platform team. Details: ${t.getMessage}"
))
}

/** Code: 200, Message: It synchronously returns the request result, DataType: ProvisioningStatus
* Code: 202, Message: If successful returns a provisioning deployment task token that can be used for polling the request status, DataType: String
Expand All @@ -98,22 +104,16 @@ class ProvisionerApiServiceImpl(mwaaManager: MwaaManager) extends SpecificProvis
toEntityMarshallerSystemError: ToEntityMarshaller[SystemError],
toEntityMarshallerProvisioningStatus: ToEntityMarshaller[ProvisioningStatus]
): Route =
ProvisioningRequestDescriptor(provisioningRequest.descriptor).flatMap(mwaaManager.executeUnprovision) match {
case Left(e: S3GatewayError) =>
logger.error(e.show)
provision500(SystemError(e.show))
case Left(e: MwaaManagerError) =>
logger.error(e.errorMessage)
provision500(SystemError(e.errorMessage))
case Left(e: NonEmptyList[_]) =>
logger.error(e.head.toString)
provision400(RequestValidationError(e.toList.map(_.toString)))
case Right(_) =>
logger.info("OK")
provision200(ProvisioningStatus(ProvisioningStatusEnums.StatusEnum.COMPLETED, "OK"))
case other =>
logger.error("Generic Error. Received {}", other)
provision500(SystemError("Generic Error"))
try mwaaManager.executeUnprovision(provisioningRequest.descriptor) match {
case Valid(_) => unprovision200(ProvisioningStatus(ProvisioningStatusEnums.StatusEnum.COMPLETED, "OK"))
case Invalid(e) => unprovision400(RequestValidationError(e.toList.map(_.errorMessage)))
}
catch {
case t: Throwable =>
logger.error(s"Exception in unprovision", t)
unprovision500(SystemError(
s"An unexpected error occurred while processing the request. Please try again and if the problem persists contact the platform team. Details: ${t.getMessage}"
))
}

/** Code: 200, Message: It synchronously returns the access request response, DataType: ProvisioningStatus
Expand All @@ -126,7 +126,7 @@ class ProvisionerApiServiceImpl(mwaaManager: MwaaManager) extends SpecificProvis
toEntityMarshallerRequestValidationError: ToEntityMarshaller[RequestValidationError],
toEntityMarshallerSystemError: ToEntityMarshaller[SystemError],
toEntityMarshallerProvisioningStatus: ToEntityMarshaller[ProvisioningStatus]
): Route = updateacl200(ProvisioningStatus(ProvisioningStatusEnums.StatusEnum.COMPLETED, "OK"))
): Route = updateacl500(NotImplementedError)

/** Code: 202, Message: It returns a token that can be used for polling the async validation operation status and results, DataType: String
* Code: 400, Message: Invalid input, DataType: RequestValidationError
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ object Constants {
val SOURCE_DAG_PATH_FIELD = "sourcePath"
val BUCKET_NAME_FIELD = "bucketName"
val DAG_NAME_FIELD = "dagName"
val SCHEDULE_CRON_FIELD = "scheduleCron"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package it.agilelab.datamesh.mwaaspecificprovisioner.common

object StringUtils {

implicit class StringImplicits(val s: String) {
def ensureTrailingSlash: String = if (s.endsWith("/")) s else s"$s/"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package it.agilelab.datamesh.mwaaspecificprovisioner.error

trait ErrorType {
def errorMessage: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package it.agilelab.datamesh.mwaaspecificprovisioner.error

import it.agilelab.datamesh.mwaaspecificprovisioner.s3.gateway.S3GatewayError

case class ProvisionErrorType(error: S3GatewayError) extends ErrorType {

override def errorMessage: String =
s"An error occurred while provisioning/unprovisioning the component. Details: ${error.getMessage}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package it.agilelab.datamesh.mwaaspecificprovisioner.error

import cats.data.NonEmptyList

trait ValidationErrorType extends ErrorType

case class InvalidDescriptor(errors: NonEmptyList[String]) extends ValidationErrorType {
override def errorMessage: String = s"Descriptor is not valid. Details: ${errors.toList.mkString(",")}"
}

case class InvalidComponent(componentId: String) extends ValidationErrorType {
override def errorMessage: String = s"The component '$componentId' to provision is not present"
}

case class InvalidComponentId(componentId: String) extends ValidationErrorType {
override def errorMessage: String = s"The componentId '$componentId' is not valid"
}

case class InvalidDagName(fieldName: String, error: Throwable) extends ValidationErrorType {
override def errorMessage: String = s"The $fieldName field is not present or is invalid. Details: ${error.getMessage}"
}

case class InvalidDestinationPath(fieldName: String, error: Throwable) extends ValidationErrorType {
override def errorMessage: String = s"The $fieldName field is not present or is invalid. Details: ${error.getMessage}"
}

case class InvalidSourcePath(fieldName: String, error: Throwable) extends ValidationErrorType {
override def errorMessage: String = s"The $fieldName field is not present or is invalid. Details: ${error.getMessage}"
}

case class InvalidBucketName(fieldName: String, error: Throwable) extends ValidationErrorType {
override def errorMessage: String = s"The $fieldName field is not present or is invalid. Details: ${error.getMessage}"
}

case class InvalidScheduleCron(fieldName: String, error: Throwable) extends ValidationErrorType {
override def errorMessage: String = s"The $fieldName field is not present or is invalid. Details: ${error.getMessage}"
}

case class ErrorSourceFile(bucket: String, key: String, error: Throwable) extends ValidationErrorType {

override def errorMessage: String =
s"An error occurred while verifying existence of the file $key in bucket $bucket: ${error.getMessage}"
}

case class MissingSourceFile(bucket: String, key: String) extends ValidationErrorType {
override def errorMessage: String = s"File $key not found in bucket $bucket"
}
Loading

0 comments on commit 5622332

Please sign in to comment.