Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 62 additions & 0 deletions http/src/main/resources/swagger/api-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand All @@ -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]
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.{
Expand Down Expand Up @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't want to commit one without the other!

Example: the app had a 1-hour threshold, but it was disabled. Now the user wants to enable with a 10-hour threshold. If only the first change goes through, the app has a surprise active 1-hour threshold.


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] =
Expand Down Expand Up @@ -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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that this makes the Create App validation more strict, to match

} yield SaveApp(
App(
AppId(-1),
Expand Down Expand Up @@ -1469,6 +1508,27 @@ final class LeoAppServiceInterp[F[_]: Parallel](config: AppServiceConfig,
)
}

private[service] def validateAutodelete(autodeleteEnabled: Boolean,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure where this should be placed. thoughts?

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 {
Expand Down
Loading