@controlplane/cli
Version:
Control Plane Corporation CLI
408 lines • 18.1 kB
JavaScript
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', async () => {
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
await 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
;