This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6854-0f2cd49c39781d683f35e02547da45fbe5adc3c3 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 30e267219455776dc6e4e8d5f426dd17e8baee64 Author: Kunwoo (Chris) <[email protected]> AuthorDate: Wed Aug 5 13:20:07 2026 -0400 feat(computing-unit-managing-service): add admin endpoint to list all computing units (#6854) ### What changes were proposed in this PR? Adds an ADMIN-only endpoint that returns every non-terminated computing unit across all users. Needed for the admin Computing Units dashboard. The endpoint queries all units via `WorkflowComputingUnitDao`, excludes rows with a non-null `terminate_time`, joins `UserDao` for each owner's name and avatar, and reuses the shared `ComputingUnitHelpers` (introduced in the preceding refactor PR) to reconcile vanished pods and resolve status/metrics. The new resource is registered in `ComputingUnitManagingService`, and the startup access-control coverage check is extended to include it. `accessPrivilege` is reported as `WRITE` for admin rows (an admin can manage any unit it can see), and `isOwner` reflects whether the requesting admin happens to own the unit. Note that the mutating endpoints gate on ownership alone and have no ADMIN bypass yet, so a client must not present these rows as writable until they do — recorded in the resource's scaladoc. > 📌 **#6853 has landed and this is rebased onto it.** The diff is now just the admin resource + registration + specs, in a single commit. ### Any related issues, documentation, discussions? Closes #6477. Part of #6476 (Admin Computing Units Dashboard). Originates from discussion #6322. ### How was this PR tested? Added `AdminComputingUnitResourceSpec` (drives `listAllComputingUnits` end-to-end over the embedded DB: terminated rows excluded, `WRITE` access, `isOwner`, an empty table, and a caller owning none of the listed units) and extended `ComputingUnitManagingServiceRunSpec` to verify the admin resource registers along with the auth features that make `@RolesAllowed` enforceable. Verified on the rebased commit by CI's `platform (computing-unit-managing-service)` job, which runs `ComputingUnitManagingService/test` and the scalafmt checks: 79 tests, 0 failed, 0 canceled (Postgres is reachable there, so the DB-gated `run()` registration test executes rather than cancelling). ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code, Claude Opus 4.8 --- .../service/ComputingUnitManagingService.scala | 2 + .../resource/AdminComputingUnitResource.scala | 120 ++++++++++++++++ .../ComputingUnitManagingServiceRunSpec.scala | 65 +++++++++ .../resource/AdminComputingUnitResourceSpec.scala | 158 +++++++++++++++++++++ 4 files changed, 345 insertions(+) diff --git a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala index db63bbf2eb..f0dffc89e1 100644 --- a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala +++ b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/ComputingUnitManagingService.scala @@ -27,6 +27,7 @@ import org.apache.texera.common.config.StorageConfig import org.apache.texera.auth.{AuthFeatures, RequestLoggingFilter, RoleAnnotationEnforcer} import org.apache.texera.dao.SqlServer import org.apache.texera.service.resource.{ + AdminComputingUnitResource, ComputingUnitAccessResource, ComputingUnitManagingResource, HealthCheckResource @@ -66,6 +67,7 @@ class ComputingUnitManagingService extends Application[ComputingUnitManagingServ environment.jersey().register(new ComputingUnitManagingResource) environment.jersey().register(new ComputingUnitAccessResource) + environment.jersey().register(new AdminComputingUnitResource) RoleAnnotationEnforcer.enforce( environment.jersey.getResourceConfig, diff --git a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/AdminComputingUnitResource.scala b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/AdminComputingUnitResource.scala new file mode 100644 index 0000000000..39d0876544 --- /dev/null +++ b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/resource/AdminComputingUnitResource.scala @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service.resource + +import io.dropwizard.auth.Auth +import jakarta.annotation.security.RolesAllowed +import jakarta.ws.rs.{GET, Path, Produces} +import jakarta.ws.rs.core.MediaType +import org.apache.texera.auth.SessionUser +import org.apache.texera.dao.SqlServer +import org.apache.texera.dao.jooq.generated.Tables.WORKFLOW_COMPUTING_UNIT +import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum +import org.apache.texera.dao.jooq.generated.tables.daos.{UserDao, WorkflowComputingUnitDao} +import org.apache.texera.dao.jooq.generated.tables.pojos.WorkflowComputingUnit +import org.apache.texera.service.resource.ComputingUnitManagingResource.DashboardWorkflowComputingUnit +import org.apache.texera.service.util.ComputingUnitHelpers +import org.jooq.DSLContext + +import scala.jdk.CollectionConverters.CollectionHasAsScala + +object AdminComputingUnitResource { + private def context: DSLContext = + SqlServer + .getInstance() + .createDSLContext() +} + +// Nested under /computing-unit rather than the /admin/<domain> shape used by AdminUserResource and +// AdminExecutionResource: the gateway only routes /api/computing-unit and +// /api/access/computing-unit to this service, so /api/admin/... would fall through to the +// /api catch-all instead. Jersey resolves /computing-unit/admin/list here rather than against +// ComputingUnitManagingResource's @Path("/{cuid}"), because a literal path segment outranks a +// template one; nothing else may add a two-segment literal path under /computing-unit without +// re-checking that. +@Produces(Array(MediaType.APPLICATION_JSON)) +@Path("/computing-unit/admin") +@RolesAllowed(Array("ADMIN")) +class AdminComputingUnitResource { + + import AdminComputingUnitResource._ + + /** + * List every non-terminated computing unit across all users (ADMIN-only). + * + * TODO: every row is reported as WRITE, but nothing yet honours that on the write side. The + * mutating endpoints on [[ComputingUnitManagingResource]] gate on ownership alone + * (`userOwnComputingUnit`, or `ComputingUnitAccessResource.hasWriteAccess`, neither of which + * has an ADMIN bypass), so an admin acting on a unit it does not own gets 400/403 from + * terminate, rename, /metrics and /limits. Give those endpoints an ADMIN bypass; until then a + * client must not present these rows as writable. + */ + @GET + @Path("/list") + def listAllComputingUnits( + @Auth user: SessionUser + ): List[DashboardWorkflowComputingUnit] = { + val ctx = context + + // Filter to active units in SQL so terminated rows are never loaded. Spelled as an explicit + // `IS NULL` predicate rather than the DAO's fetchByTerminateTime(null), which would render + // `terminate_time IN (null)` and match no row at all. + val activeUnits = + ctx + .selectFrom(WORKFLOW_COMPUTING_UNIT) + .where(WORKFLOW_COMPUTING_UNIT.TERMINATE_TIME.isNull) + .fetchInto(classOf[WorkflowComputingUnit]) + .asScala + .toList + + // Unlike ComputingUnitManagingResource.listComputingUnits, only the reconcile write is wrapped + // in a transaction, not the whole method: the Kubernetes round trips stay outside so a pooled + // connection is not held open across them, and wrapping the batchUpdate keeps a mid-batch + // failure from retiring only some vanished units (autocommit would commit per statement). + + // Pod phases decide which Kubernetes units are still alive. + val podPhases = ComputingUnitHelpers.podPhasesFor(activeUnits) + + val liveUnits = SqlServer.withTransaction(ctx) { txCtx => + ComputingUnitHelpers.reconcileVanishedKubernetesUnits( + new WorkflowComputingUnitDao(txCtx.configuration()), + activeUnits, + podPhases + ) + } + + // Metrics only for survivors, so fetch after reconciliation. + val podMetrics = ComputingUnitHelpers.podMetricsFor(liveUnits) + + val userDao = new UserDao(ctx.configuration()) + val ownerInfo = ComputingUnitHelpers.resolveOwnerInfo(userDao, liveUnits.map(_.getUid).distinct) + + liveUnits.map { unit => + ComputingUnitHelpers.buildDashboardUnit( + unit, + isOwner = unit.getUid.equals(user.getUid), + accessPrivilege = PrivilegeEnum.WRITE, + ownerInfo = ownerInfo, + podPhases = podPhases, + podMetrics = podMetrics + ) + } + } +} diff --git a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/ComputingUnitManagingServiceRunSpec.scala b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/ComputingUnitManagingServiceRunSpec.scala index d2162d48c7..e4694e2d20 100644 --- a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/ComputingUnitManagingServiceRunSpec.scala +++ b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/ComputingUnitManagingServiceRunSpec.scala @@ -19,25 +19,90 @@ package org.apache.texera.service +import io.dropwizard.auth.AuthDynamicFeature +import io.dropwizard.core.setup.Environment +import io.dropwizard.jersey.DropwizardResourceConfig +import io.dropwizard.jersey.setup.JerseyEnvironment +import io.dropwizard.jetty.MutableServletContextHandler import org.apache.texera.auth.RoleAnnotationEnforcer +import org.apache.texera.common.config.StorageConfig +import org.apache.texera.dao.SqlServer import org.apache.texera.service.resource.{ + AdminComputingUnitResource, ComputingUnitAccessResource, ComputingUnitManagingResource, HealthCheckResource } +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature +import org.mockito.ArgumentMatchers.isA +import org.mockito.Mockito.{mock, verify, when} import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers +import java.sql.DriverManager + class ComputingUnitManagingServiceRunSpec extends AnyFlatSpec with Matchers { + // Booting the service opens a real connection pool, so this path is only exercisable where + // Postgres is provisioned at the configured JDBC URL (as in CI). + private def databaseReachable: Boolean = + try { + DriverManager + .getConnection( + StorageConfig.jdbcUrl, + StorageConfig.jdbcUsername, + StorageConfig.jdbcPassword + ) + .close() + true + } catch { + case _: Throwable => false + } + // Every endpoint this service registers declares @RolesAllowed/@PermitAll/@DenyAll. "ComputingUnitManagingService's registered resources" should "all declare access control" in { RoleAnnotationEnforcer.findUnannotatedEndpoints( Seq( classOf[ComputingUnitManagingResource], classOf[ComputingUnitAccessResource], + classOf[AdminComputingUnitResource], classOf[HealthCheckResource] ) ) shouldBe empty } + + "ComputingUnitManagingService.run" should "register the admin resource on the Jersey environment" in { + assume( + databaseReachable, + "run() requires a reachable Postgres at the configured JDBC URL (provided in CI)" + ) + + val jersey = mock(classOf[JerseyEnvironment]) + val context = mock(classOf[MutableServletContextHandler]) + val env = mock(classOf[Environment]) + when(env.jersey).thenReturn(jersey) + when(env.getApplicationContext).thenReturn(context) + when(jersey.getResourceConfig).thenReturn(DropwizardResourceConfig.forTesting()) + + try { + new ComputingUnitManagingService() + .run(mock(classOf[ComputingUnitManagingServiceConfiguration]), env) + + verify(jersey).register(isA(classOf[ComputingUnitManagingResource])) + verify(jersey).register(isA(classOf[ComputingUnitAccessResource])) + verify(jersey).register(isA(classOf[AdminComputingUnitResource])) + verify(jersey).setUrlPattern("/api/*") + // Without these two, Jersey never enforces AdminComputingUnitResource's + // @RolesAllowed(ADMIN) and the cross-user listing is readable by any authenticated user. + verify(jersey).register(isA(classOf[AuthDynamicFeature])) + verify(jersey).register(classOf[RolesAllowedDynamicFeature]) + } finally { + // run() calls SqlServer.initConnection, which opens a real HikariCP pool against the + // configured JDBC URL. Close it so the pool's threads/connections don't outlive this suite + // in the shared forked JVM. Swallowed because run() may have failed before initConnection, + // and a throw here would mask that failure. + try SqlServer.getInstance().close() + catch { case _: Throwable => () } + } + } } diff --git a/computing-unit-managing-service/src/test/scala/org/apache/texera/service/resource/AdminComputingUnitResourceSpec.scala b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/resource/AdminComputingUnitResourceSpec.scala new file mode 100644 index 0000000000..a721986b69 --- /dev/null +++ b/computing-unit-managing-service/src/test/scala/org/apache/texera/service/resource/AdminComputingUnitResourceSpec.scala @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service.resource + +import jakarta.annotation.security.RolesAllowed +import org.apache.texera.auth.SessionUser +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.Tables.WORKFLOW_COMPUTING_UNIT +import org.apache.texera.dao.jooq.generated.enums.{ + PrivilegeEnum, + UserRoleEnum, + WorkflowComputingUnitTypeEnum +} +import org.apache.texera.dao.jooq.generated.tables.daos.{UserDao, WorkflowComputingUnitDao} +import org.apache.texera.dao.jooq.generated.tables.pojos.{User, WorkflowComputingUnit} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} + +import java.sql.Timestamp + +class AdminComputingUnitResourceSpec + extends AnyFlatSpec + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with MockTexeraDB { + + override protected def beforeAll(): Unit = { + super.beforeAll() + initializeDBAndReplaceDSLContext() + } + + // MockTexeraDB does not truncate between tests, and this endpoint lists every unit in the + // database, so each case must start from a known-empty table — otherwise the exact-match + // assertions below depend on the order ScalaTest happens to run the cases in. Safe to wipe + // unqualified: MockTexeraDB provisions a fresh database per suite, so these are the only rows. + override protected def beforeEach(): Unit = { + super.beforeEach() + getDSLContext.deleteFrom(WORKFLOW_COMPUTING_UNIT).execute() + } + + override protected def afterAll(): Unit = + try shutdownDB() + finally super.afterAll() + + private def makeUser(uid: Int, name: String): User = { + val u = new User() + u.setUid(uid) + u.setName(name) + u.setEmail(s"[email protected]") + u.setRole(UserRoleEnum.ADMIN) + u.setPassword("password") + u.setGoogleAvatar(s"avatar-$uid") + u + } + + // Only local units are used here: a kubernetes unit would make the listing reach for the real + // KubernetesClient singleton, and its stub seam is private[util]. + private def localUnit(cuid: Int, uid: Int, name: String): WorkflowComputingUnit = { + val u = new WorkflowComputingUnit() + u.setCuid(cuid) + u.setUid(uid) + u.setName(name) + u.setType(WorkflowComputingUnitTypeEnum.local) + u + } + + // The class-level @RolesAllowed(ADMIN) is what makes Jersey reject non-ADMIN callers; that this + // spec calls the method directly is exactly why the annotation has to be asserted here. The + // enforcement half (Jersey actually honouring it) is pinned by + // ComputingUnitManagingServiceRunSpec, which verifies RolesAllowedDynamicFeature is registered. + "AdminComputingUnitResource" should "declare @RolesAllowed(ADMIN) at the class level" in { + val annotation = classOf[AdminComputingUnitResource].getAnnotation(classOf[RolesAllowed]) + annotation should not be null + annotation.value.toSeq shouldBe Seq("ADMIN") + } + + "listAllComputingUnits" should "return every non-terminated unit across users, marked WRITE" in { + val userDao = new UserDao(getDSLContext.configuration()) + val unitDao = new WorkflowComputingUnitDao(getDSLContext.configuration()) + val admin = makeUser(700, "admin") + userDao.insert(admin) + userDao.insert(makeUser(701, "other")) + unitDao.insert(localUnit(cuid = 700, uid = 700, name = "admin-cu")) + unitDao.insert(localUnit(cuid = 701, uid = 701, name = "other-cu")) + // A terminated unit must be excluded by the SQL filter. + val terminated = localUnit(cuid = 702, uid = 701, name = "terminated-cu") + terminated.setTerminateTime(new Timestamp(0L)) + unitDao.insert(terminated) + + val result = new AdminComputingUnitResource().listAllComputingUnits(new SessionUser(admin)) + + result.map(_.computingUnit.getCuid.intValue()) should contain theSameElementsAs Seq(700, 701) + all(result.map(_.accessPrivilege)) shouldBe PrivilegeEnum.WRITE + all(result.map(_.status)) shouldBe "Running" // local units + val byCuid = result.map(r => r.computingUnit.getCuid.intValue() -> r).toMap + // isOwner tracks the caller; owner name/avatar are joined from the user table. + byCuid(700).isOwner shouldBe true + byCuid(700).ownerName shouldBe "admin" + byCuid(700).ownerGoogleAvatar shouldBe "avatar-700" + byCuid(701).isOwner shouldBe false + byCuid(701).ownerName shouldBe "other" + } + + it should "return an empty list when every unit is terminated" in { + val userDao = new UserDao(getDSLContext.configuration()) + val unitDao = new WorkflowComputingUnitDao(getDSLContext.configuration()) + val admin = makeUser(710, "lonely-admin") + userDao.insert(admin) + val terminated = localUnit(cuid = 710, uid = 710, name = "only-terminated-cu") + terminated.setTerminateTime(new Timestamp(0L)) + unitDao.insert(terminated) + + new AdminComputingUnitResource().listAllComputingUnits(new SessionUser(admin)) shouldBe empty + } + + it should "return an empty list when no unit exists at all" in { + val admin = makeUser(720, "empty-admin") + new UserDao(getDSLContext.configuration()).insert(admin) + + new AdminComputingUnitResource().listAllComputingUnits(new SessionUser(admin)) shouldBe empty + } + + // A caller whose own user row was deleted (or who is an admin from another realm) still sees + // every unit, but owns none of them — isOwner must not accidentally default to true. + it should "mark no row as owned when the caller owns none of the units" in { + val userDao = new UserDao(getDSLContext.configuration()) + val unitDao = new WorkflowComputingUnitDao(getDSLContext.configuration()) + val admin = makeUser(730, "outsider-admin") + userDao.insert(admin) + userDao.insert(makeUser(731, "owner")) + unitDao.insert(localUnit(cuid = 731, uid = 731, name = "someone-elses-cu")) + + val result = new AdminComputingUnitResource().listAllComputingUnits(new SessionUser(admin)) + + result should have size 1 + result.head.isOwner shouldBe false + result.head.ownerName shouldBe "owner" + } +}
