sfdx-hardis
Version:
Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards
535 lines (532 loc) • 24.6 kB
JavaScript
/* jscpd:ignore-start */
import { SfCommand, Flags, requiredOrgFlagWithDeprecations } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';
import c from 'chalk';
import fs from 'fs-extra';
import * as path from 'path';
import sortArray from 'sort-array';
import { createTempDir, uxLog } from '../../../../common/utils/index.js';
import * as dns from 'dns';
import Papa from 'papaparse';
import { getNotificationButtons, getOrgMarkdown, getSeverityIcon } from '../../../../common/utils/notifUtils.js';
import { soqlQuery } from '../../../../common/utils/apiUtils.js';
import { WebSocketClient } from '../../../../common/websocketClient.js';
import { NotifProvider } from '../../../../common/notifProvider/index.js';
import { generateCsvFile, generateReportPath, createXlsxFromCsv } from '../../../../common/utils/filesUtils.js';
import { CONSTANTS } from '../../../../config/index.js';
import { FileDownloader } from '../../../../common/utils/fileDownloader.js';
import { setConnectionVariables } from '../../../../common/utils/orgUtils.js';
const dnsPromises = dns.promises;
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('sfdx-hardis', 'org');
export default class LegacyApi extends SfCommand {
static title = 'Check for legacy API use';
static description = `Checks if an org uses retired or someday retired API version\n
See article below
[](https://nicolas.vuillamy.fr/handle-salesforce-api-versions-deprecation-like-a-pro-335065f52238)
This command is part of [sfdx-hardis Monitoring](${CONSTANTS.DOC_URL_ROOT}/salesforce-monitoring-deprecated-api-calls/) and can output Grafana, Slack and MsTeams Notifications.
`;
static examples = [
'$ sf hardis:org:diagnose:legacyapi',
'$ sf hardis:org:diagnose:legacyapi -u hardis@myclient.com',
"$ sf hardis:org:diagnose:legacyapi --outputfile 'c:/path/to/folder/legacyapi.csv'",
'$ sf hardis:org:diagnose:legacyapi -u hardis@myclient.com --outputfile ./tmp/legacyapi.csv',
];
// public static args = [{name: 'file'}];
static flags = {
eventtype: Flags.string({
char: 'e',
default: 'ApiTotalUsage',
description: 'Type of EventLogFile event to analyze',
}),
limit: Flags.integer({
char: 'l',
default: 999,
description: 'Number of latest EventLogFile events to analyze',
}),
outputfile: Flags.string({
char: 'f',
description: 'Force the path and name of output report file. Must end with .csv',
}),
debug: Flags.boolean({
char: 'd',
default: false,
description: messages.getMessage('debugMode'),
}),
websocket: Flags.string({
description: messages.getMessage('websocket'),
}),
skipauth: Flags.boolean({
description: 'Skip authentication check when a default username is required',
}),
'target-org': requiredOrgFlagWithDeprecations,
};
static requiresProject = false;
debugMode = false;
apexSCannerCodeUrl = 'https://raw.githubusercontent.com/pozil/legacy-api-scanner/main/legacy-api-scanner.apex';
articleTextLegacyApi = `See article to solve issue before it's too late:
• EN: https://nicolas.vuillamy.fr/handle-salesforce-api-versions-deprecation-like-a-pro-335065f52238
• FR: https://leblog.hardis-group.com/portfolio/versions-dapi-salesforce-decommissionnees-que-faire/`;
legacyApiDescriptors = [
{
apiFamily: ['SOAP', 'REST', 'BULK_API'],
minApiVersion: 1.0,
maxApiVersion: 6.0,
severity: 'ERROR',
deprecationRelease: 'Summer 21 - retirement of 1 to 6',
errors: [],
totalErrors: 0,
ipCounts: {},
},
{
apiFamily: ['SOAP', 'REST', 'BULK_API'],
minApiVersion: 7.0,
maxApiVersion: 20.0,
severity: 'ERROR',
deprecationRelease: 'Summer 22 - retirement of 7 to 20',
errors: [],
totalErrors: 0,
ipCounts: {},
},
{
apiFamily: ['SOAP', 'REST', 'BULK_API'],
minApiVersion: 21.0,
maxApiVersion: 30.0,
severity: 'ERROR',
deprecationRelease: 'Summer 25 - retirement of 21 to 30',
errors: [],
totalErrors: 0,
ipCounts: {},
},
{
apiFamily: ['SOAP'],
minApiVersion: 0.0,
maxApiVersion: Number.POSITIVE_INFINITY,
severity: 'WARNING',
deprecationRelease: 'Summer 27 - retirement of SOAP login',
errors: [],
totalErrors: 0,
ipCounts: {},
apiResources: ['login'],
},
];
allErrors = [];
ipResultsSorted = [];
outputFile;
outputFilesRes = {};
/* jscpd:ignore-end */
tempDir;
csvHeaderWritten = false;
csvColumns = null;
csvPreviousChunkEndedWithNewline = true;
totalCsvRows = 0;
notificationSampleLimit = 1000;
notificationSampleTruncated = false;
async run() {
const { flags } = await this.parse(LegacyApi);
this.debugMode = flags.debug || false;
return await this.runJsForce(flags);
}
// Refactoring of Philippe Ozil's apex script with JsForce queries
async runJsForce(flags) {
const eventType = flags.eventtype || 'ApiTotalUsage';
const limit = flags.limit || 999;
const conn = flags['target-org'].getConnection();
this.outputFile = await generateReportPath('legacy-api-calls', flags.outputfile || null);
await fs.remove(this.outputFile).catch(() => undefined);
this.resetCsvState();
const limitConstraint = limit ? ` LIMIT ${limit}` : '';
this.tempDir = await createTempDir();
// Get EventLogFile records with EventType = 'ApiTotalUsage'
uxLog("action", this, c.cyan(`Querying org for EventLogFile entries of type ${eventType} to detect Legacy API calls...`));
const logCountQuery = `SELECT COUNT() FROM EventLogFile WHERE EventType = '${eventType}'`;
const logCountRes = await soqlQuery(logCountQuery, conn);
if (logCountRes.totalSize === 0) {
uxLog("success", this, c.green(`Found no EventLogFile entry of type ${eventType}.`));
uxLog("success", this, c.green('This indicates that no legacy APIs were called during the log retention window.'));
}
else {
uxLog("log", this, c.grey('Found ' + c.bold(logCountRes.totalSize) + ` ${eventType} EventLogFile entries.`));
}
if (logCountRes.totalSize > limit) {
uxLog("warning", this, c.yellow(`There are more than ${limit} results, you may consider to increase limit using --limit argument`));
}
// Fetch EventLogFiles with ApiTotalUsage entries
const logCollectQuery = `SELECT LogFile FROM EventLogFile WHERE EventType = '${eventType}' ORDER BY LogDate DESC` + limitConstraint;
const eventLogRes = await soqlQuery(logCollectQuery, conn);
// Collect legacy api calls from logs
WebSocketClient.sendProgressStartMessage("Downloading and analyzing log files...", eventLogRes.records.length);
let counter = 0;
for (const eventLogFile of eventLogRes.records) {
await this.collectDeprecatedApiCalls(eventLogFile.LogFile, conn);
counter++;
WebSocketClient.sendProgressStepMessage(counter, eventLogRes.records.length);
}
WebSocketClient.sendProgressEndMessage();
await this.flushDescriptorErrors();
// Display summary
uxLog("action", this, c.cyan('Results of Legacy API calls analysis:'));
const logLines = [];
for (const descriptor of this.legacyApiDescriptors) {
const errorCount = descriptor.totalErrors;
const colorMethod = descriptor.severity === 'ERROR' && errorCount > 0
? c.red
: descriptor.severity === 'WARNING' && errorCount > 0
? c.yellow
: c.green;
const line = colorMethod(`- ${descriptor.deprecationRelease} : ${c.bold(errorCount)}`);
logLines.push(line);
}
uxLog("log", this, logLines.join('\n'));
// Build command result
let msg = 'No deprecated API call has been found in ApiTotalUsage logs';
let statusCode = 0;
const hasBlockingErrors = this.legacyApiDescriptors.some((descriptor) => descriptor.severity === 'ERROR' && descriptor.totalErrors > 0);
const hasWarningsOnly = this.legacyApiDescriptors.some((descriptor) => descriptor.severity === 'WARNING' && descriptor.totalErrors > 0);
if (hasBlockingErrors) {
msg = 'Found legacy API versions calls in logs';
statusCode = 1;
uxLog("error", this, c.red(c.bold(msg)));
}
else if (hasWarningsOnly) {
msg = 'Found deprecated API versions calls in logs that will not be supported anymore in the future';
statusCode = 0;
uxLog("warning", this, c.yellow(c.bold(msg)));
}
else {
uxLog("success", this, c.green(msg));
}
// Generate main CSV file
await this.finalizeCsvOutput();
// Generate one summary file by severity
const outputFileIps = [];
for (const descriptor of this.legacyApiDescriptors) {
if (descriptor.totalErrors > 0) {
const outputFileIp = await this.generateSummaryLog(descriptor.ipCounts, descriptor.severity);
if (outputFileIp) {
outputFileIps.push(outputFileIp);
}
// Trigger command to open CSV file in VS Code extension
if (outputFileIp) {
WebSocketClient.requestOpenFile(outputFileIp);
}
}
}
// Debug or manage CSV file generation error
if (this.debugMode || this.outputFile == null) {
for (const descriptor of this.legacyApiDescriptors) {
uxLog("log", this, c.grey(`- ${descriptor.deprecationRelease} : ${JSON.stringify(descriptor.totalErrors)}`));
}
}
let notifDetailText = '';
for (const descriptor of this.legacyApiDescriptors) {
if (descriptor.totalErrors > 0) {
notifDetailText += `• ${descriptor.severity}: API version calls found in logs: ${descriptor.totalErrors} (${descriptor.deprecationRelease})\n`;
}
}
notifDetailText += "\n" + this.articleTextLegacyApi;
if (WebSocketClient.isAliveWithLwcUI()) {
WebSocketClient.sendReportFileMessage("https://nicolas.vuillamy.fr/handle-salesforce-api-versions-deprecation-like-a-pro-335065f52238", "Article (EN)", 'docUrl');
WebSocketClient.sendReportFileMessage("https://leblog.hardis-group.com/portfolio/versions-dapi-salesforce-decommissionnees-que-faire/", "Article (FR)", 'docUrl');
}
if (this.notificationSampleTruncated) {
notifDetailText += `
Only the first ${this.notificationSampleLimit} log entries are attached to this notification.`;
}
// Build notifications
const orgMarkdown = await getOrgMarkdown(flags['target-org']?.getConnection()?.instanceUrl);
const notifButtons = await getNotificationButtons();
const totalErrorsFound = this.getTotalErrors();
let notifSeverity = 'log';
let notifText = `No deprecated Salesforce API versions are used in ${orgMarkdown}`;
if (totalErrorsFound > 0) {
notifSeverity = 'error';
notifText = `${totalErrorsFound} deprecated Salesforce API versions are used in ${orgMarkdown}`;
}
// Post notifications
await setConnectionVariables(flags['target-org']?.getConnection()); // Required for some notifications providers like Email
await NotifProvider.postNotifications({
type: 'LEGACY_API',
text: notifText,
attachments: [{ text: notifDetailText }],
buttons: notifButtons,
severity: notifSeverity,
attachedFiles: this.outputFilesRes.xlsxFile ? [this.outputFilesRes.xlsxFile, this.outputFilesRes.xlsxFile2] : [],
logElements: this.allErrors,
data: {
metric: totalErrorsFound,
legacyApiSummary: this.ipResultsSorted,
},
metrics: {
LegacyApiCalls: totalErrorsFound,
},
});
uxLog("log", this, c.grey(notifDetailText));
if ((this.argv || []).includes('legacyapi')) {
process.exitCode = statusCode;
}
// Return an object to be displayed with --json
return {
status: statusCode,
message: msg,
csvLogFile: this.outputFile,
outputFileIps: outputFileIps,
legacyApiResults: this.legacyApiDescriptors,
};
}
resetCsvState() {
this.csvHeaderWritten = false;
this.csvColumns = null;
this.csvPreviousChunkEndedWithNewline = true;
this.totalCsvRows = 0;
this.allErrors = [];
this.ipResultsSorted = [];
this.notificationSampleTruncated = false;
this.outputFilesRes = {};
for (const descriptor of this.legacyApiDescriptors) {
descriptor.errors = [];
descriptor.totalErrors = 0;
descriptor.ipCounts = {};
}
}
getTotalErrors() {
return this.legacyApiDescriptors.reduce((sum, descriptor) => sum + descriptor.totalErrors, 0);
}
captureNotificationSample(entries) {
if (!entries || entries.length === 0) {
return;
}
for (const entry of entries) {
if (this.allErrors.length < this.notificationSampleLimit) {
this.allErrors.push(entry);
}
else {
this.notificationSampleTruncated = true;
break;
}
}
}
updateIpCounts(descriptor, errors) {
if (!errors || errors.length === 0) {
return;
}
for (const eventLogRecord of errors) {
if (!eventLogRecord || !eventLogRecord.CLIENT_IP) {
continue;
}
descriptor.ipCounts[eventLogRecord.CLIENT_IP] = (descriptor.ipCounts[eventLogRecord.CLIENT_IP] || 0) + 1;
}
}
ensureCsvColumns(rows) {
if (this.csvColumns && this.csvColumns.length > 0) {
return;
}
const columnSet = new Set();
for (const row of rows) {
if (!row) {
continue;
}
Object.keys(row).forEach((key) => columnSet.add(key));
}
this.csvColumns = Array.from(columnSet);
}
async appendRowsToCsv(rows) {
if (!rows || rows.length === 0) {
return;
}
if (!this.outputFile) {
throw new Error('Output file path is not initialized');
}
this.ensureCsvColumns(rows);
if (!this.csvColumns || this.csvColumns.length === 0) {
return;
}
const csvString = Papa.unparse(rows, {
header: !this.csvHeaderWritten,
columns: this.csvColumns,
});
if (!this.csvHeaderWritten) {
await fs.writeFile(this.outputFile, csvString, 'utf8');
this.csvHeaderWritten = true;
}
else if (csvString.length > 0) {
const prefix = this.csvPreviousChunkEndedWithNewline ? '' : '\n';
await fs.appendFile(this.outputFile, prefix + csvString, 'utf8');
}
this.csvPreviousChunkEndedWithNewline = csvString.endsWith('\n');
this.totalCsvRows += rows.length;
}
async flushDescriptorErrors() {
for (const descriptor of this.legacyApiDescriptors) {
if (!descriptor.errors || descriptor.errors.length === 0) {
continue;
}
const descriptorErrors = descriptor.errors;
descriptor.totalErrors += descriptorErrors.length;
this.captureNotificationSample(descriptorErrors);
this.updateIpCounts(descriptor, descriptorErrors);
await this.appendRowsToCsv(descriptorErrors);
descriptor.errors = [];
}
}
async finalizeCsvOutput() {
if (!this.outputFile) {
return;
}
if (!(await fs.pathExists(this.outputFile))) {
await fs.ensureDir(path.dirname(this.outputFile));
await fs.writeFile(this.outputFile, '', 'utf8');
}
uxLog("action", this, c.cyan(c.italic(`Please see detailed CSV log in ${c.bold(this.outputFile)}`)));
this.outputFilesRes.csvFile = this.outputFile;
if (!WebSocketClient.isAliveWithLwcUI()) {
WebSocketClient.requestOpenFile(this.outputFile);
}
WebSocketClient.sendReportFileMessage(this.outputFile, 'Legacy API Calls (CSV)', 'report');
if (this.totalCsvRows > 0) {
const result = {};
await createXlsxFromCsv(this.outputFile, { fileTitle: 'Legacy API Calls' }, result);
if (result.xlsxFile) {
this.outputFilesRes.xlsxFile = result.xlsxFile;
}
}
else {
uxLog("other", this, c.grey(`No XLS file generated as ${this.outputFile} is empty`));
}
}
// GET csv log file and check for legacy API calls within
async collectDeprecatedApiCalls(logFileUrl, conn) {
// Load icons
const severityIconError = getSeverityIcon('error');
const severityIconWarning = getSeverityIcon('warning');
const severityIconInfo = getSeverityIcon('info');
// Download file as stream, and process chuck by chuck
uxLog("log", this, c.grey(`Downloading ${logFileUrl}...`));
const fetchUrl = `${conn.instanceUrl}${logFileUrl}`;
const outputFile = path.join(this.tempDir, Math.random().toString(36).substring(7) + ".csv");
const downloadResult = await new FileDownloader(fetchUrl, { conn: conn, outputFile: outputFile }).download();
if (downloadResult.success) {
uxLog("log", this, c.grey(`Parsing downloaded CSV from ${outputFile} and check for deprecated calls...`));
const outputFileStream = fs.createReadStream(outputFile, { encoding: 'utf8' });
await new Promise((resolve, reject) => {
Papa.parse(outputFileStream, {
header: true,
worker: true,
chunk: (results) => {
// Look in check the entries that match a deprecation description
for (const logEntry of results.data) {
const apiVersion = this.parseApiVersion(logEntry.API_VERSION);
const apiFamily = (logEntry.API_FAMILY || '').toUpperCase();
const apiResource = (logEntry.API_RESOURCE || '').toLowerCase();
for (const legacyApiDescriptor of this.legacyApiDescriptors) {
if (!this.matchesApiFamily(legacyApiDescriptor, apiFamily) ||
!this.matchesApiVersion(legacyApiDescriptor, apiVersion) ||
!this.matchesApiResource(legacyApiDescriptor, apiResource)) {
continue;
}
logEntry.SFDX_HARDIS_DEPRECATION_RELEASE = legacyApiDescriptor.deprecationRelease;
logEntry.SFDX_HARDIS_SEVERITY = legacyApiDescriptor.severity;
if (legacyApiDescriptor.severity === 'ERROR') {
logEntry.severity = 'error';
logEntry.severityIcon = severityIconError;
}
else if (legacyApiDescriptor.severity === 'WARNING') {
logEntry.severity = 'warning';
logEntry.severityIcon = severityIconWarning;
}
else {
// severity === 'INFO'
logEntry.severity = 'info';
logEntry.severityIcon = severityIconInfo;
}
legacyApiDescriptor.errors.push(logEntry);
break;
}
}
},
complete: function () {
resolve(true);
},
error: function (error) {
reject(error);
},
});
});
await this.flushDescriptorErrors();
await fs.remove(outputFile).catch(() => undefined);
}
else {
uxLog("warning", this, c.yellow(`Warning: Unable to process logs of ${logFileUrl}`));
}
}
async generateSummaryLog(ipCounts, severity) {
if (!ipCounts || Object.keys(ipCounts).length === 0) {
return null;
}
// Try to get hostname for ips
const ipResults = [];
for (const ip of Object.keys(ipCounts)) {
const count = ipCounts[ip];
let hostname = 'unknown';
try {
hostname = await dnsPromises.reverse(ip);
}
catch (e) {
hostname = 'unknown';
uxLog("other", this, c.grey(`Unable to resolve hostname for IP ${ip}: ${e}`));
}
const formattedHostname = Array.isArray(hostname) ? hostname.join(', ') : hostname;
const ipResult = { CLIENT_IP: ip, CLIENT_HOSTNAME: formattedHostname, SFDX_HARDIS_COUNT: count };
ipResults.push(ipResult);
}
const sortedIpResults = sortArray(ipResults, {
by: ['SFDX_HARDIS_COUNT'],
order: ['desc'],
});
this.ipResultsSorted = [
...this.ipResultsSorted,
...sortedIpResults.map((entry) => ({ ...entry, severity })),
];
// Write output CSV with client api info
const outputFileIps = this.outputFile.endsWith('.csv')
? this.outputFile.replace('.csv', '.api-clients-' + severity + '.csv')
: this.outputFile + 'api-clients-' + severity + '.csv';
const outputFileIpsRes = await generateCsvFile(sortedIpResults, outputFileIps, {
fileTitle: `Legacy API Clients - ${severity}`,
});
if (outputFileIpsRes.xlsxFile) {
this.outputFilesRes.xlsxFile2 = outputFileIpsRes.xlsxFile;
}
uxLog("log", this, c.italic(c.cyan(`Please see info about ${severity} API callers in ${c.bold(outputFileIps)}`)));
return outputFileIps;
}
parseApiVersion(rawValue) {
if (rawValue === undefined || rawValue === null || rawValue === '') {
return Number.POSITIVE_INFINITY;
}
if (typeof rawValue === 'number') {
return Number.isFinite(rawValue) ? rawValue : Number.POSITIVE_INFINITY;
}
const parsed = parseFloat(rawValue);
return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
}
matchesApiFamily(descriptor, apiFamily) {
if (!apiFamily) {
return false;
}
return descriptor.apiFamily.some((family) => family.toUpperCase() === apiFamily);
}
matchesApiVersion(descriptor, apiVersion) {
return apiVersion >= descriptor.minApiVersion && apiVersion <= descriptor.maxApiVersion;
}
matchesApiResource(descriptor, apiResource) {
if (!descriptor.apiResources || descriptor.apiResources.length === 0) {
return true;
}
if (!apiResource) {
return false;
}
return descriptor.apiResources.some((resource) => resource.toLowerCase() === apiResource);
}
}
//# sourceMappingURL=legacyapi.js.map