Skip to content
Draft
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,9 @@ final case class CreateAppRequest(kubernetesRuntimeConfig: Option[KubernetesRunt
autodeleteEnabled: Option[Boolean]
)

final case class UpdateAppRequest(autodeleteThreshold: Option[Int],
autodeleteEnabled: Option[Boolean])

final case class GetAppResponse(
workspaceId: Option[WorkspaceId],
appName: AppName,
Expand All @@ -54,7 +57,9 @@ final case class GetAppResponse(
appType: AppType,
chartName: ChartName,
accessScope: Option[AppAccessScope],
labels: LabelMap
labels: LabelMap,
autodeleteEnabled: Boolean,
autodeleteThreshold: Option[Int]
)

final case class ListAppResponse(workspaceId: Option[WorkspaceId],
Expand All @@ -70,7 +75,9 @@ final case class ListAppResponse(workspaceId: Option[WorkspaceId],
diskName: Option[DiskName],
auditInfo: AuditInfo,
accessScope: Option[AppAccessScope],
labels: LabelMap
labels: LabelMap,
autodeleteEnabled: Boolean,
autodeleteThreshold: Option[Int]
)

final case class GetAppResult(cluster: KubernetesCluster, nodepool: Nodepool, app: App)
Expand All @@ -97,7 +104,9 @@ object ListAppResponse {
a.appResources.disk.map(_.name),
a.auditInfo,
a.appAccessScope,
a.labels.filter(l => labelsToReturn.contains(l._1))
a.labels.filter(l => labelsToReturn.contains(l._1)),
a.autodeleteEnabled,
a.autodeleteThreshold
)
}
)
Expand All @@ -124,6 +133,8 @@ object GetAppResponse {
appResult.app.appType,
appResult.app.chart.name,
appResult.app.appAccessScope,
appResult.app.labels
appResult.app.labels,
appResult.app.autodeleteEnabled,
appResult.app.autodeleteThreshold
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,8 @@ object AppStatus {
val monitoredStatuses: Set[AppStatus] =
Set(Deleting, Provisioning)

val updatableStatuses: Set[AppStatus] = Set(Running, Stopped)

implicit class EnrichedDiskStatus(status: AppStatus) {
def isDeletable: Boolean = deletableStatuses contains status

Expand Down
70 changes: 70 additions & 0 deletions http/src/main/resources/swagger/api-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1520,6 +1520,42 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorReport"
patch:
summary: Updates the configuration of an app
description: In order to update the configuration of an app, it must first be ready
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 @@ -2840,6 +2876,14 @@ components:
machineSize: "Standard_DS1_v2"
disk: { labels: {}, name: "disk1", size: 50 }
autopauseThreshold: 15
UpdateAppRequest:
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateAppRequest"
example:
authdeleteThrehold: 15
authdeleteThreholdEnabled: true
UpdateAppsRequest:
content:
application/json:
Expand Down Expand Up @@ -4042,6 +4086,18 @@ components:
type: boolean
description: Whether to turn on autodelete

UpdateAppRequest:
description: a request to update an app
type: object
properties:
autodeleteThreshold:
type: integer
description: The number of minutes of idle time to elapse before the app is
deleted in minute. When autodeleteEnabled is true, a positive integer is required
autodeleteEnabled:
type: boolean
description: Whether to turn on autodelete

UpdateAppsRequest:
description: a request to update a specific set of apps (v1 or v2)
required:
Expand Down Expand Up @@ -4126,6 +4182,13 @@ components:
labels:
type: object
description: The labels of each app in the response whose key is in includeLabels in the request. Of type Map[String,String]
autodeleteThreshold:
type: integer
description: The number of minutes of idle time to elapse before the app is
deleted in minute. When autodeleteEnabled is true, a positive integer is required
autodeleteEnabled:
type: boolean
description: Whether to turn on autodelete

ListAppResponse:
description: the configuration of an app
Expand Down Expand Up @@ -4172,6 +4235,13 @@ components:
labels:
type: object
description: The labels of each app in the response whose key is in includeLabels in the request. Of type Map[String,String]
autodeleteThreshold:
type: integer
description: The number of minutes of idle time to elapse before the app is
deleted in minute. When autodeleteEnabled is true, a positive integer is required
autodeleteEnabled:
type: boolean
description: Whether to turn on autodelete

ListUpdateableAppResponse:
description: out-of-date app properties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,11 @@ object appQuery extends TableQuery(new AppTable(_)) {
.map(_.status)
.update(status)

def updateAutodelete(id: AppId, autodeleteEnabled: Boolean, autodeleteThreshold: Option[Int]): DBIO[Int] =
getByIdQuery(id)
.map(x => (x.autodeleteEnabled, x.autodeleteThreshold))
.update(autodeleteEnabled, 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 @@ -14,7 +14,8 @@ import io.opencensus.scala.akka.http.TracingDirective.traceRequestForService
import org.broadinstitute.dsde.workbench.leonardo.http.api.AppV2Routes.{
createAppDecoder,
getAppResponseEncoder,
listAppResponseEncoder
listAppResponseEncoder,
updateAppRequestDecoder
}
import org.broadinstitute.dsde.workbench.leonardo.http.service.AppService
import org.broadinstitute.dsde.workbench.model.UserInfo
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,15 @@ 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
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,20 @@ object AppV2Routes {
)
}

implicit val updateAppRequestDecoder: Decoder[UpdateAppRequest] =
Decoder.instance { x =>
for {
adtm <- x.downField("autodeleteThreshold").as[Option[Int]]
adte <- x.downField("autodeleteEnabled").as[Option[Boolean]]
} yield UpdateAppRequest(
adtm,
adte
)
}

implicit val nameKeyEncoder: KeyEncoder[ServiceName] = KeyEncoder.encodeKeyString.contramap(_.value)
implicit val listAppResponseEncoder: Encoder[ListAppResponse] =
Encoder.forProduct14(
Encoder.forProduct16(
"workspaceId",
"cloudContext",
"region",
Expand All @@ -228,7 +239,9 @@ object AppV2Routes {
"diskName",
"auditInfo",
"accessScope",
"labels"
"labels",
"autodeleteEnabled",
"autodeleteThreshold"
)(x =>
(x.workspaceId,
x.cloudContext,
Expand All @@ -243,12 +256,14 @@ object AppV2Routes {
x.diskName,
x.auditInfo,
x.accessScope,
x.labels
x.labels,
x.autodeleteEnabled,
x.autodeleteThreshold
)
)

implicit val getAppResponseEncoder: Encoder[GetAppResponse] =
Encoder.forProduct15(
Encoder.forProduct17(
"workspaceId",
"appName",
"cloudContext",
Expand All @@ -263,6 +278,8 @@ object AppV2Routes {
"appType",
"chartName",
"accessScope",
"labels"
"labels",
"autodeleteEnabled",
"autodeleteThreshold"
)(x => GetAppResponse.unapply(x).get)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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 @@ -578,6 +578,61 @@ final class LeoAppServiceInterp[F[_]: Parallel](config: AppServiceConfig,
.raiseError[Unit](AppNotFoundByWorkspaceIdException(workspaceId, appName, ctx.traceId, "permission denied"))
} yield GetAppResponse.fromDbResult(app, Config.proxyConfig.proxyUrlBase)

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")
)
tags = Map("appType" -> appResult.app.appType.toString)
_ <- metrics.incrementCounter("updateApp", 1, tags)
listOfPermissions <- authProvider.getActions(appResult.app.samResourceId, userInfo)

// throw 404 if no GetAppStatus permission
hasPermission = listOfPermissions.toSet.contains(AppAction.UpdateApp)
_ <-
if (hasPermission) F.unit
else
F.raiseError[Unit](
AppNotFoundException(cloudContext, appName, ctx.traceId, "Permission Denied")
)


canUpdate = AppStatus.updatableStatuses.contains(appResult.app.status)
_ <-
if (canUpdate) F.unit
else
F.raiseError[Unit](
AppCannotBeStoppedException(cloudContext, appName, appResult.app.status, ctx.traceId)
)

// auto delete
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))
)
_ <-
if (appResult.app.autodeleteEnabled != autodeleteEnabled || appResult.app.autodeleteThreshold != req.autodeleteThreshold)
appQuery.updateAutodelete(appResult.app.id, autodeleteEnabled, req.autodeleteThreshold).transaction.void
else Async[F].unit

} yield ()

override def createAppV2(userInfo: UserInfo, workspaceId: WorkspaceId, appName: AppName, req: CreateAppRequest)(
implicit as: Ask[F, AppContext]
): F[Unit] =
Expand Down Expand Up @@ -1525,6 +1580,19 @@ case class AppAlreadyExistsException(cloudContext: CloudContext, appName: AppNam
traceId = Some(traceId)
)

case class AppCannotBeUpdatedException(cloudContext: CloudContext,
appName: AppName,
status: AppStatus,
traceId: TraceId,
extraMsg: String = ""
) extends LeoException(
s"App ${cloudContext.asStringWithProvider}/${appName.value} cannot be updated in ${status} status." +
(if (status == AppStatus.Stopped) " Please start the app first." else ""),
StatusCodes.Conflict,
traceId = Some(traceId),
extraMessageInLogging = extraMsg
)

case class AppCannotBeDeletedException(cloudContext: CloudContext,
appName: AppName,
status: AppStatus,
Expand Down