@controlplane/cli
Version:
Control Plane Corporation CLI
294 lines • 13.6 kB
JavaScript
;
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