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()}`;
+ }
+}