UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

294 lines 13.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TerminalServer = void 0; const WebSocket = require("ws"); const resolver_1 = require("../commands/resolver"); const types_1 = require("./types"); const client_1 = require("../session/client"); const helpers_1 = require("./helpers"); class TerminalServer { constructor(session, client, quiet = false) { this.session = session; this.client = client; this.quiet = quiet; } // Public Methods // async createConfig(workloadName, location, replica, container, stdin, tty, quiet) { var _a, _b; // Use the first location of the GVC if a location is not specified if (!location) { // Resolve GVC link const gvcSelfLink = (0, resolver_1.resolveToLink)('gvc', this.session.context.gvc, this.session.context); // Fetch GVC const gvc = await this.client.get(gvcSelfLink); // Determine location self link let locationSelfLink = ''; if (((_b = (_a = gvc.spec) === null || _a === void 0 ? void 0 : _a.staticPlacement) === null || _b === void 0 ? void 0 : _b.locationLinks) && gvc.spec.staticPlacement.locationLinks.length > 0) { locationSelfLink = gvc.spec.staticPlacement.locationLinks[0]; } else { this.session.abort({ message: 'ERROR: Gvc has no locations' }); } // Extract location name from location self link location = locationSelfLink.split('/location/')[1]; // Log the location that was picked this.printDefaults('location', location); } // Determine deployment self link const workloadSelfLink = (0, resolver_1.kindResolver)('workload').resourceLink(workloadName, this.session.context); const deploymentSelfLink = `${workloadSelfLink}/deployment/${location}`; // Fetch deployment const deployment = await this.client.get(deploymentSelfLink); if (!deployment.status || !deployment.status.remote) { this.session.abort({ message: 'ERROR: No deployment is present in the workload.' }); } // Extract remote host endpoint from deployment status const remoteHost = deployment.status.remote; // Fetch the first replica if no replica was specified by the user if (!replica) { const replicaListLink = `${remoteHost}/replicas/${workloadSelfLink}`; const replicaList = await this.client.get(replicaListLink); if (replicaList.items.length === 0) { this.session.abort({ message: 'ERROR: No replica is present in the workload deployment.' }); } else { replica = replicaList.items[0]; } // Log the replica that was picked this.printDefaults('replica', replica); } // Fetch the first container if no container was specified by the user if (!container) { const workload = await this.client.get(workloadSelfLink); // If there is only one container, use its name if (workload.spec.containers.length === 1) { container = workload.spec.containers[0].name; } else { // Find a container with ports const containerSpec = workload.spec.containers.find((c) => c.ports && c.ports.length > 0); // Determine container name, use first if the one with ports was not found let containerName = ''; if (!containerSpec) { containerName = workload.spec.containers[0].name; } else { containerName = containerSpec.name; } container = containerName; } // Log the container that was picked this.printDefaults('container', container); } // Determine /exec endpoint let remoteSocket; // If the endpoint is specified as an env variable, then use it if (process.env.TERMINAL_SERVER_URL) { remoteSocket = `${process.env.TERMINAL_SERVER_URL}/exec`; } else { remoteSocket = `${remoteHost.replace('https://', 'wss://')}/exec`; } // Prepare token const token = this.session.request.token.replace('Bearer ', ''); return { remoteSocket: remoteSocket, request: { requestVersion: 'v4', token, org: this.session.context.org, gvc: this.session.context.gvc, pod: replica, container: container, contextId: this.session.id, stdin, tty, quiet, }, }; } /** * Runs a command on a replica over the terminal server's WebSocket and resolves * with its exit code. * * @param {TerminalConfig} config - The terminal config with the request fields already set. * @returns {Promise<number>} A promise that resolves with the command's exit code. */ exec(config) { return new Promise((resolve) => { let isInteractiveSession = false; let lastMessage = ''; let hadTransportError = false; let pingInterval; let terminateTimeout; // Track the stdin listeners attached on open so close can detach them let onStdinData = null; let onStdinEnd = null; // If tty is true, then this is definitely an interactive terminal session if (config.request.tty) { isInteractiveSession = true; config.request.width = process.stdout.columns; config.request.height = process.stdout.rows; // Enable raw mode, which will process each keystroke as it happens if (process.stdin.isTTY) { process.stdin.setRawMode(true); } } // Initialize a new WebSocket connection var client = new WebSocket(config.remoteSocket, { origin: 'http://localhost', }); // Ensure terminal resize events are handled efficiently and do not trigger // too frequently, which could cause performance issues const onProcessResize = (0, helpers_1.terminalResizeDebounce)(client); // Play ping pong only in an interactive terminal session if (isInteractiveSession) { pingInterval = setInterval(() => { // Ping the terminal server client.ping(); client_1.wire.debug('>>>>>>> WS Ping Sent'); // Start a timeout, which will terminate the socket connection after PONG_DEADLINE_MS amount of time terminateTimeout = setTimeout(() => { client.terminate(); client_1.wire.debug('>>>>>>> WS Session Terminated'); }, types_1.PONG_DEADLINE_MS); }, types_1.PING_INTERVAL_MS); client.on('pong', () => { client_1.wire.debug('<<<<<<< WS Pong Received'); // Once pong is received, cancel the terminate timeout if (terminateTimeout) { clearTimeout(terminateTimeout); terminateTimeout = null; } }); } client.on('open', () => { client_1.wire.debug('<<<<<<< WS Open Event with Token: ' + (0, client_1.convertAuthTokenForDebug)(config.request.token)); // Initialize Request client.send(JSON.stringify(config.request)); if (isInteractiveSession) { // Subscribe to the resize event process.stdout.on('resize', onProcessResize); // Subscribe to the data event to send every user input to the remote terminal onStdinData = (chunk) => { const message = { type: 'data', buffer: [...Buffer.from(chunk)], width: process.stdout.columns, height: process.stdout.rows, }; client.send(JSON.stringify(message)); }; process.stdin.on('data', onStdinData); } else { // Subscribe to the data event to send standard input to the remote terminal onStdinData = (chunk) => { client.send(chunk, { fin: false, binary: true }); }; process.stdin.on('data', onStdinData); // Subscribe to the end event to signal end of file onStdinEnd = () => { client.send(Buffer.from([]), { fin: true }); }; process.stdin.on('end', onStdinEnd); } }); client.on('message', (event) => { client_1.wire.debug('<<<<<<< WS Message Received'); const msg = event.toString(); // Store the last message received if (msg.trim()) { lastMessage = msg; } // Echo the remote terminal message to the user process.stdout.write(msg); }); client.on('error', (event) => { // A transport-level failure must not be reported as a successful session hadTransportError = true; // Stop playing ping pong if (pingInterval) { clearInterval(pingInterval); } process.stderr.write('error: ' + event.message); }); client.on('close', () => { client_1.wire.debug('<<<<<<< WS Close Event info'); // Unsubscribe to resize event for when session is interactive if (isInteractiveSession) { process.stdout.off('resize', onProcessResize); } // Stop playing ping pong if (pingInterval) { clearInterval(pingInterval); } // Detach the stdin data listener and stop the flowing stream if (onStdinData) { process.stdin.off('data', onStdinData); process.stdin.pause(); } // Detach the stdin end listener if (onStdinEnd) { process.stdin.off('end', onStdinEnd); } // Leave raw mode if it was enabled if (isInteractiveSession && process.stdin.isTTY) { process.stdin.setRawMode(false); } // Resolve with the session's exit code, treating a transport error as a failure resolve(TerminalServer.exitCodeForClose(lastMessage, hadTransportError)); }); }); } // Public Static Methods // static isBackendErrorMessage(message) { // Skip backend error check if there is no message if (!message) { return false; } // List of prefixes or exact matches that indicate errors const errorPatterns = [ // Placeholder comment so we can see each array item on a new line 'Unauthorized: ', 'unable to write to the audit trail: ', 'Failed to create terminal session: ', ]; // Return whether the normalized message matches any of the error patterns return errorPatterns.some((pattern) => message.startsWith(pattern)); } /** * Parses the process exit code carried by a backend error message, defaulting to 1 when the * message carries no parseable code (e.g. a session error) so a failure never reports success. * * @param {string} message - The backend error message. * @returns {number} The exit code named in the message, or 1 when none is present. */ static exitCodeFromBackendMessage(message) { const match = message.match(/exit code (\d+)/); return match ? Number(match[1]) : 1; } /** * Decides the exit code to terminate with when a terminal session closes: the code carried by a * backend error message, 1 when the socket errored without one, or 0 for a clean close. * * @param {string} lastMessage - The last message received on the socket. * @param {boolean} hadTransportError - Whether the socket emitted an error during the session. * @returns {number} The exit code the caller should terminate with. */ static exitCodeForClose(lastMessage, hadTransportError) { if (TerminalServer.isBackendErrorMessage(lastMessage)) { return TerminalServer.exitCodeFromBackendMessage(lastMessage); } return hadTransportError ? 1 : 0; } // Private Methods // printDefaults(type, name) { if (this.quiet) { return; } this.session.err((0, helpers_1.defaultingToMessage)(type, name)); } } exports.TerminalServer = TerminalServer; //# sourceMappingURL=terminal-server.js.map