UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

408 lines 18.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CpCmd = void 0; const fs = require("fs"); const path = require("path"); const WebSocket = require("ws"); const tar = require("tar-stream"); const command_1 = require("../cli/command"); const functions_1 = require("../util/functions"); const options_1 = require("./options"); const client_1 = require("../session/client"); const generic_1 = require("./generic"); const tar_file_transfer_1 = require("../tar-file-transfer/tar-file-transfer"); const io_1 = require("../util/io"); const terminal_server_1 = require("../terminal-server/terminal-server"); const progress_bar_1 = require("../tar-file-transfer/progress-bar"); const helpers_1 = require("../tar-file-transfer/helpers"); // SECTION - Functions function withFileSpecSrc(yargs) { return yargs.positional('file-spec-src', { type: 'string', describe: 'A path to the source file or directory to copy from. Use a local path or workloadName:path for paths within a workload', demandOption: false, }); } function withFileSpecDest(yargs) { return yargs.positional('file-spec-dest', { type: 'string', describe: 'A path to the destination file or directory to copy to. Use a local path or workloadName:path for paths within a workload', demandOption: false, }); } function withCpOptions(yargs) { return yargs .parserConfiguration({ 'boolean-negation': false, }) .options({ container: { requiresArg: true, description: 'The name of the workload container', alias: 'c', }, replica: { requiresArg: true, description: 'The name of the workload deployment replica', }, 'no-preserve': { boolean: true, default: false, description: `The copied file/directory's ownership and permissions will not be preserved in the container`, }, }); } // !SECTION // ANCHOR - Command class CpCmd extends command_1.Command { constructor() { super(); this.command = 'cp <file-spec-src> <file-spec-dest>'; this.aliases = ['copy']; this.describe = 'Allows you to copy files and directories to and from workloads'; this.noPreserve = false; } builder(yargs) { return (0, functions_1.pipe)( // withFileSpecSrc, withFileSpecDest, (0, generic_1.withSingleLocationOption)(false), withCpOptions, options_1.withAllOptions)(yargs); } // Public Methods // async handle(args) { // Validate org and gvc if (!this.session.context.org) { this.session.abort({ message: 'ERROR: An organization is required. Please, specify one using the --org option.' }); } if (!this.session.context.gvc) { this.session.abort({ message: 'ERROR: A GVC is required. Please, specify one using the --gvc option.' }); } // Assign arguments accordingly if (args.noPreserve) { this.noPreserve = true; } // Extract file spec from src & dest const src = this.extractFileSpec(args.fileSpecSrc); const dest = this.extractFileSpec(args.fileSpecDest); // Validate src & dest if (src.workloadName && dest.workloadName) { this.session.abort({ message: 'ERROR: One of file-spec-src or file-spec-dest must be a local file specification.' }); } if (!src.path || !dest.path) { this.session.abort({ message: 'ERROR: One of file-spec-src or file-spec-dest cannot be empty.' }); } // Initialize a new terminal server instance const terminalServer = new terminal_server_1.TerminalServer(this.session, this.client); // Determine copy direction and process copying if (src.workloadName) { // Fetch terminal config const terminalConfig = await terminalServer.createConfig(src.workloadName, args.location, args.replica, args.container, true); // Copy from workload return await this.copyFromWorkload(src, dest, terminalConfig); } if (dest.workloadName) { // Validate local path await this.validateLocalPath(src.path); // Fetch terminal config const terminalConfig = await terminalServer.createConfig(dest.workloadName, args.location, args.replica, args.container, true); // Copy to workload return await this.copyToWorkload(src, dest, terminalConfig); } // Neither src nor dest are a remote file specification this.session.abort({ message: 'ERROR: One of file-spec-src or file-spec-dest must be a remote file specification in the format workloadName:path', }); } // Private Methods // async validateLocalPath(inputPath) { await fs.promises.access(inputPath); } extractFileSpec(path) { const index = path.indexOf(':'); // A file path starting or ending with a semicolon is invalid if (index == 0 || index == path.length - 1) { this.session.abort({ message: 'ERROR: A path starting or ending with a semicolon is invalid. A workload path must match the canonical format: workloadName:path', }); } // The specified file path is a path within the local machine if (index == -1) { return { path, workloadName: '' }; } // The specified file path is a path within a workload return { path: (0, io_1.posixPath)(path.substring(index + 1)), workloadName: path.substring(0, index), }; } checkDestinationIsDir(terminalConfig, dest) { return new Promise((resolve, reject) => { let lastMessage = ''; // Extend terminal request const request = { ...terminalConfig.request, command: ['test', '-d', dest], }; // Create a new WebSocket client const client = new WebSocket(terminalConfig.remoteSocket, { origin: 'http://localhost' }); client.on('open', () => { client_1.wire.debug('<<<<<<< WS Open Event with Token: ' + (0, client_1.convertAuthTokenForDebug)(request.token)); // Initialize Request client.send(JSON.stringify(request)); }); client.on('message', (event) => { client_1.wire.debug('<<<<<<< WS Message Received'); const msg = event.toString(); if (msg.trim()) { lastMessage = msg; } }); client.on('error', (event) => { client_1.wire.debug('<<<<<<< WS Error Event info'); reject(event); }); client.on('close', () => { client_1.wire.debug('<<<<<<< WS Close Event info'); if (lastMessage) { resolve(false); } resolve(true); }); }); } async copyFromWorkload(src, dest, terminalConfig) { let isErrorCaught = false; // Normalize the src and dest paths src.path = path.posix.normalize(src.path); dest.path = path.normalize(dest.path); // Remove extraneous path shortcuts - these could occur if a path contained extra "../" // and attempted to navigate beyond "/" in a remote filesystem const prefix = (0, io_1.stripPathShortcuts)(path.posix.normalize((0, io_1.stripLeadingSlash)(src.path))); // Helps us to determine whether the symlink warning was printed or not when extracting from the tar let symlinkWarningPrinted = false; // Extend terminal request const request = { ...terminalConfig.request, command: ['tar', 'cf', '-', src.path], }; // Keep track of all progress bars const progressBars = []; // Create a tar extract stream const extract = tar.extract(); // Handle extract error extract.on('error', (e) => { this.session.err('ERROR: Failed to extract tar for the following error:'); this.session.abort({ error: e }); }); // Handle each entry in the tar archive extract.on('entry', (header, stream, next) => { // All the files will start with the prefix, which is the directory where // they were located on the workload replica, we need to strip down that prefix, but // if the prefix is missing it means the tar was tempered with. // For the case where prefix is empty we need to ensure that the path // is not absolute, which also indicates the tar file was tempered with. if (!header.name.startsWith(prefix)) { this.session.abort({ message: 'ERROR: tar contents corrupted' }); } // Determine the full file path for the entry const fileName = header.name.slice(prefix.length); const filePath = path.join(dest.path, fileName); const dirPath = path.dirname(filePath); // If the filePath is not within the destination path, go to the next tar header and print a warning to the user if (!(0, io_1.isRelativePath)(dest.path, filePath)) { this.session.out(`Warning: file ${filePath} is outside target destination, skipping.`); next(); return; } // Ensure the directory exists if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } // Handle different tar header types switch (header.type) { case 'directory': // If the filePath is a directory, create it and return if (!fs.existsSync(filePath)) { fs.mkdirSync(filePath, { recursive: true }); } next(); return; case 'symlink': // Continue if the tar header doesn't seem like a symlink if (header.linkname === undefined) { break; } let message = `Warning: skipping symlink: ${filePath} -> ${header.linkname}`; if (!symlinkWarningPrinted) { message = `Warning: skipping symlink: ${filePath} -> ${header.linkname} (consider using "cpln workload exec ${src.workloadName} --org ${this.session.context.org} --gvc ${this.session.context.gvc} -- tar cf - ${src.path} | tar xf -")`; symlinkWarningPrinted = true; } // Print the warning message this.session.out(message); next(); return; } // Progress bar for the current file const progressBar = new progress_bar_1.ProgressBar(); if (header.size !== undefined) { progressBar.start(header.size); // Accumulate progress bars so we can validate completion later progressBars.push(progressBar); } // Create a writable stream for the file const writeStream = fs.createWriteStream(filePath); // Pipe the entry stream to the file write stream stream.pipe(writeStream); // Handle write stream errors writeStream.on('error', (e) => { next(e); return; }); // Finish up progress bar on stream end writeStream.on('finish', () => { progressBar.stop(); next(); }); // Update progress as data flows stream.on('data', (chunk) => { progressBar.update(chunk.length); }); // Move to the next entry when the current entry stream ends stream.on('end', next); }); // Create a new WebSocket client const client = new WebSocket(terminalConfig.remoteSocket, { origin: 'http://localhost' }); client.on('open', () => { client_1.wire.debug('<<<<<<< WS Open Event with Token: ' + (0, client_1.convertAuthTokenForDebug)(request.token)); // Initialize Request client.send(JSON.stringify(request)); }); client.on('message', (data) => { client_1.wire.debug('<<<<<<< WS Message Received'); client_1.wire.debug(data.toString()); // Skip empty strings if (data.toString().length == 0) { return; } // Detect tar errors or warnings const tarClassification = (0, helpers_1.detectTarErrorOrWarning)(data); // Print warnings and errors and skip extracting switch (tarClassification) { case 'warning': this.session.out(data.toString()); return; case 'error': isErrorCaught = true; this.session.err(data.toString()); return; default: // Data was not classified as tar break; } // Write the received data chunk to the extract stream extract.write(data); }); client.on('error', (event) => { client_1.wire.debug('<<<<<<< WS Error Event info'); // Signal the end of the extract stream extract.end(); process.stderr.write('error: ' + event.message); }); client.on('close', () => { client_1.wire.debug('<<<<<<< WS Close Event info'); // Signal the end of the extract stream extract.end(); // Exit with an error code if an error was caught if (isErrorCaught) { process.exit(100); } // Ensure a new line after all bars stop this.session.out(''); // Iterate over each progress bar and validate if it was complete or not for (const progressBar of progressBars) { if (!progressBar.isComplete()) { this.session.abort({ message: 'ERROR: Download not complete, lost connection to the pod.' }); } } process.exit(0); }); } async copyToWorkload(src, dest, terminalConfig) { // Prepare command let lastMessage = ''; let command = ['tar', '-xmf', '-']; // Don't preserve file/directory's ownership and permissions if (this.noPreserve) { command = ['tar', '--no-same-permissions', '--no-same-owner', '-xmf', '-']; } // Figure out the destination directory let destDir = path.dirname(dest.path); // Append the file name to the destination path if the destination is a directory if (await this.checkDestinationIsDir(terminalConfig, dest.path)) { dest.path = path.join(dest.path, path.basename(src.path)); destDir = path.dirname(dest.path); } // When copying the file or directory, copy it to the destination directory if (destDir !== '.') { command.push('-C', destDir); } // Extend terminal request const request = { ...terminalConfig.request, command, writeReceivedBytes: true, }; // Create a new WebSocket client const client = new WebSocket(terminalConfig.remoteSocket, { origin: 'http://localhost' }); // Initialize a new instance of TarSocket const tarSocket = new tar_file_transfer_1.TarFileTransfer(src.path, dest.path, client); client.on('open', () => { client_1.wire.debug('<<<<<<< WS Open Event with Token: ' + (0, client_1.convertAuthTokenForDebug)(request.token)); // Initialize Request client.send(JSON.stringify(request)); // Pack & transfer the specified file path to the workload tarSocket.transfer(); }); client.on('message', (event) => { client_1.wire.debug('<<<<<<< WS Message Received'); const msg = event.toString(); if (msg.trim()) { lastMessage = msg; } if (isNaN(Number(msg))) { // Echo the remote terminal message to the user process.stdout.write(event.toString()); } else { tarSocket.updateProgress(Number(msg)); } }); client.on('error', (event) => { client_1.wire.debug('<<<<<<< WS Error Event info'); this.session.err('ERROR: ' + event.message); }); client.on('close', () => { client_1.wire.debug('<<<<<<< WS Close Event info'); if (lastMessage.includes('Failed to create terminal session: command terminated with exit code')) { try { const code = Number(lastMessage.split('exit code ')[1]); process.exit(code); } catch (e) { process.exit(1); } } if (lastMessage.includes('Failed to create terminal session: Internal error occurred:')) { process.exit(1); } // Stop progress tarSocket.stopProgress(); // Abort if the uploaded hasn't succeeded if (!tarSocket.isComplete()) { this.session.abort({ message: 'ERROR: Upload not complete, lost connection to the pod.' }); } process.exit(0); }); } } exports.CpCmd = CpCmd; //# sourceMappingURL=cp.js.map