@c8y/toolkit
Version:
Toolkit for Cumulocity applications, allows to e.g. deploy an application to an instance of Cumulocity.
409 lines • 17.4 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.deployZipFile = deployZipFile;
exports.getAppAsZip = getAppAsZip;
exports.deploy = deploy;
const client_1 = require("@c8y/client");
const constants_1 = require("../constants");
const chalk_1 = __importDefault(require("chalk"));
const semver_1 = require("semver");
const fs_1 = require("fs");
const promises_1 = require("fs/promises");
const helpers_1 = require("./helpers");
const unzip_stream_1 = require("unzip-stream");
const path_1 = require("path");
const utils_1 = require("../utils");
async function deployZipFile(zipPath, options) {
const details = await getAppAsZip(zipPath);
const { user, password, url, tenant, tags, rollingTags, addWebSdkStableTag, deleteExistingTags, availability } = options;
let client;
try {
client = await (0, utils_1.createClient)({ url, user, password, tenant });
}
catch (e) {
console.error(e.message);
throw e;
}
try {
await (0, utils_1.ensureUserHasRequiredPermissions)(client);
}
catch (e) {
console.error(e.message);
throw e;
}
console.log('Deploying application...');
const actualTags = tags ? tags.split(',') : [];
const { buffer, json, fileName, size } = details;
await deploy({
buffer,
json,
size,
client,
tags: actualTags,
fileName,
rollingTags,
addWebSdkStableTag,
deleteExistingTags,
availability
});
}
/**
* Reads zipped file by provided path, extracts application metadata, file metadata and file buffer (raw data).
*
* @param path - The path to the zip file.
* @returns A Promise that resolves to object representing the application.
*/
async function getAppAsZip(path) {
const stats = await (0, promises_1.stat)(path);
const size = stats.size;
const json = await new Promise((resolve, reject) => {
const partialCumulocityJSONData = new Array();
(0, fs_1.createReadStream)(path)
.pipe((0, unzip_stream_1.Parse)())
.on('entry', (entry) => {
const fileName = entry.path;
const isCumulocity = fileName === constants_1.OPTIONS_CONSTANTS['PATH.BUILT_MANIFEST_FILE'];
const macTrash = !!fileName.match(/__MACOSX/);
const readFile = isCumulocity && !macTrash;
if (readFile) {
entry.on('data', (data) => partialCumulocityJSONData.push(data));
}
else {
entry.autodrain();
}
})
.on('close', () => {
if (partialCumulocityJSONData.length === 0) {
return reject('No manifest file found in zip file');
}
try {
let partialCumulocityJSONString = '';
partialCumulocityJSONData.forEach(data => {
partialCumulocityJSONString += data.toString();
});
const fileData = JSON.parse(partialCumulocityJSONString);
const appDataFromFile = (0, helpers_1.dataToAppData)(fileData);
const name = (0, helpers_1.getName)(appDataFromFile, (0, path_1.basename)(path));
const key = (0, helpers_1.getKey)(appDataFromFile, name);
const contextPath = (0, helpers_1.getContextPath)(appDataFromFile, name);
Object.assign(appDataFromFile, { name, key, contextPath });
resolve(appDataFromFile);
}
catch (e) {
reject(e);
}
})
.on('error', e => reject(e));
});
json.type ??= client_1.ApplicationType.HOSTED;
return {
fileName: (0, path_1.basename)(path),
json,
buffer: (0, fs_1.createReadStream)(path),
size
};
}
/**
* Uploads an application to server, make it active and subscribes tenant to this app.
*
* @param appToDeploy - The application to deploy.
* @returns A Promise that resolves when the deployment is complete.
*/
async function deploy(deployOptions) {
const { json, buffer, fileName, size } = deployOptions;
let application = await getApplicationOrCreateIfNotExists(deployOptions, json);
// removes old version(s) if limit of existing versions is reached
if (await reduceApplicationVersionsIfRequired(deployOptions, application)) {
application = await getApplicationOrCreateIfNotExists(deployOptions, json);
}
if (application.isPackage && deployOptions.addWebSdkStableTag && json.webSdkVersion) {
const majorVersion = json.webSdkVersion.split('.')[0];
const isActuallyANumberRegexp = /^\d+$/;
if (isActuallyANumberRegexp.test(majorVersion)) {
const tagToAdd = `${majorVersion}-stable`;
if (deployOptions.tags) {
deployOptions.tags.push(tagToAdd);
}
else {
deployOptions.tags = [tagToAdd];
}
}
else {
console.warn(`WebSDK version is not in the correct format. Expected format is <major>.<minor>.<patch>.`);
}
}
const uploadedFile = await uploadApplication(deployOptions, application, buffer, fileName, size);
// roll tags around after upload of version to ensure time where a certain tag does not exists is minimized
if (application.isPackage) {
await rollingTags(deployOptions, application);
}
const isFirstVersion = !application.applicationVersions || application.applicationVersions.length === 0;
const hasLatestTag = deployOptions.tags && deployOptions.tags.indexOf('latest') >= 0;
// packages will be only activated if tag is set to latest
// or if this is the first version of the package
if (application.isPackage && (isFirstVersion || hasLatestTag)) {
await deployOptions.client.application.update({
id: application.id,
activeVersionId: uploadedFile.binaryId
});
}
if (!application.isPackage) {
await activateBinary(deployOptions, uploadedFile, application);
}
const availability = deployOptions.availability || json.availability;
if (json.type === 'HOSTED' && availability !== application.availability) {
if (availability === client_1.ApplicationAvailability.MARKET) {
await subscribeTenantToApp(deployOptions, application);
console.info(chalk_1.default.blue('Subscribing app to current tenant.'));
}
else if (availability === client_1.ApplicationAvailability.PRIVATE ||
availability === client_1.ApplicationAvailability.SHARED) {
deployOptions.client.application.update({
id: application.id,
availability: availability || client_1.ApplicationAvailability.PRIVATE
});
console.info(chalk_1.default.blue(`Setting availability to "${availability}".`));
}
}
const msg = constants_1.OPTIONS_CONSTANTS['TXT.APP_DEPLOYED'](application.contextPath);
console.info(chalk_1.default.green.bold(msg));
}
/**
* Removes tags from other versions of app and re-adds them to the new version
*/
async function rollingTags(deployOptions, application) {
if (!deployOptions.tags.length || !deployOptions.rollingTags) {
return;
}
const tags = deployOptions.tags;
const tagsToRemove = tags.filter(tag => tag !== 'latest');
for (const appVersion of application.applicationVersions || []) {
const tagsOfVersion = appVersion.tags || [];
const tagsToKeepForVersion = tagsOfVersion.filter(tag => !tagsToRemove.includes(tag));
const tagsToBeRemovedForVersion = tagsOfVersion.filter(tag => tagsToRemove.includes(tag));
if (tagsToKeepForVersion.length === tagsOfVersion.length) {
continue;
}
console.info(chalk_1.default.yellow(`Removing tags ${tagsToBeRemovedForVersion} from version ${appVersion.version} of application ${application.contextPath}`));
await deployOptions.client.application.setPackageVersionTag(application, appVersion.version, tagsToKeepForVersion);
}
const versionToBeTagged = application.version;
await deployOptions.client.application.setPackageVersionTag(application, versionToBeTagged, tags);
}
/**
* Picks oldest version of application
* First tries to find oldest version without a tag
* If no version without tag is found, it tries to find oldest version without latest tag
*/
function identifyApplicationVersionToBeRemoved(application) {
const appVersions = application.applicationVersions;
if (!appVersions || appVersions.length === 0) {
throw Error('No application versions found');
}
const versionsWithoutTag = appVersions.filter(version => !version.tags || version.tags.length === 0);
if (versionsWithoutTag.length > 0) {
try {
const sortedVersions = (0, semver_1.sort)(versionsWithoutTag.map(version => version.version));
return sortedVersions[0];
}
catch (e) {
console.warn('Failed to sort versions without tags', e);
}
return versionsWithoutTag[0].version;
}
const versionsWithoutLatestTag = appVersions.filter(version => !version.tags || !version.tags.includes('latest'));
if (versionsWithoutLatestTag.length > 0) {
try {
const sortedVersions = (0, semver_1.sort)(versionsWithoutLatestTag.map(version => version.version));
return sortedVersions[0];
}
catch (e) {
console.warn('Failed to sort versions without tags', e);
}
return versionsWithoutLatestTag[0].version;
}
throw Error('No application version to be removed found');
}
/**
* Removes old application versions if the limit of versions is reached.
* Returns Promise<false> if number of app versions is lower than max versions (20) and app needs no version reduction.
* Returns Promise<true> if number of app versions equals or is higher than max versions (20) and app versions have been reduced.
*/
async function reduceApplicationVersionsIfRequired(deployOptions, application) {
const maxVersions = 20;
const numberOfAppVersions = application.applicationVersions?.length || 0;
if (numberOfAppVersions < maxVersions) {
return false;
}
const appVersionToRemove = identifyApplicationVersionToBeRemoved(application);
console.info(chalk_1.default.yellow(`Application ${application.contextPath} has reached the maximum ${maxVersions} versions (${numberOfAppVersions}). Removing version "${appVersionToRemove}"`));
try {
await deployOptions.client.application.deleteVersionPackage(application, {
version: appVersionToRemove
});
}
catch (e) {
console.error(`Failed to remove version "${appVersionToRemove}"`, e);
throw e;
}
const { data: refreshedApp } = await deployOptions.client.application.detail(application);
application = refreshedApp;
// do this recursively until we have less than 20 versions
await reduceApplicationVersionsIfRequired(deployOptions, application);
return true;
}
/**
* Retrieves an existing application from server or creates a new one if it doesn't exist.
*
* @param application - The application to retrieve or create.
* @returns A Promise that resolves to the retrieved or created application.
*/
async function getApplicationOrCreateIfNotExists(deployOptions, application) {
const remoteApplication = await getApplication(deployOptions, application);
return !remoteApplication
? await createApplication(deployOptions, application)
: Object.assign(remoteApplication, application);
}
/**
* Uploads an application binary to the server.
*
* @param application - The application for which the binary is being uploaded.
* @param buffer - The binary data to upload.
* @param filePath - The file path for the binary (optional).
* @param size - The size of the binary (optional).
* @returns A Promise that resolves to the uploaded binary data.
*/
async function uploadApplication(deployOptions, application, buffer, filePath, size) {
if (!filePath) {
filePath = `${application.contextPath}.zip`;
}
try {
const msg = constants_1.OPTIONS_CONSTANTS['TXT.APP_UPLOADING'](buffer.length || size);
const { tags, deleteExistingTags } = deployOptions;
if (tags && !application.isPackage) {
console.info(chalk_1.default.yellow(constants_1.OPTIONS_CONSTANTS['TXT.NOT_PACKAGE_WARNING']));
}
console.info(chalk_1.default.green(msg));
if (tags && deleteExistingTags) {
console.info(chalk_1.default.yellow(constants_1.OPTIONS_CONSTANTS['TXT.DELETE_EXISTING_TAG_WARNING']));
const appVersions = application.applicationVersions?.filter(version => version.tags && version.tags.some(tag => tags.indexOf(tag) > -1));
if (appVersions && appVersions?.length > 0) {
for (const appVersion of appVersions) {
await deployOptions.client.application.deleteVersionPackage(application, appVersion);
}
}
}
const uploadOverrides = getPackageUploadOverrides(deployOptions, application);
const { data } = await deployOptions.client.application
.binary(application)
.upload(buffer, filePath, uploadOverrides);
return data;
}
catch (err) {
console.debug(err);
console.error(`${constants_1.OPTIONS_CONSTANTS['TXT.APP_UPLOAD_ERROR']} ${err.res.status}`);
console.error(JSON.stringify(err.data, null, 2));
process.exit(9);
}
}
/**
* Activates a binary for an application.
*
* @param uploadedFile - The uploaded binary file.
* @param application - The application to activate the binary for.
*/
async function activateBinary(deployOptions, uploadedFile, application) {
application.activeVersionId = `${uploadedFile.id}`;
delete application.type;
try {
console.info(chalk_1.default.green(constants_1.OPTIONS_CONSTANTS['TXT.APP_ACTIVATING'](uploadedFile.id)));
await deployOptions.client.application.update(application);
}
catch (err) {
console.debug(err);
const msg = constants_1.OPTIONS_CONSTANTS['TXT.APP_ACTIVATING_FAILED'](application.contextPath);
console.error(`${msg} ${err.res.status}`);
console.error(JSON.stringify(err.data, null, 2));
process.exit(9);
}
}
/**
* Subscribes a tenant to an application.
*
* @param app - The application to subscribe to.
*/
async function subscribeTenantToApp(deployOptions, app) {
await deployOptions.client.tenant.subscribeApplication({ name: deployOptions.client.core.tenant }, app);
}
/**
* Retrieves an application based on the provided application object.
*
* @param application - The application to retrieve.
* @returns A Promise that resolves to the retrieved application or null if not found.
*/
async function getApplication(deployOptions, application) {
const { tenant } = deployOptions.client.core;
try {
console.info(chalk_1.default.green(constants_1.OPTIONS_CONSTANTS['TXT.APP_FETCHING']));
const { data: applications } = await deployOptions.client.application.listByOwner(tenant, {
pageSize: 1000
});
return applications.find(v => v.owner?.tenant.id === tenant && v.contextPath === application.contextPath);
}
catch (err) {
console.debug(err);
console.error(`${constants_1.OPTIONS_CONSTANTS['TXT.APP_FETCHING_FAILED']} ${err.res.status}`);
console.error(JSON.stringify(err.data, null, 2));
process.exit(9);
}
}
/**
* Creates a new application on the server.
*
* @param application - The application to create.
* @returns A Promise that resolves to the created application.
*/
async function createApplication(deployOptions, application) {
try {
console.info(chalk_1.default.green(constants_1.OPTIONS_CONSTANTS['TXT.APP_CREATING']));
const { data } = await deployOptions.client.application.create(application);
return data;
}
catch (err) {
console.debug(err);
console.error(`${constants_1.OPTIONS_CONSTANTS['TXT.APP_CREATE_FAILED']} ${err.res.status}`);
console.error(JSON.stringify(err.data, null, 2));
process.exit(9);
}
}
/**
* Generates upload overrides for package uploads.
*
* @param app - The application for which overrides are generated.
* @returns Upload overrides or undefined if the application is not a package.
*/
function getPackageUploadOverrides(deployOptions, app) {
if (!app.isPackage) {
return;
}
let tags = deployOptions.tags;
// in case of rollingTags, we will add the tags later
if (deployOptions.rollingTags) {
tags = [];
}
const version = app.version;
return {
listUrl: 'versions',
headers: {
Accept: 'application/vnd.com.nsn.cumulocity.applicationVersion+json;charset=UTF-8;ver=0.9'
},
bodyFileProperty: 'applicationBinary',
requestBody: {
applicationVersion: Object.assign({ version, tags })
}
};
}
//# sourceMappingURL=deploy.js.map