Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a project healing endpoint #4635

Merged
merged 17 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from 12 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 @@ -4,21 +4,22 @@ import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server._
import cats.effect.IO
import cats.effect.unsafe.implicits.global
import cats.implicits.catsSyntaxApplicativeError
import ch.epfl.bluebrain.nexus.delta.rdf.Vocabulary.contexts
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.context.{ContextValue, RemoteContextResolution}
import ch.epfl.bluebrain.nexus.delta.rdf.jsonld.encoder.JsonLdEncoder
import ch.epfl.bluebrain.nexus.delta.rdf.utils.JsonKeyOrdering
import ch.epfl.bluebrain.nexus.delta.routes.SupervisionRoutes.{allProjectsAreHealthy, unhealthyProjectsEncoder, SupervisionBundle}
import ch.epfl.bluebrain.nexus.delta.routes.SupervisionRoutes.{allProjectsAreHealthy, healingSuccessful, unhealthyProjectsEncoder, SupervisionBundle}
import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck
import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress
import ch.epfl.bluebrain.nexus.delta.sdk.directives.DeltaDirectives.emit
import ch.epfl.bluebrain.nexus.delta.sdk.directives.UriDirectives.baseUriPrefix
import ch.epfl.bluebrain.nexus.delta.sdk.directives.UriDirectives.{baseUriPrefix, projectRef}
import ch.epfl.bluebrain.nexus.delta.sdk.directives._
import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities
import ch.epfl.bluebrain.nexus.delta.sdk.marshalling.RdfMarshalling
import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri
import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.supervision
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ProjectsHealth
import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.{projects, supervision}
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ProjectHealer, ProjectRejection, ProjectsHealth}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.ProjectRef
import ch.epfl.bluebrain.nexus.delta.sourcing.stream.SupervisedDescription
import io.circe.generic.semiauto.deriveEncoder
Expand All @@ -29,7 +30,8 @@ class SupervisionRoutes(
identities: Identities,
aclCheck: AclCheck,
supervised: IO[List[SupervisedDescription]],
projectsHealth: ProjectsHealth
projectsHealth: ProjectsHealth,
projectHealer: ProjectHealer
)(implicit
baseUri: BaseUri,
cr: RemoteContextResolution,
Expand All @@ -41,21 +43,31 @@ class SupervisionRoutes(
baseUriPrefix(baseUri.prefix) {
pathPrefix("supervision") {
extractCaller { implicit caller =>
get {
concat(
authorizeFor(AclAddress.Root, supervision.read).apply {
concat(
(pathPrefix("projections") & pathEndOrSingleSlash) {
(pathPrefix("projections") & get & pathEndOrSingleSlash) {
emit(supervised.map(SupervisionBundle))
},
(pathPrefix("projects") & pathEndOrSingleSlash) {
(pathPrefix("projects") & get & pathEndOrSingleSlash) {
onSuccess(projectsHealth.health.unsafeToFuture()) { projects =>
if (projects.isEmpty) emit(StatusCodes.OK, IO.pure(allProjectsAreHealthy))
else emit(StatusCodes.InternalServerError, IO.pure(unhealthyProjectsEncoder(projects)))
}
}
)
},
authorizeFor(AclAddress.Root, projects.write).apply {
(post & pathPrefix("projects") & projectRef & pathPrefix("heal") & pathEndOrSingleSlash) { project =>
emit(
projectHealer
.heal(project)
.map(_ => healingSuccessful(project))
olivergrabinski marked this conversation as resolved.
Show resolved Hide resolved
.attemptNarrow[ProjectRejection]
)
}
}
}
)
}
}
}
Expand All @@ -79,4 +91,7 @@ object SupervisionRoutes {
Json.obj("status" := "Some projects are unhealthy.", "unhealthyProjects" := set)
}

private def healingSuccessful(project: ProjectRef) =
Json.obj("message" := s"Project '$project' has been healed.")

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.OrganizationRejecti
import ch.epfl.bluebrain.nexus.delta.sdk.projects.FetchContext.ContextRejection
import ch.epfl.bluebrain.nexus.delta.sdk.projects._
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ProjectRejection.WrappedOrganizationRejection
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ApiMappings, Project, ProjectEvent, ProjectsHealth}
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ApiMappings, Project, ProjectEvent, ProjectHealer, ProjectsHealth}
import ch.epfl.bluebrain.nexus.delta.sdk.provisioning.ProjectProvisioning
import ch.epfl.bluebrain.nexus.delta.sdk.quotas.Quotas
import ch.epfl.bluebrain.nexus.delta.sdk.sse.SseEncoder
Expand Down Expand Up @@ -79,6 +79,11 @@ object ProjectsModule extends ModuleDef {
ProjectsHealth(errorStore)
}

make[ProjectHealer].from(
(errorStore: ScopeInitializationErrorStore, scopeInitializer: ScopeInitializer, serviceAccount: ServiceAccount) =>
ProjectHealer(errorStore, scopeInitializer, serviceAccount)
)

make[ProjectsStatistics].fromEffect { (xas: Transactors) =>
ProjectsStatistics(xas)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import ch.epfl.bluebrain.nexus.delta.sdk.PriorityRoute
import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclCheck
import ch.epfl.bluebrain.nexus.delta.sdk.identities.Identities
import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ProjectsHealth
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ProjectHealer, ProjectsHealth}
import ch.epfl.bluebrain.nexus.delta.sourcing.stream.Supervisor
import izumi.distage.model.definition.{Id, ModuleDef}

Expand All @@ -30,9 +30,16 @@ object SupervisionModule extends ModuleDef {
baseUri: BaseUri,
rc: RemoteContextResolution @Id("aggregate"),
jo: JsonKeyOrdering,
projectsHealth: ProjectsHealth
projectsHealth: ProjectsHealth,
projectHealer: ProjectHealer
) =>
new SupervisionRoutes(identities, aclCheck, supervisor.getRunningProjections(), projectsHealth)(baseUri, rc, jo)
new SupervisionRoutes(
identities,
aclCheck,
supervisor.getRunningProjections(),
projectsHealth,
projectHealer
)(baseUri, rc, jo)
}

many[RemoteContextResolution].addEffect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package ch.epfl.bluebrain.nexus.delta.routes
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.model.headers.OAuth2BearerToken
import akka.http.scaladsl.server.Route
import cats.effect.IO
import cats.effect.{IO, Ref}
import ch.epfl.bluebrain.nexus.delta.sdk.acls.AclSimpleCheck
import ch.epfl.bluebrain.nexus.delta.sdk.acls.model.AclAddress
import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.ScopeInitializationFailed
import ch.epfl.bluebrain.nexus.delta.sdk.identities.IdentitiesDummy
import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.Caller
import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.supervision
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ProjectsHealth
import ch.epfl.bluebrain.nexus.delta.sdk.permissions.Permissions.{projects, supervision}
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.ProjectRejection.ProjectHealingFailed
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.{ProjectHealer, ProjectsHealth}
import ch.epfl.bluebrain.nexus.delta.sdk.utils.BaseRouteSpec
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.{Anonymous, Authenticated, Group, User}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{Label, ProjectRef}
Expand Down Expand Up @@ -42,25 +44,37 @@ class SupervisionRoutesSpec extends BaseRouteSpec {
private val description2 =
SupervisedDescription(metadata, ExecutionStrategy.TransientSingleNode, 0, ExecutionStatus.Running, progress)

def projectsHealth(unhealthyProjects: Set[ProjectRef]) =
private def projectsHealth(unhealthyProjects: Set[ProjectRef]) =
new ProjectsHealth {
override def health: IO[Set[ProjectRef]] = IO.pure(unhealthyProjects)
}

private def routesTemplate(unhealthyProjects: Set[ProjectRef]) = Route.seal(
private def projectHealer(healerWasExecuted: Ref[IO, Boolean]) = new ProjectHealer {
override def heal(project: ProjectRef): IO[Unit] = healerWasExecuted.set(true)
}
private val failingHealer = new ProjectHealer {
override def heal(project: ProjectRef): IO[Unit] =
IO.raiseError(ProjectHealingFailed(ScopeInitializationFailed("failure details"), project))
}
private val noopHealer = new ProjectHealer {
override def heal(project: ProjectRef): IO[Unit] = IO.unit
}

private def routesTemplate(unhealthyProjects: Set[ProjectRef], healer: ProjectHealer) = Route.seal(
new SupervisionRoutes(
identities,
aclCheck,
IO.pure { List(description1, description2) },
projectsHealth(unhealthyProjects)
projectsHealth(unhealthyProjects),
healer
).routes
)

private val routes = routesTemplate(Set.empty)
private val routes = routesTemplate(Set.empty, noopHealer)

override def beforeAll(): Unit = {
super.beforeAll()
aclCheck.append(AclAddress.Root, superviser -> Set(supervision.read)).accepted
aclCheck.append(AclAddress.Root, superviser -> Set(supervision.read, projects.write)).accepted
}

"The supervision projection endpoint" should {
Expand Down Expand Up @@ -89,14 +103,14 @@ class SupervisionRoutesSpec extends BaseRouteSpec {
}

"return a successful http code when there are no unhealthy projects" in {
val routesWithHealthyProjects = routesTemplate(Set.empty)
val routesWithHealthyProjects = routesTemplate(Set.empty, noopHealer)
Get("/v1/supervision/projects") ~> asSuperviser ~> routesWithHealthyProjects ~> check {
response.status shouldEqual StatusCodes.OK
}
}

"return an error code when there are unhealthy projects" in {
val routesWithUnhealthyProjects = routesTemplate(unhealthyProjects)
val routesWithUnhealthyProjects = routesTemplate(unhealthyProjects, noopHealer)
Get("/v1/supervision/projects") ~> asSuperviser ~> routesWithUnhealthyProjects ~> check {
response.status shouldEqual StatusCodes.InternalServerError
response.asJson shouldEqual
Expand All @@ -114,4 +128,47 @@ class SupervisionRoutesSpec extends BaseRouteSpec {

}

"The projects healing endpoint" should {
"be forbidden without projects/write permission" in {
val healerWasExecuted = Ref.unsafe[IO, Boolean](false)
val routesWithHealer = routesTemplate(Set.empty, projectHealer(healerWasExecuted))
olivergrabinski marked this conversation as resolved.
Show resolved Hide resolved
Post("/v1/supervision/projects/myorg/myproject/heal") ~> routesWithHealer ~> check {
response.shouldBeForbidden
}
healerWasExecuted.get.accepted shouldEqual false
}

"succeed and execute the healer" in {
val healerWasExecuted = Ref.unsafe[IO, Boolean](false)
val routesWithHealer = routesTemplate(Set.empty, projectHealer(healerWasExecuted))
Post("/v1/supervision/projects/myorg/myproject/heal") ~> asSuperviser ~> routesWithHealer ~> check {
response.status shouldEqual StatusCodes.OK
response.asJson shouldEqual
json"""
{
"message" : "Project 'myorg/myproject' has been healed."
}
"""
}
healerWasExecuted.get.accepted shouldEqual true
}

"return an error if the healing failed" in {
val routesWithFailingHealer = routesTemplate(Set.empty, failingHealer)
Post("/v1/supervision/projects/myorg/myproject/heal") ~> asSuperviser ~> routesWithFailingHealer ~> check {
response.status shouldEqual StatusCodes.InternalServerError
response.asJson shouldEqual
json"""
{
"@context" : "https://bluebrain.github.io/nexus/contexts/error.json",
"@type" : "ProjectHealingFailed",
"reason" : "Healing project 'myorg/myproject' has failed.",
"details" : "failure details"
}
"""
}
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ import ch.epfl.bluebrain.nexus.delta.plugins.blazegraph.model._
import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.ScopeInitializationFailed
import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount
import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.Organization
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.Project
import ch.epfl.bluebrain.nexus.delta.sdk.{Defaults, ScopeInitialization}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{EntityType, Identity, IriFilter}
import ch.epfl.bluebrain.nexus.delta.sourcing.model._

/**
* The default creation of the default SparqlView as part of the project initialization.
Expand Down Expand Up @@ -46,16 +45,16 @@ class BlazegraphScopeInitialization(
permission = permissions.query
)

override def onProjectCreation(project: Project, subject: Identity.Subject): IO[Unit] =
override def onProjectCreation(project: ProjectRef, subject: Identity.Subject): IO[Unit] =
views
.create(defaultViewId, project.ref, defaultValue)
.create(defaultViewId, project, defaultValue)
.void
.handleErrorWith {
case _: ResourceAlreadyExists => IO.unit // nothing to do, view already exits
case _: ProjectContextRejection => IO.unit // project or org are likely deprecated
case rej =>
val str =
s"Failed to create the default SparqlView for project '${project.ref}' due to '${rej.getMessage}'."
s"Failed to create the default SparqlView for project '$project' due to '${rej.getMessage}'."
logger.error(str) >> IO.raiseError(ScopeInitializationFailed(str))
}
.span("createDefaultSparqlView")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class BlazegraphScopeInitializationSpec

"create a default SparqlView on newly created project" in {
views.fetch(defaultViewId, project.ref).rejectedWith[ViewNotFound]
init.onProjectCreation(project, bob).accepted
init.onProjectCreation(project.ref, bob).accepted
val resource = views.fetch(defaultViewId, project.ref).accepted
resource.value match {
case v: IndexingBlazegraphView =>
Expand All @@ -85,7 +85,7 @@ class BlazegraphScopeInitializationSpec

"not create a default SparqlView if one already exists" in {
views.fetch(defaultViewId, project.ref).accepted.rev shouldEqual 1L
init.onProjectCreation(project, bob).accepted
init.onProjectCreation(project.ref, bob).accepted
views.fetch(defaultViewId, project.ref).accepted.rev shouldEqual 1L
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@ import ch.epfl.bluebrain.nexus.delta.plugins.elasticsearch.model.{defaultViewId,
import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.ScopeInitializationFailed
import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount
import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.Organization
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.Project
import ch.epfl.bluebrain.nexus.delta.sdk.views.PipeStep
import ch.epfl.bluebrain.nexus.delta.sdk.{Defaults, ScopeInitialization}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{EntityType, Identity}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{EntityType, Identity, ProjectRef}
import ch.epfl.bluebrain.nexus.delta.sourcing.stream.pipes.{DefaultLabelPredicates, SourceAsText}

/**
Expand Down Expand Up @@ -49,16 +48,16 @@ class ElasticSearchScopeInitialization(
permission = permissions.query
)

override def onProjectCreation(project: Project, subject: Identity.Subject): IO[Unit] =
override def onProjectCreation(project: ProjectRef, subject: Identity.Subject): IO[Unit] =
views
.create(defaultViewId, project.ref, defaultValue)
.create(defaultViewId, project, defaultValue)
.void
.handleErrorWith {
case _: ResourceAlreadyExists => IO.unit // nothing to do, view already exits
case _: ProjectContextRejection => IO.unit // project or org are likely deprecated
case rej =>
val str =
s"Failed to create the default ElasticSearchView for project '${project.ref}' due to '${rej.getMessage}'."
s"Failed to create the default ElasticSearchView for project '$project' due to '${rej.getMessage}'."
logger.error(str) >> IO.raiseError(ScopeInitializationFailed(str))
}
.span("createDefaultElasticSearchView")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class ElasticSearchScopeInitializationSpec

"create a default ElasticSearchView on a newly created project" in {
views.fetch(defaultViewId, project.ref).rejectedWith[ViewNotFound]
init.onProjectCreation(project, bob).accepted
init.onProjectCreation(project.ref, bob).accepted
val resource = views.fetch(defaultViewId, project.ref).accepted
resource.value match {
case v: IndexingElasticSearchView =>
Expand All @@ -87,7 +87,7 @@ class ElasticSearchScopeInitializationSpec

"not create a default ElasticSearchView if one already exists" in {
views.fetch(defaultViewId, project.ref).accepted.rev shouldEqual 1L
init.onProjectCreation(project, bob).accepted
init.onProjectCreation(project.ref, bob).accepted
views.fetch(defaultViewId, project.ref).accepted.rev shouldEqual 1L
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ import ch.epfl.bluebrain.nexus.delta.sdk.error.ServiceError.ScopeInitializationF
import ch.epfl.bluebrain.nexus.delta.sdk.identities.model.ServiceAccount
import ch.epfl.bluebrain.nexus.delta.sdk.model.BaseUri
import ch.epfl.bluebrain.nexus.delta.sdk.organizations.model.Organization
import ch.epfl.bluebrain.nexus.delta.sdk.projects.model.Project
import ch.epfl.bluebrain.nexus.delta.sdk.{Defaults, ScopeInitialization}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{EntityType, Identity}
import ch.epfl.bluebrain.nexus.delta.sourcing.model.Identity.Subject
import ch.epfl.bluebrain.nexus.delta.sourcing.model.{EntityType, Identity, ProjectRef}

final class SearchScopeInitialization(
views: CompositeViews,
Expand All @@ -29,18 +28,18 @@ final class SearchScopeInitialization(
implicit private val serviceAccountSubject: Subject = serviceAccount.subject

override def onProjectCreation(
project: Project,
project: ProjectRef,
subject: Identity.Subject
): IO[Unit] = {
views
.create(defaultViewId, project.ref, SearchViewFactory(defaults, config))
.create(defaultViewId, project, SearchViewFactory(defaults, config))
.void
.handleErrorWith {
case _: ViewAlreadyExists => IO.unit
case _: ProjectContextRejection => IO.unit
case rej =>
val str =
s"Failed to create the search view for project '${project.ref}' due to '${rej.getMessage}'."
s"Failed to create the search view for project '$project' due to '${rej.getMessage}'."
logger.error(str) >> IO.raiseError(ScopeInitializationFailed(str))
}
.named("createSearchView", "search")
Expand Down
Loading