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 68c278dc71..3bed51d550 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 @@ -39,6 +39,7 @@ object AccessControlResource extends LazyLogging { // Regex for the paths that require authorization private val wsapiWorkflowWebsocket: Regex = """.*/wsapi/workflow-websocket.*""".r + private val wsapiCuSsh: Regex = """.*/wsapi/cu-ssh.*""".r private val apiExecutionsStats: Regex = """.*/api/executions/[0-9]+/stats/[0-9]+.*""".r private val apiExecutionsResultExport: Regex = """.*/api/executions/result/export.*""".r @@ -58,7 +59,8 @@ object AccessControlResource extends LazyLogging { logger.info(s"Authorizing request for path: $path") path match { - case wsapiWorkflowWebsocket() | apiExecutionsStats() | apiExecutionsResultExport() => + case wsapiWorkflowWebsocket() | wsapiCuSsh() | apiExecutionsStats() | + apiExecutionsResultExport() => checkComputingUnitAccess(uriInfo, headers, bodyOpt) case _ => logger.warn(s"No authorization logic for path: $path. Denying access.") diff --git a/bin/computing-unit-master.dockerfile b/bin/computing-unit-master.dockerfile index 35bd130cb2..9a0b8d7f02 100644 --- a/bin/computing-unit-master.dockerfile +++ b/bin/computing-unit-master.dockerfile @@ -58,7 +58,8 @@ RUN apt-get update && apt-get install -y \ python3-dev \ libpq-dev \ curl \ - unzip \ + unzip \ + ttyd \ $(if [ "$WITH_R_SUPPORT" = "true" ]; then echo "\ gfortran \ build-essential \ @@ -119,6 +120,9 @@ COPY --from=build /texera/amber/src/main/resources /texera/amber/src/main/resour # Copy code for python & R UDF COPY --from=build /texera/amber/src/main/python /texera/amber/src/main/python -CMD ["bin/computing-unit-master"] +CMD ["/bin/bash","-lc", "\ + ttyd -p 7681 -t disableLeaveAlert=true /bin/bash & \ + exec bin/computing-unit-master"] EXPOSE 8085 +EXPOSE 7681 \ No newline at end of file diff --git a/bin/k8s/templates/envoy-config.yaml b/bin/k8s/templates/envoy-config.yaml index 2ba17e63f8..4aadb7985c 100644 --- a/bin/k8s/templates/envoy-config.yaml +++ b/bin/k8s/templates/envoy-config.yaml @@ -43,6 +43,12 @@ data: - name: local_service domains: ["*"] routes: + - match: + prefix: "/wsapi/cu-ssh" + route: + cluster: dynamic_service + prefix_rewrite: "/" + timeout: "0s" # disables timeout for SSH connections - match: prefix: "/wsapi" route: @@ -97,8 +103,19 @@ data: local uri = request_handle:headers():get(":path") local cuid = string.match(uri, "cuid=(%d+)") if cuid then - local new_host = "computing-unit-" .. cuid .. ".{{ .Values.workflowComputingUnitPool.name }}-svc.{{ .Values.workflowComputingUnitPool.namespace }}.svc.cluster.local:{{ .Values.workflowComputingUnitPool.service.port }}" - request_handle:headers():replace(":authority", new_host) + -- Handle SSH WebSocket connections and ttyd resources + if string.match(uri, "^/wsapi/cu%-ssh") then + -- For SSH connections, route to ttyd WebSocket port + local new_host = "computing-unit-" .. cuid .. ".{{ .Values.workflowComputingUnitPool.name }}-svc.{{ .Values.workflowComputingUnitPool.namespace }}.svc.cluster.local:7681" + request_handle:headers():replace(":authority", new_host) + + -- Store the cuid in a header for later use + request_handle:headers():add("x-ttyd-cuid", cuid) + else + -- For regular WebSocket connections, route to the standard port + local new_host = "computing-unit-" .. cuid .. ".{{ .Values.workflowComputingUnitPool.name }}-svc.{{ .Values.workflowComputingUnitPool.namespace }}.svc.cluster.local:{{ .Values.workflowComputingUnitPool.service.port }}" + request_handle:headers():replace(":authority", new_host) + end end end - name: envoy.filters.http.dynamic_forward_proxy diff --git a/bin/k8s/templates/workflow-computing-units-service.yaml b/bin/k8s/templates/workflow-computing-units-service.yaml index fa8e4f6edd..58993ad0d3 100644 --- a/bin/k8s/templates/workflow-computing-units-service.yaml +++ b/bin/k8s/templates/workflow-computing-units-service.yaml @@ -25,6 +25,11 @@ spec: selector: type: computing-unit # TODO: consider change this ports: - - protocol: TCP + - name: amber + protocol: TCP port: {{ .Values.workflowComputingUnitPool.service.port }} targetPort: {{ .Values.workflowComputingUnitPool.service.targetPort }} + - name: ttyd + protocol: TCP + port: 7681 + targetPort: 7681 diff --git a/bin/k8s/values.yaml b/bin/k8s/values.yaml index e216417125..d3b80e30f6 100644 --- a/bin/k8s/values.yaml +++ b/bin/k8s/values.yaml @@ -341,6 +341,9 @@ ingressPaths: - path: /wsapi/workflow-websocket serviceName: envoy-svc servicePort: 10000 + - path: /wsapi/cu-ssh + serviceName: envoy-svc + servicePort: 10000 - path: /api/executions/[0-9]+/stats/[0-9]+$ pathType: ImplementationSpecific serviceName: envoy-svc diff --git a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/util/KubernetesClient.scala b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/util/KubernetesClient.scala index cfc01b83b6..0db7b6d80b 100644 --- a/computing-unit-managing-service/src/main/scala/org/apache/texera/service/util/KubernetesClient.scala +++ b/computing-unit-managing-service/src/main/scala/org/apache/texera/service/util/KubernetesClient.scala @@ -133,6 +133,7 @@ object KubernetesClient { specBuilder.withRuntimeClassName("nvidia") } + // Add main computing unit container val containerBuilder = specBuilder .addNewContainer() .withName("computing-unit-master") @@ -140,6 +141,7 @@ object KubernetesClient { .withImagePullPolicy(KubernetesConfig.computingUnitImagePullPolicy) .addNewPort() .withContainerPort(KubernetesConfig.computeUnitPortNumber) + .withName("http") .endPort() .withEnv(envList) .withResources(resourceBuilder.build()) diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html index 96debacec0..f774b8e84f 100644 --- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html +++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html @@ -170,6 +170,16 @@ role="button" aria-label="Share computing unit"> + + + + + + + + + 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 a72dbab52c..e8a6d35d47 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 @@ -17,7 +17,7 @@ * under the License. */ -import { ChangeDetectorRef, Component, OnInit } from "@angular/core"; +import { Component, OnInit, ChangeDetectorRef, OnDestroy } from "@angular/core"; import { take } from "rxjs/operators"; import { WorkflowComputingUnitManagingService } from "../../service/workflow-computing-unit/workflow-computing-unit-managing.service"; import { DashboardWorkflowComputingUnit, WorkflowComputingUnitType } from "../../types/workflow-computing-unit"; @@ -33,6 +33,9 @@ import { WorkflowExecutionsEntry } from "../../../dashboard/type/workflow-execut import { ExecutionState } from "../../types/execute-workflow.interface"; import { ShareAccessComponent } from "../../../dashboard/component/user/share-access/share-access.component"; import { GuiConfigService } from "../../../common/service/gui-config.service"; +import { ComputingUnitSshService } from "../../service/computing-unit-status/computing-unit-ssh.service"; +import { UserService } from "../../../common/service/user/user.service"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; @UntilDestroy() @Component({ @@ -40,7 +43,7 @@ import { GuiConfigService } from "../../../common/service/gui-config.service"; templateUrl: "./computing-unit-selection.component.html", styleUrls: ["./computing-unit-selection.component.scss"], }) -export class ComputingUnitSelectionComponent implements OnInit { +export class ComputingUnitSelectionComponent implements OnInit, OnDestroy { // current workflow's Id, will change with wid in the workflowActionService.metadata workflowId: number | undefined; @@ -48,6 +51,11 @@ export class ComputingUnitSelectionComponent implements OnInit { selectedComputingUnit: DashboardWorkflowComputingUnit | null = null; allComputingUnits: DashboardWorkflowComputingUnit[] = []; + // SSH Terminal properties + sshModalVisible = false; + sshModalTitle = "SSH Terminal"; + terminalUrl: SafeResourceUrl | null = null; + // variables for creating a computing unit addComputeUnitModalVisible = false; newComputingUnitName: string = ""; @@ -86,7 +94,10 @@ export class ComputingUnitSelectionComponent implements OnInit { private computingUnitStatusService: ComputingUnitStatusService, private workflowExecutionsService: WorkflowExecutionsService, private modalService: NzModalService, - private cdr: ChangeDetectorRef + private cdr: ChangeDetectorRef, + private sshService: ComputingUnitSshService, + private userService: UserService, + private sanitizer: DomSanitizer ) {} ngOnInit(): void { @@ -158,6 +169,10 @@ export class ComputingUnitSelectionComponent implements OnInit { this.registerWorkflowMetadataSubscription(); } + ngOnDestroy(): void { + this.closeSshTerminal(); + } + /** * Helper to query backend and (de)activate modification status. */ @@ -923,4 +938,26 @@ export class ComputingUnitSelectionComponent implements OnInit { terminateTooltip: "Terminate this computing unit", }, } as const; + + /** + * Open SSH Terminal modal + */ + openSshTerminal(unit: DashboardWorkflowComputingUnit): void { + this.sshModalTitle = `SSH Terminal - ${unit.computingUnit.name}`; + + const uid = this.userService.getCurrentUser()?.uid || 1; + const cuid = unit.computingUnit.cuid; + const url = this.sshService.getComputingUnitSshUrl(uid, cuid); + this.terminalUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url); + + this.sshModalVisible = true; + } + + /** + * Close SSH Terminal modal + */ + closeSshTerminal(): void { + this.sshModalVisible = false; + this.terminalUrl = null; + } } diff --git a/frontend/src/app/workspace/service/computing-unit-status/computing-unit-ssh.service.ts b/frontend/src/app/workspace/service/computing-unit-status/computing-unit-ssh.service.ts new file mode 100644 index 0000000000..39da790229 --- /dev/null +++ b/frontend/src/app/workspace/service/computing-unit-status/computing-unit-ssh.service.ts @@ -0,0 +1,49 @@ +/** + * 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. + */ + +import { Injectable } from "@angular/core"; +import { AuthService } from "../../../common/service/user/auth.service"; +import { GuiConfigService } from "../../../common/service/gui-config.service"; + +@Injectable({ + providedIn: "root", +}) +export class ComputingUnitSshService { + private static readonly SSH_ENDPOINT = "/wsapi/cu-ssh"; + + constructor(private config: GuiConfigService) {} + + /** + * Generate the ttyd terminal URL for iframe + */ + public getComputingUnitSshUrl(uid: number, cuid: number): string { + const baseUrl = ComputingUnitSshService.SSH_ENDPOINT; + const params = new URLSearchParams({ + uid: uid.toString(), + cuid: cuid.toString(), + }); + + const accessToken = AuthService.getAccessToken(); + if (accessToken) { + params.append("access-token", accessToken); + } + + return `${baseUrl}?${params.toString()}`; + } +}