From c42b1693ceb9ebd9a6bbba57fded8326989b1ff2 Mon Sep 17 00:00:00 2001 From: Graham Dumpleton Date: Mon, 18 Sep 2023 10:02:51 +1000 Subject: [PATCH 1/7] Create a loopback service mapping to the cluster ingress router. --- .../pkg/cmd/admin_cluster_create_cmd.go | 29 +++++++++++++++++++ .../pkg/cmd/cluster_workshop_serve_cmd.go | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/client-programs/pkg/cmd/admin_cluster_create_cmd.go b/client-programs/pkg/cmd/admin_cluster_create_cmd.go index a23b46f4..6acd30d1 100644 --- a/client-programs/pkg/cmd/admin_cluster_create_cmd.go +++ b/client-programs/pkg/cmd/admin_cluster_create_cmd.go @@ -26,6 +26,7 @@ import ( apiv1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" "k8s.io/kubectl/pkg/scheme" "github.com/vmware-tanzu-labs/educates-training-platform/client-programs/pkg/cluster" @@ -242,6 +243,10 @@ func (o *AdminClusterCreateOptions) Run() error { return errors.Wrap(err, "failed to create service for registry") } + if err = createLoopbackService(client, fullConfig.ClusterIngress.Domain); err != nil { + return err + } + if !o.WithServices { return nil } @@ -413,3 +418,27 @@ func checkPortAvailability(listenAddress string, ports []uint) (bool, error) { return true, nil } + +func createLoopbackService(k8sclient *kubernetes.Clientset, domain string) error { + service := apiv1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "loopback", + }, + Spec: apiv1.ServiceSpec{ + Type: apiv1.ServiceTypeExternalName, + ExternalName: fmt.Sprintf("localhost.%s", domain), + }, + } + + servicesClient := k8sclient.CoreV1().Services("default") + + servicesClient.Delete(context.TODO(), "loopback", *metav1.NewDeleteOptions(0)) + + _, err := servicesClient.Create(context.TODO(), &service, metav1.CreateOptions{}) + + if err != nil { + return errors.Wrap(err, "unable to create localhost loopback service") + } + + return nil +} diff --git a/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go b/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go index ea5cf5a8..cfc9aa01 100644 --- a/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go +++ b/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go @@ -264,7 +264,7 @@ func (p *ProjectInfo) NewClusterWorkshopServeCmd() *cobra.Command { c.Flags().StringVar( &o.ProxyHost, "proxy-host", - "localhost.$(ingress_domain)", + "loopback.default.svc.cluster.local", "host by which any remote proxy will be accessed", ) c.Flags().IntVar( From 71eb3b9bef243bc4835f7c87f7dd6680c3eeacd3 Mon Sep 17 00:00:00 2001 From: Graham Dumpleton Date: Mon, 18 Sep 2023 10:08:07 +1000 Subject: [PATCH 2/7] Ensure local config directory is created if doesn't exist. --- client-programs/pkg/cmd/cluster_workshop_serve_cmd.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go b/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go index ea5cf5a8..1dbcf755 100644 --- a/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go +++ b/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go @@ -80,6 +80,12 @@ func generateAccessToken(refresh bool) (string, error) { configFileDir := path.Join(xdg.DataHome, "educates") accessTokenFile := path.Join(configFileDir, "live-reload-token.dat") + err := os.MkdirAll(configFileDir, os.ModePerm) + + if err != nil { + return "", errors.Wrapf(err, "unable to create config directory") + } + var accessToken string if refresh { From 470096b781f6778525f6899696243a411dd09423 Mon Sep 17 00:00:00 2001 From: Graham Dumpleton Date: Mon, 18 Sep 2023 11:00:46 +1000 Subject: [PATCH 3/7] Allow image puller to be disabled by setting image list to empty. --- .../bundle/config/11-session-manager/07-daemonsets.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/carvel-packages/training-platform/bundle/config/11-session-manager/07-daemonsets.yaml b/carvel-packages/training-platform/bundle/config/11-session-manager/07-daemonsets.yaml index 18f969e0..0804b435 100644 --- a/carvel-packages/training-platform/bundle/config/11-session-manager/07-daemonsets.yaml +++ b/carvel-packages/training-platform/bundle/config/11-session-manager/07-daemonsets.yaml @@ -1,8 +1,11 @@ #@ load("@ytt:data", "data") #@ load("/00-package.star", "image_reference", "image_pull_secrets", "image_pull_policy") -#@ prepull = ["training-portal"] +#@ prepull = [] +#@ if data.values.imagePuller.prePullImages: +#@ prepull.append("training-portal") #@ prepull.extend(data.values.imagePuller.prePullImages) +#@ end --- #@ if prepull: From 5665fcaebcf61cff394395ae24b27930490c1995 Mon Sep 17 00:00:00 2001 From: Graham Dumpleton Date: Mon, 18 Sep 2023 13:43:31 +1000 Subject: [PATCH 4/7] Change temporary directory used for uploads. --- .../opt/gateway/src/backend/modules/uploads.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/workshop-images/base-environment/opt/gateway/src/backend/modules/uploads.ts b/workshop-images/base-environment/opt/gateway/src/backend/modules/uploads.ts index 0a7467c3..3f26c9dd 100644 --- a/workshop-images/base-environment/opt/gateway/src/backend/modules/uploads.ts +++ b/workshop-images/base-environment/opt/gateway/src/backend/modules/uploads.ts @@ -7,11 +7,10 @@ const multer = require("multer") import { config } from "./config" -// The upload file will be a magic random name so is okay to upload here -// directly. The file will then be renamed to the target name. Need to stage -// it in same directory and not /tmp as can't move file across volumes. +// Not that the temporary directory for uploads must be in the same volume +// as the workshop home directory where files will finally be placed. -const upload = multer({ dest: path.join(os.homedir(), "uploads") }) +const upload = multer({ dest: path.join(os.homedir(), ".local/share/uploads") }) export function setup_uploads(app: express.Application, token: string = null) { if (!config.enable_uploads) From c6cf01721eb2f0755210d275f43b22c4ddcd6094 Mon Sep 17 00:00:00 2001 From: Graham Dumpleton Date: Tue, 19 Sep 2023 09:57:47 +1000 Subject: [PATCH 5/7] Add API for triggering actions from terminal, with xdg-open support. --- .../base-environment/opt/eduk8s/bin/xdg-open | 14 + .../gateway/src/backend/modules/messages.ts | 218 ++++++++++++++ .../gateway/src/backend/modules/terminals.ts | 59 ++-- .../opt/gateway/src/backend/server.ts | 20 +- .../gateway/src/frontend/scripts/educates.ts | 280 +++++++++++++++++- 5 files changed, 543 insertions(+), 48 deletions(-) create mode 100755 workshop-images/base-environment/opt/eduk8s/bin/xdg-open create mode 100644 workshop-images/base-environment/opt/gateway/src/backend/modules/messages.ts diff --git a/workshop-images/base-environment/opt/eduk8s/bin/xdg-open b/workshop-images/base-environment/opt/eduk8s/bin/xdg-open new file mode 100755 index 00000000..bd0d0e78 --- /dev/null +++ b/workshop-images/base-environment/opt/eduk8s/bin/xdg-open @@ -0,0 +1,14 @@ +#!/bin/bash + +MESSAGE=$(cat < { + if (ws.readyState === WebSocket.OPEN) + ws.send(message) + }) + } + + close_connections() { + this.sockets.forEach((ws) => { ws.close() }) + } + + cleanup_connection(ws: WebSocket) { + let index = this.sockets.indexOf(ws) + if (index != -1) + this.sockets.splice(index, 1) + } + + handle_message(ws: WebSocket, packet: MessagesPacket) { + switch (packet.type) { + case MessagesPacketType.HELLO: { + let args: HelloPacketArgs = packet.args + + if (this.sockets.indexOf(ws) == -1) { + console.log("Adding messages channel", this.id) + + this.sockets.push(ws) + } + + break + } + } + } +} + +class SessionManager { + static instance: SessionManager + + id: string = uuidv4() + + private socket_server: WebSocket.Server + + private sessions = new Map() + + private constructor() { + this.socket_server = new WebSocket.Server({ noServer: true }) + + this.configure_handlers() + } + + static get_instance(): SessionManager { + if (!SessionManager.instance) + SessionManager.instance = new SessionManager() + + return SessionManager.instance + } + + private configure_handlers() { + this.socket_server.on("connection", (ws: WebSocket) => { + ws.on("message", (message: string) => { + let packet: MessagesPacket = JSON.parse(message) + let session: MessagesChannel = this.retrieve_session(packet.id) + + session.handle_message(ws, packet) + }) + + ws.on("close", () => { + this.cleanup_connection(ws) + }) + }) + } + + private retrieve_session(id: string): MessagesChannel { + let session: MessagesChannel = this.sessions.get(id) + + if (!session) { + session = new MessagesChannel(id) + this.sessions.set(id, session) + } + + return session + } + + private cleanup_connection(ws: WebSocket) { + this.sessions.forEach((session: MessagesChannel) => { + session.cleanup_connection(ws) + }) + } + + handle_upgrade(req, socket, head) { + this.socket_server.handleUpgrade(req, socket, head, (ws) => { + this.socket_server.emit('connection', ws, req) + }) + } + + broadcast_message(data: any) { + this.sessions.forEach((session: MessagesChannel) => { + session.broadcast_message(MessagesPacketType.MESSAGE, data) + }) + } + + close_all_sessions() { + this.sessions.forEach((session: MessagesChannel) => { + session.close_connections() + }) + } +} + +export class MessagesServer { + id: string + + constructor() { + this.id = SessionManager.get_instance().id + } + + session_manager() { + return SessionManager.get_instance() + } + + is_enabled() { + return true + } + + broadcast_message(data: any) { + SessionManager.get_instance().broadcast_message(data) + } + + close_all_sessions() { + SessionManager.get_instance().close_all_sessions() + } +} + +export const messages = new MessagesServer() + +export function setup_messages(app: express.Application, server: http.Server, token: string = null) { + app.use("/message/broadcast$", express.json()); + + async function messages_broadcast(req, res, next) { + let data = req.body + + messages.broadcast_message(data) + + res.status(200).send('OK') + } + + if (token) { + app.post("/message/broadcast", async function (req, res, next) { + let request_token = req.query.token + + if (!request_token || request_token != token) + return next() + + return await messages_broadcast(req, res, next) + }) + } + else { + app.post("/message/broadcast", messages_broadcast) + } +} diff --git a/workshop-images/base-environment/opt/gateway/src/backend/modules/terminals.ts b/workshop-images/base-environment/opt/gateway/src/backend/modules/terminals.ts index ed02a002..c05ee158 100644 --- a/workshop-images/base-environment/opt/gateway/src/backend/modules/terminals.ts +++ b/workshop-images/base-environment/opt/gateway/src/backend/modules/terminals.ts @@ -2,7 +2,6 @@ import * as express from "express" import * as http from "http" import * as path from "path" import * as WebSocket from "ws" -import * as url from "url" import { v4 as uuidv4 } from "uuid" @@ -11,7 +10,7 @@ import { IPty } from "node-pty" const BASEDIR = path.dirname(path.dirname(path.dirname(__dirname))) -enum PacketType { +enum TerminalsPacketType { HELLO, PING, DATA, @@ -20,8 +19,8 @@ enum PacketType { ERROR } -interface Packet { - type: PacketType +interface TerminalsPacket { + type: TerminalsPacketType id: string args?: any } @@ -92,7 +91,7 @@ class TerminalSession { seq: ++this.sequence } - this.broadcast_message(PacketType.DATA, args) + this.broadcast_message(TerminalsPacketType.DATA, args) // We need to add the data onto the sub process data buffer used // to send data to new client connections. We don't want this to @@ -118,7 +117,7 @@ class TerminalSession { console.log("Terminal session exited", this.id) - this.broadcast_message(PacketType.EXIT) + this.broadcast_message(TerminalsPacketType.EXIT) this.close_connections() @@ -129,7 +128,7 @@ class TerminalSession { }) } - private send_message(ws: WebSocket, type: PacketType, args?: any) { + private send_message(ws: WebSocket, type: TerminalsPacketType, args?: any) { if (ws.readyState !== WebSocket.OPEN) return @@ -146,7 +145,7 @@ class TerminalSession { ws.send(message) } - private broadcast_message(type: PacketType, args?: any) { + private broadcast_message(type: TerminalsPacketType, args?: any) { let packet = { type: type, id: this.id @@ -173,9 +172,9 @@ class TerminalSession { this.sockets.splice(index, 1) } - handle_message(ws: WebSocket, packet: Packet) { + handle_message(ws: WebSocket, packet: TerminalsPacket) { switch (packet.type) { - case PacketType.DATA: { + case TerminalsPacketType.DATA: { if (this.terminal) { let args: InboundDataPacketArgs = packet.args @@ -184,7 +183,7 @@ class TerminalSession { break } - case PacketType.HELLO: { + case TerminalsPacketType.HELLO: { let args: HelloPacketArgs = packet.args if (args.token == SessionManager.instance.id) { @@ -194,7 +193,7 @@ class TerminalSession { // Send notification to any existing sessions that this // session is being hijacked by new client connection. - this.broadcast_message(PacketType.ERROR, { reason: "Hijacked" }) + this.broadcast_message(TerminalsPacketType.ERROR, { reason: "Hijacked" }) if (this.sockets.indexOf(ws) == -1) { console.log("Attaching terminal session", this.id) @@ -225,7 +224,7 @@ class TerminalSession { seq: seq } - this.send_message(ws, PacketType.DATA, args) + this.send_message(ws, TerminalsPacketType.DATA, args) } else { // Is expecting that the client sends in the HELLO @@ -236,7 +235,7 @@ class TerminalSession { let args: ErrorPacketArgs = { reason: "Forbidden" } - this.send_message(ws, PacketType.ERROR, args) + this.send_message(ws, TerminalsPacketType.ERROR, args) break } @@ -245,7 +244,7 @@ class TerminalSession { // an initial resize when connect based on size in HELLO // message. } - case PacketType.RESIZE: { + case TerminalsPacketType.RESIZE: { if (this.terminal) { let args: ResizePacketArgs = packet.args @@ -303,7 +302,7 @@ class SessionManager { private configure_handlers() { this.socket_server.on("connection", (ws: WebSocket) => { ws.on("message", (message: string) => { - let packet: Packet = JSON.parse(message) + let packet: TerminalsPacket = JSON.parse(message) let session: TerminalSession = this.retrieve_session(packet.id) session.handle_message(ws, packet) @@ -333,13 +332,9 @@ class SessionManager { } handle_upgrade(req, socket, head) { - const pathname = url.parse(req.url).pathname; - - if (pathname == "/terminal/server") { - this.socket_server.handleUpgrade(req, socket, head, (ws) => { - this.socket_server.emit('connection', ws, req) - }) - } + this.socket_server.handleUpgrade(req, socket, head, (ws) => { + this.socket_server.emit('connection', ws, req) + }) } close_all_sessions() { @@ -356,24 +351,28 @@ export class TerminalServer { this.id = SessionManager.get_instance().id } + session_manager() { + return SessionManager.get_instance() + } + + is_enabled() { + return ENABLE_TERMINAL == "true" + } + close_all_sessions() { SessionManager.get_instance().close_all_sessions() } } +export const terminals = new TerminalServer() + const ENABLE_TERMINAL = process.env.ENABLE_TERMINAL export function setup_terminals(app: express.Application, server: http.Server) { if (ENABLE_TERMINAL != "true") return - let session_manager = SessionManager.get_instance() - - server.on("upgrade", (req, socket, head) => { - session_manager.handle_upgrade(req, socket, head) - }) - - app.locals.endpoint_id = session_manager.id + app.locals.endpoint_id = terminals.id app.get("/terminal/?$", (req, res) => { res.redirect("/terminal/session/1") diff --git a/workshop-images/base-environment/opt/gateway/src/backend/server.ts b/workshop-images/base-environment/opt/gateway/src/backend/server.ts index 749983ca..26cd71dd 100644 --- a/workshop-images/base-environment/opt/gateway/src/backend/server.ts +++ b/workshop-images/base-environment/opt/gateway/src/backend/server.ts @@ -7,11 +7,13 @@ import * as session from "express-session" import { v4 as uuidv4 } from "uuid" import { createProxyMiddleware } from "http-proxy-middleware" import * as morgan from "morgan" +import * as url from "url" import { setup_access } from "./modules/access" import { setup_proxy } from "./modules/proxy" import { setup_session } from "./modules/session" -import { setup_terminals, TerminalServer } from "./modules/terminals" +import { setup_terminals, terminals } from "./modules/terminals" +import { setup_messages, messages } from "./modules/messages" import { setup_dashboard } from "./modules/dashboard" import { setup_assets } from "./modules/assets" import { setup_slides } from "./modules/slides" @@ -32,8 +34,6 @@ const app = express() const server = http.createServer(app) -const terminals = new TerminalServer() - app.set("views", path.join(BASEDIR, "src/backend/views")) app.set("view engine", "pug") @@ -158,6 +158,8 @@ async function main() { setup_files(app, config.services_password) setup_uploads(app, config.services_password) + setup_messages(app, server, config.services_password) + // Assets are made visible without authentication so that Microsoft // Clarity can access any stylesheets so it can render screen // recordings. Note that this includes a bypass for the workshop @@ -180,10 +182,22 @@ async function main() { setup_examiner(app) setup_files(app) setup_uploads(app) + + setup_messages(app, server) + setup_dashboard(app, oauth2_client) setup_routing(app) + server.on("upgrade", (req, socket, head) => { + let parsedUrl = url.parse(req.url, true) + if (terminals.is_enabled() && parsedUrl.pathname == "/terminal/server") { + terminals.session_manager().handle_upgrade(req, socket, head) + } else if (terminals.is_enabled() && parsedUrl.pathname == "/message/server") { + messages.session_manager().handle_upgrade(req, socket, head) + } + }) + start_http_server() } catch (error) { logger.error("Unexpected error occurred", error) diff --git a/workshop-images/base-environment/opt/gateway/src/frontend/scripts/educates.ts b/workshop-images/base-environment/opt/gateway/src/frontend/scripts/educates.ts index f8dfebfe..5ed83401 100644 --- a/workshop-images/base-environment/opt/gateway/src/frontend/scripts/educates.ts +++ b/workshop-images/base-environment/opt/gateway/src/frontend/scripts/educates.ts @@ -125,7 +125,245 @@ async function send_analytics_event(event: string, data = {}, timeout = 0) { } } -enum PacketType { +enum MessagesPacketType { + HELLO, + PING, + MESSAGE, +} + +interface MessagesHelloPacketArgs { +} + +interface MessagesPacket { + type: MessagesPacketType + id: string + args?: any +} + +class MessagesChannel { + private id: string + private socket: WebSocket + private sequence: number + private reconnecting: boolean + private reconnectTimer: any + private shutdown: boolean + + constructor(id: string) { + this.id = id + + this.sequence = -1 + + this.shutdown = false + this.reconnecting = false + + this.configure_session() + } + + private configure_session() { + let parsed_url = url.parse(window.location.origin) + + let protocol = parsed_url.protocol == "https:" ? "wss" : "ws" + let host = parsed_url.host + let pathname = "/message/server" + + let server_url = `${protocol}://${host}${pathname}` + + console.log("Configure channel for messages", this.id) + + this.socket = new WebSocket(server_url) + + this.configure_handlers() + } + + private configure_handlers() { + if (this.shutdown) + return + + console.log("Configure handlers for messages", this.id) + + this.socket.onerror = (event) => { + console.error("WebSocket error observed:", event) + } + + let socket: WebSocket = this.socket + + this.socket.onopen = async () => { + // If the socket isn't the one currently associated with the + // terminal then bail out straight away as some sort of mixup has + // occurred. Close the socket for good measure. + + console.log("Connection opened for messages", this.id) + + if (this.reconnectTimer) { + console.log("Clear reconnection timeout for messages", this.id) + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + + if (socket !== this.socket) { + console.warn("Multiple connections for messages", this.id) + socket.close() + return + } + + this.reconnecting = false + + let args: MessagesHelloPacketArgs = { + } + + this.send_message(MessagesPacketType.HELLO, args) + + // A sequence number of -1 means this is a completely new session. + // In this case we need to setup the callback for receiving messages + // over the channel and initiate the pings. We can only do this once + // else we get duplicate registrations if we have to reconnect + // because the connection is dropped. + + if (this.sequence == -1) { + this.initiate_pings() + + // Set sequence number to 0 so we don't do this all again. + + this.sequence = 0 + } + else { + console.log("Re-connecting messages channel", this.id) + } + } + + this.socket.onmessage = async (evt) => { + // If the socket isn't the one currently associated with the + // channel then bail out straight away as some sort of mixup has + // occurred. Close the socket for good measure. + + if (this.socket === null) { + console.warn("Connection was abandoned for channel", this.id) + socket.close() + return + } + + if (socket !== this.socket) { + console.warn("Multiple connections for message channel", this.id) + socket.close() + return + } + + let packet: MessagesPacket = JSON.parse(evt.data) + + if (packet.id == this.id) { + switch (packet.type) { + case (MessagesPacketType.MESSAGE): { + let handler = action_table[packet.args.action] + if (handler !== undefined) + handler(packet.args.data) + } + } + } + else { + console.warn("Client session " + this.id + " received message for session " + packet.id) + } + } + + this.socket.onclose = (_evt: any) => { + // If the socket isn't the one currently associated with the message + // channel then bail out straight away as some sort of mixup has + // occurred. + + if (socket !== this.socket) + return + + let self = this + + this.socket = null + + if (this.shutdown) + return + + function connect() { + if (this.shutdown) + return + + let parsed_url = url.parse(window.location.origin) + + let protocol = parsed_url.protocol == "https:" ? "wss" : "ws" + let host = parsed_url.host + let pathname = "/message/server" + + let server_url = `${protocol}://${host}${pathname}` + + console.log("Attempt re-connect for message channel", self.id) + + self.socket = new WebSocket(server_url) + + self.configure_handlers() + } + + console.log("Messages connection was lost", self.id) + + setTimeout(connect, 100) + + async function terminate() { + self.reconnectTimer = null + + if (!self.reconnecting) + return + + console.log("Abandoning connection for messages", self.id) + + self.reconnecting = false + self.shutdown = true + + self.socket = null + } + + if (!this.reconnecting) { + console.log("Trigger reconnection timeout for messages", self.id) + + self.reconnectTimer = setTimeout(terminate, 10000) + + this.reconnecting = true + } + } + } + + private initiate_pings() { + let self = this + + // Ping messages are only sent from client to backend server. Some + // traffic is required when the session is otherwise idle, else you + // can't tell if the connection has been dropped. + + function ping() { + self.send_message(MessagesPacketType.PING) + setTimeout(ping, 15000) + } + + setTimeout(ping, 15000) + } + + private send_message(type: MessagesPacketType, args?: any): boolean { + if (!this.socket) + return false + + if (this.socket.readyState === WebSocket.OPEN) { + let packet: MessagesPacket = { + type: type, + id: this.id + } + + if (args !== undefined) + packet["args"] = args + + this.socket.send(JSON.stringify(packet)) + + return true + } + + return false + } +} + +enum TerminalsPacketType { HELLO, PING, DATA, @@ -134,13 +372,13 @@ enum PacketType { ERROR } -interface Packet { - type: PacketType +interface TerminalsPacket { + type: TerminalsPacketType id: string args?: any } -interface HelloPacketArgs { +interface TerminalsHelloPacketArgs { token: string cols: number rows: number @@ -316,14 +554,14 @@ class TerminalSession { // yet. If this is a completely new session, the sequence number // will start out as -1 so we will be sent everything. - let args: HelloPacketArgs = { + let args: TerminalsHelloPacketArgs = { token: this.endpoint, cols: this.terminal.cols, rows: this.terminal.rows, seq: this.sequence } - this.send_message(PacketType.HELLO, args) + this.send_message(TerminalsPacketType.HELLO, args) // A sequence number of -1 means this is a completely new session. // In this case we need to setup the callback for receiving input @@ -340,7 +578,7 @@ class TerminalSession { if (this.synced) { let args: OutboundDataPacketArgs = { data: data } - this.send_message(PacketType.DATA, args) + this.send_message(TerminalsPacketType.DATA, args) } }) @@ -406,11 +644,11 @@ class TerminalSession { return } - let packet: Packet = JSON.parse(evt.data) + let packet: TerminalsPacket = JSON.parse(evt.data) if (packet.id == this.id) { switch (packet.type) { - case (PacketType.DATA): { + case (TerminalsPacketType.DATA): { let args: InboundDataPacketArgs = packet.args await this.write(args.data) @@ -440,7 +678,7 @@ class TerminalSession { break } - case (PacketType.EXIT): { + case (TerminalsPacketType.EXIT): { console.log("Terminal has exited", this.id) $(this.element).addClass("notify-exited") @@ -461,7 +699,7 @@ class TerminalSession { break } - case (PacketType.ERROR): { + case (TerminalsPacketType.ERROR): { let args: ErrorPacketArgs = packet.args // Right now we only expect to receive reasons of @@ -598,7 +836,7 @@ class TerminalSession { // can't tell if the connection has been dropped. function ping() { - self.send_message(PacketType.PING) + self.send_message(TerminalsPacketType.PING) setTimeout(ping, 15000) } @@ -621,16 +859,16 @@ class TerminalSession { rows: this.terminal.rows } - this.send_message(PacketType.RESIZE, args) + this.send_message(TerminalsPacketType.RESIZE, args) } } - private send_message(type: PacketType, args?: any): boolean { + private send_message(type: TerminalsPacketType, args?: any): boolean { if (!this.socket) return false if (this.socket.readyState === WebSocket.OPEN) { - let packet: Packet = { + let packet: TerminalsPacket = { type: type, id: this.id } @@ -897,10 +1135,15 @@ class Dashboard { private expiration: number private extendable: boolean private expiring: boolean + private messages: MessagesChannel constructor() { let $body = $("body") + console.log("Initializing message channel") + + this.messages = new MessagesChannel("0") + if ($("#dashboard").length) { // To indicate progress, update message on startup cover panel. Also // hide the cover panel after 15 seconds if we don't get through all @@ -1646,6 +1889,10 @@ interface DashboardPreviewOptions { title: string } +interface OpenUrlOptions { + url: string +} + const action_table = { "terminal:execute": async function (args: TerminalExecuteOptions) { let id = args.session || "1" @@ -1717,6 +1964,9 @@ const action_table = { "dashboard:terminate-session": function () { dashboard.terminate_session() }, + "dashboard:open-url": function (args: OpenUrlOptions) { + window.open(args.url, "_blank") + }, } window.addEventListener("message", function (event) { From e5914493b4a05413a37c4938f25ed31687008757 Mon Sep 17 00:00:00 2001 From: Graham Dumpleton Date: Tue, 19 Sep 2023 11:34:48 +1000 Subject: [PATCH 6/7] Add progress messages for common CLI commands. --- .../pkg/cmd/cluster_portal_open_cmd.go | 15 +++++++++++++ .../pkg/cmd/cluster_workshop_deploy_cmd.go | 21 +++++++++++++++++++ .../pkg/cmd/cluster_workshop_serve_cmd.go | 5 +++++ .../pkg/cmd/cluster_workshop_update_cmd.go | 2 ++ .../pkg/cmd/workshop_publish_cmd.go | 4 ++++ 5 files changed, 47 insertions(+) diff --git a/client-programs/pkg/cmd/cluster_portal_open_cmd.go b/client-programs/pkg/cmd/cluster_portal_open_cmd.go index d09ba6e5..44f3d0c2 100644 --- a/client-programs/pkg/cmd/cluster_portal_open_cmd.go +++ b/client-programs/pkg/cmd/cluster_portal_open_cmd.go @@ -71,7 +71,18 @@ func (o *ClusterPortalOpenOptions) Run() error { } } + fmt.Printf("Training portal %q.\n", trainingPortal.GetName()) + + fmt.Print("Checking training portal is ready.\n") + + spinner := func(iteration int) string { + spinners := `|/-\` + return string(spinners[iteration%len(spinners)]) + } + for i := 1; i < 300; i++ { + fmt.Printf("\r[%s] Waiting...", spinner(i)) + time.Sleep(time.Second) resp, err := http.Get(rootUrl) @@ -86,6 +97,10 @@ func (o *ClusterPortalOpenOptions) Run() error { break } + fmt.Print("\r \r") + + fmt.Printf("Opening training portal %s.\n", targetUrl) + switch runtime.GOOS { case "linux": err = exec.Command("xdg-open", targetUrl).Start() diff --git a/client-programs/pkg/cmd/cluster_workshop_deploy_cmd.go b/client-programs/pkg/cmd/cluster_workshop_deploy_cmd.go index 00691c68..7dfab07c 100644 --- a/client-programs/pkg/cmd/cluster_workshop_deploy_cmd.go +++ b/client-programs/pkg/cmd/cluster_workshop_deploy_cmd.go @@ -91,6 +91,8 @@ func (o *ClusterWorkshopDeployOptions) Run() error { return err } + fmt.Printf("Loaded workshop %q.\n", workshop.GetName()) + // Update the training portal, creating it if necessary. err = deployWorkshopResource(dynamicClient, workshop, o.Portal, o.Capacity, o.Reserved, o.Initial, o.Expires, o.Overtime, o.Deadline, o.Orphaned, o.Overdue, o.Refresh, o.Repository, o.Environ, o.OpenBrowser) @@ -489,8 +491,10 @@ func deployWorkshopResource(client dynamic.Interface, workshop *unstructured.Uns unstructured.SetNestedSlice(trainingPortal.Object, updatedWorkshops, "spec", "workshops") if trainingPortalExists { + fmt.Printf("Updating existing training portal %q.\n", trainingPortal.GetName()) _, err = trainingPortalClient.Update(context.TODO(), trainingPortal, metav1.UpdateOptions{FieldManager: "educates-cli"}) } else { + fmt.Printf("Creating new training portal %q.\n", trainingPortal.GetName()) _, err = trainingPortalClient.Create(context.TODO(), trainingPortal, metav1.CreateOptions{FieldManager: "educates-cli"}) } @@ -498,13 +502,24 @@ func deployWorkshopResource(client dynamic.Interface, workshop *unstructured.Uns return errors.Wrapf(err, "unable to update training portal %q in cluster", portal) } + fmt.Print("Workshop added to training portal.\n") + if openBrowser { // Need to refetch training portal because if was just created the URL // for access may not have been set yet. var targetUrl string + fmt.Print("Checking training portal is ready.\n") + + spinner := func(iteration int) string { + spinners := `|/-\` + return string(spinners[iteration%len(spinners)]) + } + for i := 1; i < 60; i++ { + fmt.Printf("\r[%s] Waiting...", spinner(i)) + time.Sleep(time.Second) trainingPortal, err = trainingPortalClient.Get(context.TODO(), portal, metav1.GetOptions{}) @@ -535,6 +550,8 @@ func deployWorkshopResource(client dynamic.Interface, workshop *unstructured.Uns } for i := 1; i < 300; i++ { + fmt.Printf("\r[%s] Waiting...", spinner(i)) + time.Sleep(time.Second) resp, err := http.Get(rootUrl) @@ -549,6 +566,10 @@ func deployWorkshopResource(client dynamic.Interface, workshop *unstructured.Uns break } + fmt.Print("\r \r") + + fmt.Printf("Opening training portal %s.\n", targetUrl) + switch runtime.GOOS { case "linux": err = exec.Command("xdg-open", targetUrl).Start() diff --git a/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go b/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go index 4d9fb6df..b0a92f02 100644 --- a/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go +++ b/client-programs/pkg/cmd/cluster_workshop_serve_cmd.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "io/ioutil" "os" "path" @@ -203,6 +204,8 @@ func (o *ClusterWorkshopServeOptions) Run() error { if err != nil { return err } + + fmt.Printf("Patched workshop %q.\n", workshop.GetName()) } var cleanupFunc = func() { @@ -216,6 +219,8 @@ func (o *ClusterWorkshopServeOptions) Run() error { // Update the workshop resource in the Kubernetes cluster. updateWorkshopResource(dynamicClient, workshop) + + fmt.Printf("Restored workshop %q.\n", workshop.GetName()) } } diff --git a/client-programs/pkg/cmd/cluster_workshop_update_cmd.go b/client-programs/pkg/cmd/cluster_workshop_update_cmd.go index 85130723..fbe19080 100644 --- a/client-programs/pkg/cmd/cluster_workshop_update_cmd.go +++ b/client-programs/pkg/cmd/cluster_workshop_update_cmd.go @@ -79,6 +79,8 @@ func (o *ClusterWorkshopUpdateOptions) Run() error { return err } + fmt.Printf("Loaded workshop %q.\n", workshop.GetName()) + return nil } diff --git a/client-programs/pkg/cmd/workshop_publish_cmd.go b/client-programs/pkg/cmd/workshop_publish_cmd.go index 1d93d336..1831983a 100644 --- a/client-programs/pkg/cmd/workshop_publish_cmd.go +++ b/client-programs/pkg/cmd/workshop_publish_cmd.go @@ -105,6 +105,8 @@ func (o *FilesPublishOptions) Publish(directory string) error { return errors.Wrap(err, "couldn't parse workshop definition") } + fmt.Printf("Processing workshop with name %q.\n", workshop.GetName()) + if workshop.GetAPIVersion() != "training.educates.dev/v1beta1" || workshop.GetKind() != "Workshop" { return errors.New("invalid type for workshop definition") } @@ -222,6 +224,8 @@ func (o *FilesPublishOptions) Publish(directory string) error { // Now publish workshop directory contents as OCI image artifact. + fmt.Printf("Publishing workshop files to %q.\n", image) + pushOptions := imgpkgcmd.NewPushOptions(confUI) pushOptions.ImageFlags.Image = image From 8665f406c8b54724edae088570b01679afca726f Mon Sep 17 00:00:00 2001 From: Graham Dumpleton Date: Tue, 19 Sep 2023 19:55:13 +1000 Subject: [PATCH 7/7] Add variables for workshop environment/session resource uids. --- session-manager/handlers/workshopenvironment.py | 1 + session-manager/handlers/workshopsession.py | 1 + 2 files changed, 2 insertions(+) diff --git a/session-manager/handlers/workshopenvironment.py b/session-manager/handlers/workshopenvironment.py index 6f3e7cc7..4749d8e4 100644 --- a/session-manager/handlers/workshopenvironment.py +++ b/session-manager/handlers/workshopenvironment.py @@ -954,6 +954,7 @@ def workshop_environment_create( environment_token = spec.get("request", {}).get("token", "") environment_variables = dict( + workshop_environment_uid=uid, platform_arch=PLATFORM_ARCH, image_repository=image_repository, oci_image_cache=oci_image_cache, diff --git a/session-manager/handlers/workshopsession.py b/session-manager/handlers/workshopsession.py index 21f49712..917225f1 100644 --- a/session-manager/handlers/workshopsession.py +++ b/session-manager/handlers/workshopsession.py @@ -926,6 +926,7 @@ def resolve_security_policy(name): ingress_port = "443" session_variables = dict( + workshop_session_uid=uid, platform_arch=PLATFORM_ARCH, image_repository=image_repository, oci_image_cache=oci_image_cache,