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-5138-85c4b0ca5d6fc0b79275df9e1756b05303bba76c in repository https://gitbox.apache.org/repos/asf/texera.git
commit ec12c8886003bd890d341949d8e53b838d712a57 Author: Sarah Asad <[email protected]> AuthorDate: Thu May 28 15:27:21 2026 -0700 feat: Add Python Virtual Environment Support: Add k8s Gateway Configuration (#5138) <!-- Thanks for sending a pull request (PR)! Here are some tips for you: 1. If this is your first time, please read our contributor guidelines: [Contributing to Texera](https://github.com/apache/texera/blob/main/CONTRIBUTING.md) 2. Ensure you have added or run the appropriate tests for your PR 3. If the PR is work in progress, mark it a draft on GitHub. 4. Please write your PR title to summarize what this PR proposes, we are following Conventional Commits style for PR titles as well. 5. Be sure to keep the PR description updated to reflect all changes. --> ### What changes were proposed in this PR? <!-- Please clarify what changes you are proposing. The purpose of this section is to outline the changes. Here are some tips for you: 1. If you propose a new API, clarify the use case for a new API. 2. If you fix a bug, you can clarify why it is a bug. 3. If it is a refactoring, clarify what has been changed. 3. It would be helpful to include a before-and-after comparison using screenshots or GIFs. 4. Please consider writing useful notes for better and faster reviews. --> This PR is an extension of PR https://github.com/apache/texera/pull/4484, https://github.com/apache/texera/pull/4902, https://github.com/apache/texera/pull/5035, and #5069. It adds Kubernetes gateway routing and access control configurations. ### Any related issues, documentation, discussions? <!-- Please use this section to link other resources if not mentioned already. 1. If this PR fixes an issue, please include `Fixes #1234`, `Resolves #1234` or `Closes #1234`. If it is only related, simply mention the issue number. 2. If there is design documentation, please add the link. 3. If there is a discussion in the mailing list, please add the link. --> This change is part of ongoing efforts to support environment isolation and reproducibility within Texera. Related issue includes https://github.com/apache/texera/issues/4296. This PR closes sub-issue #5137. ### How was this PR tested? <!-- If tests were added, say they were added here. Or simply mention that if the PR is tested with existing test cases. Make sure to include/update test cases that check the changes thoroughly including negative and positive cases if possible. If it was tested in a way different from regular unit tests, please clarify how you tested step by step, ideally copy and paste-able, so that other reviewers can test and check, and descendants can verify in the future. If tests were not added, please describe why they were not added and/or why it was difficult to add. --> Tested manually and test added to AccessControlResourceSpec. ### Was this PR authored or co-authored using generative AI tooling? <!-- If generative AI tooling has been used in the process of authoring this PR, please include the phrase: 'Generated-by: ' followed by the name of the tool and its version. If no, write 'No'. Please refer to the [ASF Generative Tooling Guidance](https://www.apache.org/legal/generative-tooling.html) for details. --> Co-authored using: Claude Code (claude-opus-4-7) --------- Co-authored-by: Kunwoo (Chris) <[email protected]> --- .../service/resource/AccessControlResource.scala | 28 +++++++++-- .../apache/texera/AccessControlResourceSpec.scala | 58 ++++++++++++++++++++++ .../pythonvirtualenvironment/PveResource.scala | 11 ++-- .../PveWebsocketResource.scala | 4 +- bin/computing-unit-master.dockerfile | 2 + bin/k8s/templates/gateway-routes.yaml | 14 ++++++ .../computing-unit-selection.component.ts | 10 ++-- .../virtual-environment.service.ts | 11 ++-- 8 files changed, 115 insertions(+), 23 deletions(-) diff --git a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala index 0cd52f4919..d305bf8eb6 100644 --- a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala +++ b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala @@ -22,7 +22,7 @@ import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.typesafe.scalalogging.LazyLogging import jakarta.ws.rs.client.{Client, ClientBuilder, Entity} import jakarta.ws.rs.core._ -import jakarta.ws.rs.{Consumes, GET, POST, Path, Produces} +import jakarta.ws.rs.{Consumes, DELETE, GET, POST, Path, Produces} import org.apache.texera.auth.JwtParser.parseToken import org.apache.texera.auth.SessionUser import org.apache.texera.auth.util.{ComputingUnitAccess, HeaderField} @@ -43,6 +43,11 @@ object AccessControlResource extends LazyLogging { private val wsapiWorkflowWebsocket: Regex = """.*/wsapi/workflow-websocket.*""".r private val apiExecutionsStats: Regex = """.*/api/executions/[0-9]+/stats/[0-9]+.*""".r private val apiExecutionsResultExport: Regex = """.*/api/executions/result/export.*""".r + private val pveRoute: Regex = """^/?(?:auth/)?(?:api/|wsapi/)?pve(?:/.*)?$""".r + // Path patterns whose cuid lives in the URL path rather than the query string. + private val pvePvesCuidPath: Regex = """^/?(?:auth/)?(?:api/|wsapi/)?pve/pves/([0-9]+)$""".r + private val pvePackagesCuidPath: Regex = + """^/?(?:auth/)?(?:api/|wsapi/)?pve/([0-9]+)/[^/]+/packages/.+$""".r /** * Authorize the request based on the path and headers. @@ -60,7 +65,8 @@ object AccessControlResource extends LazyLogging { logger.info(s"Authorizing request for path: $path") path match { - case wsapiWorkflowWebsocket() | apiExecutionsStats() | apiExecutionsResultExport() => + case wsapiWorkflowWebsocket() | apiExecutionsStats() | apiExecutionsResultExport() | + pveRoute() => checkComputingUnitAccess(uriInfo, headers, bodyOpt) case _ => logger.warn(s"No authorization logic for path: $path. Denying access.") @@ -95,7 +101,14 @@ object AccessControlResource extends LazyLogging { qToken.orElse(hToken).orElse(bToken).getOrElse("") } logger.info(s"token extracted from request $token") - val cuid = queryParams.getOrElse("cuid", "") + + val cuid = queryParams.get("cuid").filter(_.nonEmpty).getOrElse { + uriInfo.getPath match { + case pvePvesCuidPath(c) => c + case pvePackagesCuidPath(c) => c + case _ => "" + } + } val cuidInt = try { cuid.toInt @@ -213,6 +226,15 @@ class AccessControlResource extends LazyLogging { logger.info("Request body: " + body) AccessControlResource.authorize(uriInfo, headers, Option(body).map(_.trim).filter(_.nonEmpty)) } + + @DELETE + @Path("/{path:.*}") + def authorizeDelete( + @Context uriInfo: UriInfo, + @Context headers: HttpHeaders + ): Response = { + AccessControlResource.authorize(uriInfo, headers) + } } @Path("/chat") diff --git a/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala b/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala index 751b51022f..75f3bacb10 100644 --- a/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala +++ b/access-control-service/src/test/scala/org/apache/texera/AccessControlResourceSpec.scala @@ -233,4 +233,62 @@ class AccessControlResourceSpec response.getHeaderString(HeaderField.UserName) shouldBe testUser1.getName response.getHeaderString(HeaderField.UserEmail) shouldBe testUser1.getEmail } + + private def mockRequest( + path: String, + cuidQueryParam: Option[String] + ): (UriInfo, HttpHeaders) = { + val mockUriInfo = mock(classOf[UriInfo]) + val mockHttpHeaders = mock(classOf[HttpHeaders]) + + val queryParams = new MultivaluedHashMap[String, String]() + cuidQueryParam.foreach(queryParams.add("cuid", _)) + + val requestHeaders = new MultivaluedHashMap[String, String]() + requestHeaders.add("Authorization", "Bearer " + token) + + when(mockUriInfo.getQueryParameters).thenReturn(queryParams) + when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI)) + when(mockUriInfo.getPath).thenReturn(path) + when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders) + when(mockHttpHeaders.getRequestHeader("Authorization")) + .thenReturn(util.Arrays.asList("Bearer " + token)) + + (mockUriInfo, mockHttpHeaders) + } + + it should "return OK for /pve/system with cuid as query parameter" in { + val (uri, headers) = mockRequest("/pve/system", Some(testCU.getCuid.toString)) + val response = new AccessControlResource().authorizeGet(uri, headers) + + response.getStatus shouldBe Response.Status.OK.getStatusCode + } + + it should "return OK for /pve/pves/{cuid} (cuid extracted from path)" in { + val (uri, headers) = mockRequest(s"/pve/pves/${testCU.getCuid}", None) + val response = new AccessControlResource().authorizeDelete(uri, headers) + + response.getStatus shouldBe Response.Status.OK.getStatusCode + } + + it should "return OK for /pve/{cuid}/{pveName}/packages/{packageName} (cuid extracted from path)" in { + val (uri, headers) = mockRequest(s"/pve/${testCU.getCuid}/myenv/packages/numpy", None) + val response = new AccessControlResource().authorizeDelete(uri, headers) + + response.getStatus shouldBe Response.Status.OK.getStatusCode + } + + it should "return FORBIDDEN for a PVE path with no cuid in query or path" in { + val (uri, headers) = mockRequest("/pve/no-cuid-anywhere", None) + val response = new AccessControlResource().authorizeGet(uri, headers) + + response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode + } + + it should "return FORBIDDEN for a non-PVE / non-whitelisted path" in { + val (uri, headers) = mockRequest("/random/garbage", Some(testCU.getCuid.toString)) + val response = new AccessControlResource().authorizeGet(uri, headers) + + response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode + } } diff --git a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala index 80a4f68645..ac07616d50 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala @@ -19,6 +19,8 @@ package org.apache.texera.web.resource.pythonvirtualenvironment +import org.apache.texera.config.KubernetesConfig + import javax.ws.rs._ import javax.ws.rs.core.MediaType import scala.jdk.CollectionConverters._ @@ -37,11 +39,8 @@ class PveResource { @Path("/system") @Produces(Array(MediaType.APPLICATION_JSON)) def getSystemPackages: util.Map[String, util.List[String]] = { + val isLocal = !KubernetesConfig.kubernetesComputingUnitEnabled try { - - // TODO: Support Kubernetes environment handling - val isLocal = true - val systemPkgs = PveManager.getSystemPackages(isLocal).toList.asJava @@ -103,9 +102,9 @@ class PveResource { def deletePackage( @PathParam("cuid") cuid: Int, @PathParam("pveName") pveName: String, - @PathParam("packageName") packageName: String, - @QueryParam("isLocal") isLocal: Boolean + @PathParam("packageName") packageName: String ): Response = { + val isLocal = !KubernetesConfig.kubernetesComputingUnitEnabled val messages = PveManager.deletePackages( cuid, packageName, diff --git a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala index e21f91fada..efaa266caa 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala @@ -19,6 +19,8 @@ package org.apache.texera.web.resource.pythonvirtualenvironment +import org.apache.texera.config.KubernetesConfig + import javax.websocket._ import javax.websocket.server.ServerEndpoint import java.util.concurrent.LinkedBlockingQueue @@ -41,7 +43,7 @@ class PveWebsocketResource { val cuid = params.get("cuid").get(0).toInt val pveName = params.get("pveName").get(0) - val isLocal = params.get("isLocal").get(0).toBoolean + val isLocal = !KubernetesConfig.kubernetesComputingUnitEnabled val action = params.getOrDefault("action", java.util.List.of("create")).get(0) val queue = new LinkedBlockingQueue[String]() diff --git a/bin/computing-unit-master.dockerfile b/bin/computing-unit-master.dockerfile index 0d9d60b79f..aece464438 100644 --- a/bin/computing-unit-master.dockerfile +++ b/bin/computing-unit-master.dockerfile @@ -88,11 +88,13 @@ WORKDIR /texera/amber COPY --from=build /texera/amber/requirements.txt /tmp/requirements.txt COPY --from=build /texera/amber/operator-requirements.txt /tmp/operator-requirements.txt +COPY --from=build /texera/amber/system-requirements-lock.txt /tmp/system-requirements-lock.txt # Install Python runtime dependencies RUN apt-get update && apt-get install -y \ python3-pip \ python3-dev \ + python3-venv \ libpq-dev \ && apt-get clean diff --git a/bin/k8s/templates/gateway-routes.yaml b/bin/k8s/templates/gateway-routes.yaml index 55dc40f581..ab53c184e8 100644 --- a/bin/k8s/templates/gateway-routes.yaml +++ b/bin/k8s/templates/gateway-routes.yaml @@ -118,6 +118,20 @@ spec: - group: gateway.envoyproxy.io kind: Backend name: texera-dynamic-backend + - matches: + - path: + type: PathPrefix + value: /pve + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /api/pve + backendRefs: + - group: gateway.envoyproxy.io + kind: Backend + name: texera-dynamic-backend --- # MinIO Route {{- if .Values.minio.gateway.enabled }} diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts index a2843a51bf..b9a41c5b5f 100644 --- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts +++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts @@ -782,7 +782,6 @@ export class ComputingUnitSelectionComponent implements OnInit { getPVEs(): void { const cuId = this.selectedComputingUnit!.computingUnit.cuid; - const isLocal = this.selectedComputingUnit?.computingUnit.type === "local"; this.workflowPveService .fetchPVEs(cuId) @@ -802,7 +801,7 @@ export class ComputingUnitSelectionComponent implements OnInit { })); this.workflowPveService - .getSystemPackages(isLocal) + .getSystemPackages(cuId) .pipe(untilDestroyed(this)) .subscribe({ next: installedResp => { @@ -880,11 +879,10 @@ export class ComputingUnitSelectionComponent implements OnInit { const cuId = this.selectedComputingUnit!.computingUnit.cuid; const env = this.pves[index]; const trimmedName = env.name.trim(); - const isLocal = this.selectedComputingUnit?.computingUnit.type === "local"; env.socket?.close(); - const websocketUrl = this.workflowPveService.getPveWebSocketUrl(cuId, trimmedName, isLocal, action, packages); + const websocketUrl = this.workflowPveService.getPveWebSocketUrl(cuId, trimmedName, action, packages); const socket = new WebSocket(websocketUrl); @@ -967,7 +965,6 @@ export class ComputingUnitSelectionComponent implements OnInit { createVirtualEnvironment(index: number): void { const env = this.pves[index]; const trimmedName = env.name.trim(); - const isLocal = this.selectedComputingUnit?.computingUnit.type === "local"; if (!/^[a-zA-Z0-9]+$/.test(trimmedName)) { this.notificationService.error("Environment name must contain only letters and numbers."); @@ -1066,7 +1063,6 @@ export class ComputingUnitSelectionComponent implements OnInit { private deleteUserPackages(index: number, onDone?: () => void): void { const cuId = this.selectedComputingUnit!.computingUnit.cuid; - const isLocal = this.selectedComputingUnit?.computingUnit.type === "local"; const pveName = this.pves[index].name.trim(); const packagesToDelete = [...this.pves[index].deletingPackages]; @@ -1094,7 +1090,7 @@ export class ComputingUnitSelectionComponent implements OnInit { const pkg = packagesToDelete[deleteIndex]; this.workflowPveService - .deletePackage(cuId, pveName, pkg.name, isLocal) + .deletePackage(cuId, pveName, pkg.name) .pipe(untilDestroyed(this)) .subscribe({ next: messages => { diff --git a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts index 7788cba270..d3108e4756 100644 --- a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts +++ b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts @@ -49,8 +49,8 @@ export class WorkflowPveService { return params; } - getSystemPackages(isLocal: boolean): Observable<PackageResponse> { - const params = this.buildBaseParams(); + getSystemPackages(cuid: number): Observable<PackageResponse> { + const params = this.buildBaseParams().set("cuid", cuid.toString()); return this.http.get<PackageResponse>("/pve/system", { params }); } @@ -67,8 +67,8 @@ export class WorkflowPveService { return this.http.delete(`/pve/pves/${cuid}`); } - deletePackage(cuid: number, pveName: string, packageName: string, isLocal: boolean) { - const params = this.buildBaseParams().set("isLocal", isLocal.toString()); + deletePackage(cuid: number, pveName: string, packageName: string) { + const params = this.buildBaseParams(); return this.http.delete<string[]>( `/pve/${cuid}/${encodeURIComponent(pveName)}/packages/${encodeURIComponent(packageName)}`, @@ -76,7 +76,7 @@ export class WorkflowPveService { ); } - getPveWebSocketUrl(cuid: number, pveName: string, isLocal: boolean, action: string, packages: string[] = []): string { + getPveWebSocketUrl(cuid: number, pveName: string, action: string, packages: string[] = []): string { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const query = encodeURIComponent(JSON.stringify(packages)); @@ -88,7 +88,6 @@ export class WorkflowPveService { `?packages=${query}` + `&cuid=${cuid}` + `&pveName=${encodeURIComponent(pveName)}` + - `&isLocal=${isLocal}` + `&action=${action}` + tokenParam );
