This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new ec12c88860 feat: Add Python Virtual Environment Support: Add k8s 
Gateway Configuration (#5138)
ec12c88860 is described below

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
     );

Reply via email to