diff --git a/package.json b/package.json index 1822cdb2..59ffc9da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/aio-cli-plugin-api-mesh", - "version": "5.4.0-beta.0", + "version": "5.4.0-beta.1", "description": "Adobe I/O CLI plugin to develop and manage API mesh sources", "keywords": [ "oclif-plugin" diff --git a/src/commands/api-mesh/__tests__/get-log-forwarding-errors.test.js b/src/commands/api-mesh/__tests__/get-log-forwarding-errors.test.js new file mode 100644 index 00000000..924c2b12 --- /dev/null +++ b/src/commands/api-mesh/__tests__/get-log-forwarding-errors.test.js @@ -0,0 +1,225 @@ +/* +Copyright 2021 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +jest.mock('fs'); +jest.mock('axios'); + +jest.mock('../../../helpers', () => ({ + initSdk: jest.fn().mockResolvedValue({}), + initRequestId: jest.fn().mockResolvedValue({}), + promptConfirm: jest.fn().mockResolvedValue(true), +})); +jest.mock('../../../lib/smsClient'); +jest.mock('../../../classes/logger'); + +const fs = require('fs'); +const path = require('path'); +const GetLogForwardingErrorsCommand = require('../config/get/log-forwarding/errors'); +const { initSdk, promptConfirm } = require('../../../helpers'); +const { getMeshId, getLogForwardingErrors } = require('../../../lib/smsClient'); + +describe('GetLogForwardingErrorsCommand', () => { + let parseSpy; + let logSpy; + + beforeEach(() => { + logSpy = jest + .spyOn(GetLogForwardingErrorsCommand.prototype, 'log') + .mockImplementation(() => {}); + parseSpy = jest + .spyOn(GetLogForwardingErrorsCommand.prototype, 'parse') + .mockResolvedValue({ flags: { filename: undefined, ignoreCache: false } }); + jest.spyOn(path, 'extname').mockReturnValue('.csv'); + jest.spyOn(path, 'resolve').mockImplementation((...args) => args.join('/')); + initSdk.mockResolvedValue({ + imsOrgId: 'orgId', + imsOrgCode: 'orgCode', + projectId: 'projectId', + workspaceId: 'workspaceId', + workspaceName: 'workspaceName', + }); + getMeshId.mockResolvedValue('meshId'); + getLogForwardingErrors.mockResolvedValue({ + presignedUrls: ['http://example.com/error1', 'http://example.com/error2'], + totalSize: 1024, + }); + jest + .spyOn(GetLogForwardingErrorsCommand.prototype, 'downloadFileContent') + .mockImplementation(() => createMockStream(mockLogData)); + fs.existsSync.mockReturnValue(false); + fs.writeFileSync.mockImplementation(() => {}); + fs.statSync.mockReturnValue({ size: 0 }); + path.extname = jest.fn().mockReturnValue('.csv'); + path.resolve = jest.fn().mockImplementation((...args) => args.join('/')); + promptConfirm.mockResolvedValue(true); + global.requestId = 'dummy_request_id'; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('prints log lines to console when no filename provided', async () => { + const command = new GetLogForwardingErrorsCommand([], {}); + await command.run(); + // Accept log message with or without leading newline + const logCalls = logSpy.mock.calls.map(call => call[0]); + const found = logCalls.some( + line => line.trim() === 'Successfully fetched log forwarding errors.', + ); + expect(found).toBe(true); + const logLines = logCalls.filter(line => line.startsWith('> ')); + expect(logLines.length).toBe(6); // 3 lines per file * 2 files + expect(logLines[0]).toBe('> Error log line 1'); + expect(logLines[1]).toBe('> Error log line 2'); + expect(logLines[2]).toBe('> Error log line 3'); + }); + + test('writes to file when filename provided and user confirms', async () => { + parseSpy.mockResolvedValueOnce({ flags: { filename: 'test.csv', ignoreCache: false } }); + const command = new GetLogForwardingErrorsCommand([], {}); + await command.run(); + expect(promptConfirm).toHaveBeenCalledWith( + 'The expected file size is 1.00 KB. Confirm test.csv download? (y/n)', + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('test.csv'), + expect.stringContaining('Error log line 1'), + 'utf8', + ); + expect(logSpy).toHaveBeenCalledWith( + 'Successfully downloaded the log forwarding error logs to test.csv', + ); + }); + + test('does not write file when user declines confirmation', async () => { + parseSpy.mockResolvedValueOnce({ flags: { filename: 'test.csv', ignoreCache: false } }); + promptConfirm.mockResolvedValueOnce(false); + const command = new GetLogForwardingErrorsCommand([], {}); + await command.run(); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); // Only the initial empty file creation + expect(fs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('test.csv'), ''); + expect(logSpy).toHaveBeenCalledWith('Log forwarding errors file not downloaded.'); + }); + + test('throws error for invalid file extension', async () => { + parseSpy.mockResolvedValueOnce({ flags: { filename: 'test.txt', ignoreCache: false } }); + path.extname.mockReturnValue('.txt'); + const command = new GetLogForwardingErrorsCommand([], {}); + await expect(command.run()).rejects.toThrow( + 'Invalid file type. Provide a filename with a .csv extension.', + ); + }); + + test('throws error if existing file is not empty', async () => { + parseSpy.mockResolvedValueOnce({ flags: { filename: 'test.csv', ignoreCache: false } }); + fs.existsSync.mockReturnValue(true); + fs.statSync.mockReturnValue({ size: 100 }); + const command = new GetLogForwardingErrorsCommand([], {}); + await expect(command.run()).rejects.toThrow('Make sure the file: test.csv is empty'); + }); + + test('creates empty file if it does not exist', async () => { + parseSpy.mockResolvedValueOnce({ flags: { filename: 'test.csv', ignoreCache: false } }); + fs.existsSync.mockReturnValue(false); + const command = new GetLogForwardingErrorsCommand([], {}); + await command.run(); + expect(fs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('test.csv'), ''); + }); + + test('throws error if no presignedUrls returned', async () => { + getLogForwardingErrors.mockResolvedValueOnce({ presignedUrls: [], totalSize: 0 }); + const command = new GetLogForwardingErrorsCommand([], {}); + await expect(command.run()).rejects.toThrow( + 'No log forwarding errors found for the configured destination.', + ); + }); + + test('throws error if totalSize is 0', async () => { + getLogForwardingErrors.mockResolvedValueOnce({ + presignedUrls: ['http://example.com/error1'], + totalSize: 0, + }); + const command = new GetLogForwardingErrorsCommand([], {}); + await expect(command.run()).rejects.toThrow( + 'No log forwarding error logs available for the configured destination.', + ); + }); + + test('throws error if meshId is not found', async () => { + getMeshId.mockResolvedValueOnce(null); + const command = new GetLogForwardingErrorsCommand([], {}); + await expect(command.run()).rejects.toThrow( + 'Unable to get mesh ID. Please check the details and try again. RequestId: dummy_request_id', + ); + }); + + test('throws error if getMeshId throws', async () => { + getMeshId.mockImplementationOnce(() => { + throw new Error('fail mesh'); + }); + const command = new GetLogForwardingErrorsCommand([], {}); + await expect(command.run()).rejects.toThrow( + 'Unable to get mesh ID. Please check the details and try again. RequestId: dummy_request_id', + ); + }); + + test('handles download failure gracefully', async () => { + jest + .spyOn(GetLogForwardingErrorsCommand.prototype, 'downloadFileContent') + .mockRejectedValueOnce(new Error('Download failed')); + const command = new GetLogForwardingErrorsCommand([], {}); + await command.run(); + const logCalls = logSpy.mock.calls.map(call => call[0]); + const found = logCalls.some(line => + line.includes('Failed to download or process log file: Download failed'), + ); + expect(found).toBe(true); + }); + + test('filters out empty lines from content', async () => { + const mockDataWithEmptyLines = `Line 1\n\nLine 2\n\n\nLine 3\n`; + jest + .spyOn(GetLogForwardingErrorsCommand.prototype, 'downloadFileContent') + .mockImplementation(() => createMockStream(mockDataWithEmptyLines)); + const command = new GetLogForwardingErrorsCommand([], {}); + await command.run(); + const logCalls = logSpy.mock.calls.map(call => call[0]); + const logLines = logCalls.filter(line => line.startsWith('> ')); + expect(logLines.length).toBe(6); // 3 non-empty lines per file * 2 files + expect(logLines[0]).toBe('> Line 1'); + expect(logLines[1]).toBe('> Line 2'); + expect(logLines[2]).toBe('> Line 3'); + }); + + test('calls getLogForwardingErrors with correct parameters', async () => { + const command = new GetLogForwardingErrorsCommand([], {}); + await command.run(); + expect(getLogForwardingErrors).toHaveBeenCalledWith( + 'orgCode', + 'projectId', + 'workspaceId', + 'meshId', + ); + }); +}); + +const mockLogData = `Error log line 1\nError log line 2\n\nError log line 3`; + +function createMockStream(data) { + const { Readable } = require('stream'); + const stream = new Readable({ + read() {}, + }); + stream.push(data); + stream.push(null); + return stream; +} diff --git a/src/commands/api-mesh/config/delete/log-forwarding.js b/src/commands/api-mesh/config/delete/log-forwarding.js index 2bfbe43a..8b9a9f2c 100644 --- a/src/commands/api-mesh/config/delete/log-forwarding.js +++ b/src/commands/api-mesh/config/delete/log-forwarding.js @@ -21,6 +21,8 @@ class DeleteLogForwardingCommand extends Command { autoConfirmAction: autoConfirmActionFlag, }; + static usage = 'api-mesh:config:delete:log-forwarding'; + async run() { logger.info(`RequestId: ${global.requestId}`); diff --git a/src/commands/api-mesh/config/get/log-forwarding/errors.js b/src/commands/api-mesh/config/get/log-forwarding/errors.js new file mode 100644 index 00000000..e87d6a02 --- /dev/null +++ b/src/commands/api-mesh/config/get/log-forwarding/errors.js @@ -0,0 +1,166 @@ +/* +Copyright 2021 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { Command } = require('@oclif/core'); +const { initSdk, promptConfirm } = require('../../../../../helpers'); +const { getMeshId, getLogForwardingErrors } = require('../../../../../lib/smsClient'); +const logger = require('../../../../../classes/logger'); +const axios = require('axios'); +const { ignoreCacheFlag, fileNameFlag } = require('../../../../../utils'); +const fs = require('fs'); +const path = require('path'); + +require('dotenv').config(); + +class GetLogForwardingErrorsCommand extends Command { + static flags = { + ignoreCache: ignoreCacheFlag, + filename: fileNameFlag, + }; + + static usage = 'api-mesh:config:get:log-forwarding:errors'; + + async run() { + logger.info(`RequestId: ${global.requestId}`); + + const { flags } = await this.parse(GetLogForwardingErrorsCommand); + + const { ignoreCache, filename } = await flags; + + logger.info('Calling initSdk...'); + + const { imsOrgCode, projectId, workspaceId } = await initSdk({ ignoreCache }); + + // Retrieve meshId + let meshId = ''; + + try { + meshId = await getMeshId(imsOrgCode, projectId, workspaceId, meshId); + if (!meshId) { + throw new Error('MeshIdNotFound'); + } + } catch (err) { + this.error( + `Unable to get mesh ID. Please check the details and try again. RequestId: ${global.requestId}`, + ); + } + + // fetch log forwarding errors presigned URLs + const { presignedUrls, totalSize } = await getLogForwardingErrors( + imsOrgCode, + projectId, + workspaceId, + meshId, + ); + + // If presigned URLs are not found, throw error saying that no log forwarding errors are found + if (!presignedUrls || presignedUrls.length === 0) { + this.error( + `No log forwarding errors found for the configured destination. RequestId: ${global.requestId}`, + ); + } + + const allRows = []; + let shouldDownload = true; + + // If filename is provided, check if it has a .csv extension and if the file exists + if (filename) { + if (path.extname(filename).toLowerCase() !== '.csv') { + this.error('Invalid file type. Provide a filename with a .csv extension.'); + } + const outputFile = path.resolve(process.cwd(), filename); + if (fs.existsSync(outputFile)) { + // If the file exists, check if it is empty + const stats = fs.statSync(outputFile); + if (stats.size > 0) { + this.error(`Make sure the file: ${filename} is empty`); + } + } else { + // If the file does not exist, create an empty file + fs.writeFileSync(outputFile, ''); + } + } + if (totalSize > 0) { + // Download and process each presigned URL + for (const { url } of presignedUrls) { + try { + logger.info(`[GetLogForwardingErrorsCommand] Downloading from URL: ${url}`); + const stream = await this.downloadFileContent(url); + const content = await this.streamToString(stream); + // Split content into lines, filter out empty lines + const lines = content.split(/\r?\n/).filter(line => line.trim() !== ''); + allRows.push(...lines); + } catch (err) { + this.log( + `Failed to download or process log file: ${err.message}. RequestId: ${global.requestId}`, + ); + } + } + // if filename is provided, write the content to the file + if (filename) { + const totalSizeKB = (totalSize / 1024).toFixed(2); + // Get user confirmation before downloading + shouldDownload = await promptConfirm( + `The expected file size is ${totalSizeKB} KB. Confirm ${filename} download? (y/n)`, + ); + if (shouldDownload) { + const outputFile = path.resolve(process.cwd(), filename); + const csvContent = allRows.join('\n'); + fs.writeFileSync(outputFile, csvContent + '\n', 'utf8'); + this.log(`Successfully downloaded the log forwarding error logs to ${filename}`); + } else { + this.log('Log forwarding errors file not downloaded.'); + return; + } + } + // If filename is not provided, print the logs to the console + else { + this.log(`\nSuccessfully fetched log forwarding errors.`); + // print the error logs each in a new line starting with > + allRows.forEach(rows => { + this.log(`> ${rows}`); + }); + } + } else { + this.error( + `No log forwarding error logs available for the configured destination. RequestId: ${global.requestId}`, + ); + } + } + + // Download file content from the presigned URL + async downloadFileContent(url) { + logger.debug(`[downloadFileContent] Downloading from URL: ${url}`); + return axios({ + method: 'get', + url: url, + responseType: 'stream', + }) + .then(response => response.data) + .catch(error => { + logger.error('Error downloading log forwarding error content:', error.message); + throw error; + }); + } + + // Parse CSV rows from the content + async streamToString(stream) { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); + } +} + +GetLogForwardingErrorsCommand.description = 'Get log forwarding errors for the mesh.'; + +module.exports = GetLogForwardingErrorsCommand; diff --git a/src/commands/api-mesh/config/get/log-forwarding.js b/src/commands/api-mesh/config/get/log-forwarding/index.js similarity index 78% rename from src/commands/api-mesh/config/get/log-forwarding.js rename to src/commands/api-mesh/config/get/log-forwarding/index.js index dd925225..a1a2f941 100644 --- a/src/commands/api-mesh/config/get/log-forwarding.js +++ b/src/commands/api-mesh/config/get/log-forwarding/index.js @@ -10,10 +10,10 @@ governing permissions and limitations under the License. */ const { Command } = require('@oclif/core'); -const { initSdk, initRequestId } = require('../../../../helpers'); -const logger = require('../../../../classes/logger'); -const { ignoreCacheFlag, jsonFlag } = require('../../../../utils'); -const { getLogForwarding, getMeshId } = require('../../../../lib/smsClient'); +const { initSdk, initRequestId } = require('../../../../../helpers'); +const logger = require('../../../../../classes/logger'); +const { ignoreCacheFlag, jsonFlag } = require('../../../../../utils'); +const { getLogForwarding, getMeshId } = require('../../../../../lib/smsClient'); class GetLogForwardingCommand extends Command { static flags = { @@ -23,6 +23,8 @@ class GetLogForwardingCommand extends Command { static enableJsonFlag = true; + static usage = 'api-mesh:config:get:log-forwarding'; + async run() { await initRequestId(); @@ -73,6 +75,11 @@ class GetLogForwardingCommand extends Command { } } -GetLogForwardingCommand.description = `Get log forwarding details for a given mesh`; +GetLogForwardingCommand.description = `Get log forwarding details and error logs for a specified mesh. + +-The 'log-forwarding' command includes the following options: + +- api-mesh:config:get:log-forwarding : Retrieve log forwarding details for a given mesh. +- api-mesh:config:get:log-forwarding:errors : Download log forwarding error logs for a selected time period.`; module.exports = GetLogForwardingCommand; diff --git a/src/commands/api-mesh/config/index.js b/src/commands/api-mesh/config/index.js index 85b2556f..d7e3c668 100644 --- a/src/commands/api-mesh/config/index.js +++ b/src/commands/api-mesh/config/index.js @@ -13,15 +13,17 @@ governing permissions and limitations under the License. const { Help, Command } = require('@oclif/core'); class ConfigCommand extends Command { + static summary = 'Manage the configuration for API Mesh'; + + static usage = 'api-mesh:config [COMMAND]'; + async run() { const help = new Help(this.config); await help.showHelp(['api-mesh:config', '--help']); } } -ConfigCommand.description = `Manage the configuration for API Mesh. - -The 'config' command includes the following options: +ConfigCommand.description = `The 'config' command includes the following options: - set: Set log forwarding details for a given mesh. - get: Retrieve log forwarding details for a given mesh. - delete: Delete log forwarding details for a given mesh.`; diff --git a/src/commands/api-mesh/config/set/log-forwarding.js b/src/commands/api-mesh/config/set/log-forwarding.js index d6d3b91f..57b2fc79 100644 --- a/src/commands/api-mesh/config/set/log-forwarding.js +++ b/src/commands/api-mesh/config/set/log-forwarding.js @@ -42,6 +42,8 @@ class SetLogForwardingCommand extends Command { static enableJsonFlag = true; + static usage = 'api-mesh:config:set:log-forwarding'; + async run() { await initRequestId(); diff --git a/src/lib/smsClient.js b/src/lib/smsClient.js index 5ea90dbc..5d931ca9 100644 --- a/src/lib/smsClient.js +++ b/src/lib/smsClient.js @@ -1497,6 +1497,47 @@ const deleteLogForwarding = async (organizationCode, projectId, workspaceId, mes } }; +/** + * Get log forwarding errors for a given mesh within a specified time range. + * @param {string} organizationCode - The IMS org code + * @param {string} projectId - The project ID + * @param {string} workspaceId - The workspace ID + * @param {string} meshId - The mesh ID + */ +const getLogForwardingErrors = async (organizationCode, projectId, workspaceId, meshId) => { + const { accessToken } = await getDevConsoleConfig(); + const config = { + method: 'GET', + url: `${SMS_BASE_URL}/organizations/${organizationCode}/projects/${projectId}/workspaces/${workspaceId}/meshes/${meshId}/log/forwarding/errors`, + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'x-request-id': global.requestId, + 'x-api-key': SMS_API_KEY, + }, + }; + logger.info('Initiating GET %s', config.url); + try { + const response = await axios(config); + + logger.info('Response from GET %s', response.status); + + if (response?.status === 200) { + logger.info(`Log forwarding error Presigned urls: ${objToString(response, ['data'])}`); + const { presignedUrls, totalSize } = response.data; + return { + presignedUrls, + totalSize, + }; + } + } catch (error) { + logger.error(`Error fetching log forwarding errors presigned urls: ${error}`); + return { + urls: {}, + totalSize: 0, + }; + } +}; + module.exports = { getApiKeyCredential, describeMesh, @@ -1521,4 +1562,5 @@ module.exports = { setLogForwarding, getLogForwarding, deleteLogForwarding, + getLogForwardingErrors, };