diff --git a/core/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/appRoutesModels.scala b/core/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/appRoutesModels.scala index 69720391dcc..095f222fbf7 100644 --- a/core/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/appRoutesModels.scala +++ b/core/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/appRoutesModels.scala @@ -39,6 +39,8 @@ final case class CreateAppRequest(kubernetesRuntimeConfig: Option[KubernetesRunt autodeleteEnabled: Option[Boolean] ) +final case class UpdateAppRequest(autodeleteEnabled: Option[Boolean], autodeleteThreshold: Option[Int]) + final case class GetAppResponse( workspaceId: Option[WorkspaceId], appName: AppName, diff --git a/http/src/main/resources/swagger/api-docs.yaml b/http/src/main/resources/swagger/api-docs.yaml index ff87ed7d29f..b09004b5ebb 100644 --- a/http/src/main/resources/swagger/api-docs.yaml +++ b/http/src/main/resources/swagger/api-docs.yaml @@ -1594,6 +1594,42 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorReport" + patch: + summary: Updates the configuration of an app + description: Updates the configuration of an app. Currently limited to autodeletion config. + operationId: updateApp + tags: + - apps + parameters: + - in: path + name: googleProject + description: googleProject + required: true + schema: + type: string + - in: path + name: appName + description: appName + required: true + schema: + type: string + requestBody: + $ref: "#/components/requestBodies/UpdateAppRequest" + responses: + "202": + description: App update request accepted + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorReport" + "500": + description: Internal Error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorReport" delete: summary: Deletes an existing app in the given project description: deletes an App @@ -2914,6 +2950,14 @@ components: machineSize: "Standard_DS1_v2" disk: { labels: {}, name: "disk1", size: 50 } autopauseThreshold: 15 + UpdateAppRequest: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateAppRequest" + example: + autodeleteEnabled: true + autodeleteThreshold: 60 UpdateAppsRequest: content: application/json: @@ -4110,6 +4154,24 @@ components: type: boolean description: Whether to turn on autodelete + UpdateAppRequest: + description: A request to update the configuration of an app + type: object + properties: + autodeleteEnabled: + type: boolean + description: > + Optional: Whether to turn on autodelete. When autodeleteEnabled is set to true but there is no + autodeleteThreshold supplied in this request, the existing app's configuration will be used. If the + existing configuration does not have an autodeleteThreshold with a positive integer, this would result in + an invalid autodelete configuration, and therefore this update request would be rejected. + autodeleteThreshold: + type: integer + description: > + Optional: The number of minutes of idle time to elapse before the app is deleted. This must be a positive + integer. Note: deletion timing is not exact. The app will be deleted a short period of time after this + threshold time has elapsed. + UpdateAppsRequest: description: a request to update a specific set of apps (v1 or v2) required: diff --git a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/db/AppComponent.scala b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/db/AppComponent.scala index 8f9c33b8a09..23d7bdc8eef 100644 --- a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/db/AppComponent.scala +++ b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/db/AppComponent.scala @@ -302,6 +302,12 @@ object appQuery extends TableQuery(new AppTable(_)) { .map(_.status) .update(status) + def updateAutodeleteEnabled(id: AppId, autodeleteEnabled: Boolean): DBIO[Int] = + getByIdQuery(id).map(_.autodeleteEnabled).update(autodeleteEnabled) + + def updateAutodeleteThreshold(id: AppId, autodeleteThreshold: Option[Int]): DBIO[Int] = + getByIdQuery(id).map(_.autodeleteThreshold).update(autodeleteThreshold) + def markAsErrored(id: AppId): DBIO[Int] = getByIdQuery(id) .map(x => (x.status, x.diskId)) diff --git a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/api/AppRoutes.scala b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/api/AppRoutes.scala index 6ef038626f1..ea6b60ac304 100644 --- a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/api/AppRoutes.scala +++ b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/api/AppRoutes.scala @@ -5,7 +5,7 @@ package api import akka.http.scaladsl.marshalling.ToResponseMarshallable import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.server -import akka.http.scaladsl.server.Directives.{pathEndOrSingleSlash, _} +import akka.http.scaladsl.server.Directives._ import cats.effect.IO import cats.mtl.Ask import de.heikoseeberger.akkahttpcirce.ErrorAccumulatingCirceSupport._ @@ -20,6 +20,7 @@ import org.broadinstitute.dsde.workbench.leonardo.http.service.AppService import org.broadinstitute.dsde.workbench.model.UserInfo import org.broadinstitute.dsde.workbench.model.google.GoogleProject import org.broadinstitute.dsde.workbench.openTelemetry.OpenTelemetryMetrics +import org.broadinstitute.dsde.workbench.leonardo.http.api.AppRoutes.updateAppDecoder class AppRoutes(kubernetesService: AppService[IO], userInfoDirectives: UserInfoDirectives)(implicit metrics: OpenTelemetryMetrics[IO] @@ -71,6 +72,13 @@ class AppRoutes(kubernetesService: AppService[IO], userInfoDirectives: UserInfoD ) ) } ~ + patch { + entity(as[UpdateAppRequest]) { req => + complete( + updateAppHandler(userInfo, googleProject, appName, req) + ) + } + } ~ delete { parameterMap { params => complete( @@ -167,6 +175,22 @@ class AppRoutes(kubernetesService: AppService[IO], userInfoDirectives: UserInfoD resp <- ctx.span.fold(apiCall)(span => spanResource[IO](span, "listApp").use(_ => apiCall)) } yield StatusCodes.OK -> resp + private[api] def updateAppHandler(userInfo: UserInfo, + googleProject: GoogleProject, + appName: AppName, + req: UpdateAppRequest + )(implicit ev: Ask[IO, AppContext]): IO[ToResponseMarshallable] = + for { + ctx <- ev.ask[AppContext] + apiCall = kubernetesService.updateApp( + userInfo, + CloudContext.Gcp(googleProject), + appName, + req + ) + _ <- ctx.span.fold(apiCall)(span => spanResource[IO](span, "updateApp").use(_ => apiCall)) + } yield StatusCodes.Accepted + private[api] def deleteAppHandler(userInfo: UserInfo, googleProject: GoogleProject, appName: AppName, @@ -214,4 +238,12 @@ object AppRoutes { case _ => Right(NumNodepools.apply(n)) } ) + + implicit val updateAppDecoder: Decoder[UpdateAppRequest] = + Decoder.instance { x => + for { + enabled <- x.downField("autodeleteEnabled").as[Option[Boolean]] + threshold <- x.downField("autodeleteThreshold").as[Option[Int]] + } yield UpdateAppRequest(enabled, threshold) + } } diff --git a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/AppService.scala b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/AppService.scala index 0a6ccec179f..42ec6f4468a 100644 --- a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/AppService.scala +++ b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/AppService.scala @@ -29,6 +29,13 @@ trait AppService[F[_]] { params: Map[String, String] )(implicit as: Ask[F, AppContext]): F[Vector[ListAppResponse]] + def updateApp( + userInfo: UserInfo, + cloudContext: CloudContext.Gcp, + appName: AppName, + req: UpdateAppRequest + )(implicit as: Ask[F, AppContext]): F[Unit] + def deleteApp(userInfo: UserInfo, cloudContext: CloudContext.Gcp, appName: AppName, deleteDisk: Boolean)(implicit as: Ask[F, AppContext] ): F[Unit] diff --git a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/LeoAppServiceInterp.scala b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/LeoAppServiceInterp.scala index ee4fa821be4..8a582de19eb 100644 --- a/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/LeoAppServiceInterp.scala +++ b/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/LeoAppServiceInterp.scala @@ -29,6 +29,7 @@ import org.broadinstitute.dsde.workbench.leonardo.JsonCodec._ import org.broadinstitute.dsde.workbench.leonardo.SamResourceId._ import org.broadinstitute.dsde.workbench.leonardo.config._ import org.broadinstitute.dsde.workbench.leonardo.dao.{WsmApiClientProvider, WsmDao} +import org.broadinstitute.dsde.workbench.leonardo.db.DBIOInstances.dbioInstance import org.broadinstitute.dsde.workbench.leonardo.db.KubernetesServiceDbQueries.getActiveFullAppByWorkspaceIdAndAppName import org.broadinstitute.dsde.workbench.leonardo.db._ import org.broadinstitute.dsde.workbench.leonardo.http.service.LeoAppServiceInterp.{ @@ -669,6 +670,48 @@ final class LeoAppServiceInterp[F[_]: Parallel](config: AppServiceConfig, .raiseError[Unit](AppNotFoundByWorkspaceIdException(workspaceId, appName, ctx.traceId, "permission denied")) } yield GetAppResponse.fromDbResult(app, Config.proxyConfig.proxyUrlBase) + private def getUpdateAppTransaction(appId: AppId, validatedChanges: UpdateAppRequest): F[Unit] = (for { + _ <- validatedChanges.autodeleteEnabled.traverse(enabled => + appQuery + .updateAutodeleteEnabled(appId, enabled) + ) + + // note: does not clear the threshold if None. This only sets defined thresholds. + _ <- validatedChanges.autodeleteThreshold.traverse(threshold => + appQuery + .updateAutodeleteThreshold(appId, Some(threshold)) + ) + } yield ()).transaction + + override def updateApp(userInfo: UserInfo, cloudContext: CloudContext.Gcp, appName: AppName, req: UpdateAppRequest)( + implicit as: Ask[F, AppContext] + ): F[Unit] = + for { + ctx <- as.ask + + // throw 403 if no project-level permission + hasProjectPermission <- authProvider.isUserProjectReader(cloudContext, userInfo) + _ <- F.raiseWhen(!hasProjectPermission)(ForbiddenError(userInfo.userEmail, Some(ctx.traceId))) + + appOpt <- KubernetesServiceDbQueries.getActiveFullAppByName(cloudContext, appName).transaction + appResult <- F.fromOption(appOpt, + AppNotFoundException(cloudContext, appName, ctx.traceId, "No active app found in DB") + ) + + _ <- metrics.incrementCounter("updateApp", 1, Map("appType" -> appResult.app.appType.toString)) + + // throw 404 if no UpdateApp permission + listOfPermissions <- authProvider.getActions(appResult.app.samResourceId, userInfo) + hasPermission = listOfPermissions.toSet.contains(AppAction.UpdateApp) + _ <- F.raiseWhen(!hasPermission)(AppNotFoundException(cloudContext, appName, ctx.traceId, "Permission Denied")) + + // confirm that the combination of the request and the existing DB values result in a valid configuration + resolvedAutodeleteEnabled = req.autodeleteEnabled.getOrElse(appResult.app.autodeleteEnabled) + resolvedAutodeleteThreshold = req.autodeleteThreshold.orElse(appResult.app.autodeleteThreshold) + _ <- F.fromEither(validateAutodelete(resolvedAutodeleteEnabled, resolvedAutodeleteThreshold, ctx.traceId)) + _ <- getUpdateAppTransaction(appResult.app.id, req) + } yield () + override def createAppV2(userInfo: UserInfo, workspaceId: WorkspaceId, appName: AppName, req: CreateAppRequest)( implicit as: Ask[F, AppContext] ): F[Unit] = @@ -1429,13 +1472,9 @@ final class LeoAppServiceInterp[F[_]: Parallel](config: AppServiceConfig, if (req.appType == AppType.Allowed) Some(config.leoKubernetesConfig.allowedAppConfig.numOfReplicas) else None - autodeleteEnabled = req.autodeleteEnabled.getOrElse(false) - autodeleteThreshold = req.autodeleteThreshold.getOrElse(0) - _ <- Either.cond(!(autodeleteEnabled && autodeleteThreshold <= 0), - (), - BadRequestException("autodeleteThreshold should be a positive value", Some(ctx.traceId)) - ) + autodeleteEnabled = req.autodeleteEnabled.getOrElse(false) + _ <- validateAutodelete(autodeleteEnabled, req.autodeleteThreshold, ctx.traceId) } yield SaveApp( App( AppId(-1), @@ -1469,6 +1508,27 @@ final class LeoAppServiceInterp[F[_]: Parallel](config: AppServiceConfig, ) } + private[service] def validateAutodelete(autodeleteEnabled: Boolean, + autodeleteThreshold: Option[Int], + traceId: TraceId + ): Either[LeoException, Unit] = { + val invalidThreshold = autodeleteThreshold.exists(_ <= 0) + val wantEnabledButThresholdMissing = autodeleteEnabled && autodeleteThreshold.isEmpty + + for { + _ <- Either.cond(!invalidThreshold, + (), + BadRequestException("autodeleteThreshold should be a positive value", Some(traceId)) + ) + + _ <- Either.cond( + !wantEnabledButThresholdMissing, + (), + BadRequestException("when enabling autodelete, an autodeleteThreshold must be present", Some(traceId)) + ) + } yield () + } + private def getVisibleApps(allClusters: List[KubernetesCluster], userInfo: UserInfo)(implicit as: Ask[F, AppContext] ): F[Option[List[AppSamResourceId]]] = for { diff --git a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/AppServiceInterpSpec.scala b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/AppServiceInterpSpec.scala index bfe40282bb0..db7776005dd 100644 --- a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/AppServiceInterpSpec.scala +++ b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/AppServiceInterpSpec.scala @@ -15,7 +15,7 @@ import org.broadinstitute.dsde.workbench.leonardo.AppRestore.{GalaxyRestore, Oth import org.broadinstitute.dsde.workbench.leonardo.CommonTestData._ import org.broadinstitute.dsde.workbench.leonardo.KubernetesTestData._ import org.broadinstitute.dsde.workbench.leonardo.SamResourceId.AppSamResourceId -import org.broadinstitute.dsde.workbench.leonardo.TestUtils.appContext +import org.broadinstitute.dsde.workbench.leonardo.TestUtils.{appContext, defaultMockitoAnswer} import org.broadinstitute.dsde.workbench.leonardo.auth.AllowlistAuthProvider import org.broadinstitute.dsde.workbench.leonardo.config.Config.leoKubernetesConfig import org.broadinstitute.dsde.workbench.leonardo.config.{Config, CustomAppConfig, CustomApplicationAllowListConfig} @@ -36,7 +36,7 @@ import org.broadinstitute.dsde.workbench.leonardo.monitor.{ } import org.broadinstitute.dsde.workbench.leonardo.util.QueueFactory import org.broadinstitute.dsde.workbench.model.google.GoogleProject -import org.broadinstitute.dsde.workbench.model.{TraceId, WorkbenchEmail} +import org.broadinstitute.dsde.workbench.model.{TraceId, UserInfo, WorkbenchEmail} import org.broadinstitute.dsde.workbench.util2.messaging.CloudPublisher import org.broadinstitute.dsp.{ChartName, ChartVersion} import org.http4s.Uri @@ -49,7 +49,7 @@ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.prop.TableDrivenPropertyChecks._ import org.scalatestplus.mockito.MockitoSugar import org.typelevel.log4cats.StructuredLogger - +import org.scalatest.prop.TableDrivenPropertyChecks.forAll import java.time.Instant import scala.concurrent.ExecutionContext.Implicits.global @@ -3107,7 +3107,7 @@ class AppServiceInterpTest extends AnyFlatSpec with AppServiceInterpSpec with Le thrown.appType shouldBe AppType.HailBatch } - "checkIfAppCreationIsAllowedAndIsAoU" should "enable IntraNodeVisibility if customApp check is disabled" in { + "checkIfAppCreationIsAllowed" should "enable IntraNodeVisibility if customApp check is disabled" in { val interp = makeInterp(QueueFactory.makePublisherQueue(), enableCustomAppCheckFlag = false) val res = interp.checkIfAppCreationIsAllowed(userEmail, project, Uri.unsafeFromString("https://dummy")) @@ -3161,4 +3161,221 @@ class AppServiceInterpTest extends AnyFlatSpec with AppServiceInterpSpec with Le .get .isInstanceOf[ForbiddenError] shouldBe true } + + def createAppForAutodeleteTests(autodeleteEnabled: Boolean, autodeleteThreshold: Option[Int]): AppName = { + val appName = AppName("Microsoft Excel") + val createReq = createAppRequest.copy( + diskConfig = Some(PersistentDiskRequest(diskName, None, None, Map.empty)), + autodeleteEnabled = Some(autodeleteEnabled), + autodeleteThreshold = autodeleteThreshold + ) + + appServiceInterp + .createApp(userInfo, cloudContextGcp, appName, createReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + val createdApp = appServiceInterp + .getApp(userInfo, cloudContextGcp, appName) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + // confirm assumptions + + createdApp.autodeleteEnabled shouldBe autodeleteEnabled + createdApp.autodeleteThreshold shouldBe autodeleteThreshold + + appName + } + + "updateApp" should "allow enabling autodeletion by specifying both fields" in isolatedDbTest { + val initialAutodeleteEnabled = false + val appName = createAppForAutodeleteTests(initialAutodeleteEnabled, None) + + val autodeleteThreshold = 1000 + val updateReq = UpdateAppRequest(Some(true), Some(autodeleteThreshold)) + appServiceInterp + .updateApp(userInfo, cloudContextGcp, appName, updateReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + // confirm the update + + val updatedApp = appServiceInterp + .getApp(userInfo, cloudContextGcp, appName) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + updatedApp.autodeleteEnabled shouldBe true + updatedApp.autodeleteThreshold shouldBe Some(autodeleteThreshold) + } + + it should "allow enabling autodeletion by setting autodeleteEnabled=true if the existing config has a valid autodeleteThreshold" in isolatedDbTest { + val initialAutodeleteEnabled = false + val autodeleteThreshold = 1000 + val appName = createAppForAutodeleteTests(initialAutodeleteEnabled, Some(autodeleteThreshold)) + + val updateReq = UpdateAppRequest(Some(true), None) + appServiceInterp + .updateApp(userInfo, cloudContextGcp, appName, updateReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + // confirm the update + + val updatedApp = appServiceInterp + .getApp(userInfo, cloudContextGcp, appName) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + updatedApp.autodeleteEnabled shouldBe true + updatedApp.autodeleteThreshold shouldBe Some(autodeleteThreshold) + } + + it should "reject enabling autodeletion by setting autodeleteEnabled=true if the existing config has a missing autodeleteThreshold" in isolatedDbTest { + val initialAutodeleteEnabled = false + val appName = createAppForAutodeleteTests(initialAutodeleteEnabled, None) + + val updateReq = UpdateAppRequest(Some(true), None) + a[BadRequestException] should be thrownBy { + appServiceInterp + .updateApp(userInfo, cloudContextGcp, appName, updateReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + } + } + + it should "reject enabling autodeletion if the threshold in the request is invalid" in isolatedDbTest { + val initialAutodeleteEnabled = false + val appName = createAppForAutodeleteTests(initialAutodeleteEnabled, None) + + val badAutodeleteThreshold = 0 + val updateReq = UpdateAppRequest(Some(true), Some(badAutodeleteThreshold)) + a[BadRequestException] should be thrownBy { + appServiceInterp + .updateApp(userInfo, cloudContextGcp, appName, updateReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + } + } + + it should "reject disabling autodeletion if the threshold in the request is invalid" in isolatedDbTest { + val initialAutodeleteEnabled = true + val autodeleteThreshold = 1000 + val appName = createAppForAutodeleteTests(initialAutodeleteEnabled, Some(autodeleteThreshold)) + + val badAutodeleteThreshold = 0 + val updateReq = UpdateAppRequest(Some(false), Some(badAutodeleteThreshold)) + a[BadRequestException] should be thrownBy { + appServiceInterp + .updateApp(userInfo, cloudContextGcp, appName, updateReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + } + } + + it should "reject autodeletionEnabled=None if the threshold in the request is invalid" in isolatedDbTest { + val initialAutodeleteEnabled = true + val autodeleteThreshold = 1000 + val appName = createAppForAutodeleteTests(initialAutodeleteEnabled, Some(autodeleteThreshold)) + + val badAutodeleteThreshold = 0 + val updateReq = UpdateAppRequest(None, Some(badAutodeleteThreshold)) + a[BadRequestException] should be thrownBy { + appServiceInterp + .updateApp(userInfo, cloudContextGcp, appName, updateReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + } + } + + it should "allow disabling autodeletion by setting enabled = false" in isolatedDbTest { + val initialAutodeleteEnabled = true + val autodeleteThreshold = 1000 + val appName = createAppForAutodeleteTests(initialAutodeleteEnabled, Some(autodeleteThreshold)) + + val updateReq = UpdateAppRequest(Some(false), None) + appServiceInterp + .updateApp(userInfo, cloudContextGcp, appName, updateReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + // confirm the update + + val updatedApp = appServiceInterp + .getApp(userInfo, cloudContextGcp, appName) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + updatedApp.autodeleteEnabled shouldBe false + updatedApp.autodeleteThreshold shouldBe Some(autodeleteThreshold) + } + + it should "allow disabling autodeletion by setting enabled = false and specifying a new autodeleteThreshold" in isolatedDbTest { + val initialAutodeleteEnabled = true + val autodeleteThreshold = 1000 + val appName = createAppForAutodeleteTests(initialAutodeleteEnabled, Some(autodeleteThreshold)) + + val newThreshold = 2000 + val updateReq = UpdateAppRequest(Some(false), Some(newThreshold)) + appServiceInterp + .updateApp(userInfo, cloudContextGcp, appName, updateReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + // confirm the update + + val updatedApp = appServiceInterp + .getApp(userInfo, cloudContextGcp, appName) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + updatedApp.autodeleteEnabled shouldBe false + updatedApp.autodeleteThreshold shouldBe Some(newThreshold) + } + + it should "allow changing autodeletion threshold when enableAutodelete=true" in isolatedDbTest { + val initialAutodeleteEnabled = true + val autodeleteThreshold = 1000 + val appName = createAppForAutodeleteTests(initialAutodeleteEnabled, Some(autodeleteThreshold)) + + val newAutodeleteThreshold = 2000 + val updateReq = UpdateAppRequest(Some(true), Some(newAutodeleteThreshold)) + appServiceInterp + .updateApp(userInfo, cloudContextGcp, appName, updateReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + // confirm the update + + val updatedApp = appServiceInterp + .getApp(userInfo, cloudContextGcp, appName) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + updatedApp.autodeleteEnabled shouldBe true + updatedApp.autodeleteThreshold shouldBe Some(newAutodeleteThreshold) + } + + it should "allow changing autodeletion threshold when enableAutodelete=None" in isolatedDbTest { + val initialAutodeleteEnabled = true + val autodeleteThreshold = 1000 + val appName = createAppForAutodeleteTests(initialAutodeleteEnabled, Some(autodeleteThreshold)) + + val newAutodeleteThreshold = 2000 + val updateReq = UpdateAppRequest(None, Some(newAutodeleteThreshold)) + appServiceInterp + .updateApp(userInfo, cloudContextGcp, appName, updateReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + // confirm the update + + val updatedApp = appServiceInterp + .getApp(userInfo, cloudContextGcp, appName) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + updatedApp.autodeleteEnabled shouldBe true + updatedApp.autodeleteThreshold shouldBe Some(newAutodeleteThreshold) + } + + it should "error if app not found" in isolatedDbTest { + val appName = AppName("app1") + val createDiskConfig = PersistentDiskRequest(diskName, None, None, Map.empty) + val appReq = createAppRequest.copy(diskConfig = Some(createDiskConfig)) + + appServiceInterp + .createApp(userInfo, cloudContextGcp, appName, appReq) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + val wrongApp = AppName("wrongApp") + an[AppNotFoundException] should be thrownBy { + appServiceInterp + .updateApp(userInfo, cloudContextGcp, wrongApp, UpdateAppRequest(None, None)) + .unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + } + } } diff --git a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/MockAppService.scala b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/MockAppService.scala index 53e9c6dc022..d027799ea79 100644 --- a/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/MockAppService.scala +++ b/http/src/test/scala/org/broadinstitute/dsde/workbench/leonardo/http/service/MockAppService.scala @@ -80,6 +80,9 @@ class MockAppService extends AppService[IO] { ): IO[Unit] = IO.unit + override def updateApp(userInfo: UserInfo, cloudContext: CloudContext.Gcp, appName: AppName, req: UpdateAppRequest)( + implicit as: Ask[IO, AppContext] + ): IO[Unit] = IO.unit } object MockAppService extends MockAppService