diff --git a/src/commands/api-mesh/__tests__/set-log-forwarding.test.js b/src/commands/api-mesh/__tests__/set-log-forwarding.test.js new file mode 100644 index 0000000..e7c4686 --- /dev/null +++ b/src/commands/api-mesh/__tests__/set-log-forwarding.test.js @@ -0,0 +1,246 @@ +/* +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 SetLogForwardingCommand = require('../set-log-forwarding'); +const { + initSdk, + promptConfirm, + promptSelect, + promptInput, + promptInputSecret, +} = require('../../../helpers'); +const { getMeshId, setLogForwarding } = require('../../../lib/devConsole'); + +jest.mock('../../../helpers', () => ({ + initSdk: jest.fn().mockResolvedValue({}), + initRequestId: jest.fn().mockResolvedValue({}), + promptConfirm: jest.fn().mockResolvedValue(true), + promptSelect: jest.fn().mockResolvedValue('New Relic'), + promptInput: jest.fn().mockResolvedValue('https://log-api.newrelic.com/log/v1'), + promptInputSecret: jest.fn().mockResolvedValue('abcdef0123456789abcdef0123456789abcdef01'), +})); +jest.mock('../../../lib/devConsole'); +jest.mock('../../../classes/logger'); + +describe('SetLogForwardingCommand', () => { + let parseSpy; + let logSpy; + let errorSpy; + + beforeEach(() => { + // Setup spies and mock functions + parseSpy = jest.spyOn(SetLogForwardingCommand.prototype, 'parse').mockResolvedValue({ + flags: { + ignoreCache: false, + autoConfirmAction: false, + json: false, + }, + args: [], // Empty args since we are using prompts + }); + + logSpy = jest.spyOn(SetLogForwardingCommand.prototype, 'log'); + errorSpy = jest.spyOn(SetLogForwardingCommand.prototype, 'error').mockImplementation(() => { + throw new Error(errorSpy.mock.calls[0][0]); + }); + + initSdk.mockResolvedValue({ + imsOrgId: 'orgId', + imsOrgCode: 'orgCode', + projectId: 'projectId', + workspaceId: 'workspaceId', + workspaceName: 'workspaceName', + }); + getMeshId.mockResolvedValue('meshId'); + setLogForwarding.mockResolvedValue({ success: true, result: true }); + global.requestId = 'dummy_request_id'; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Test New Relic destination', () => { + /** Success Scenario */ + test('sets log forwarding with valid parameters', async () => { + const command = new SetLogForwardingCommand([], {}); + await command.run(); + + expect(promptSelect).toHaveBeenCalledWith('Select log forwarding destination:', [ + 'New Relic', + ]); + expect(promptInput).toHaveBeenCalledWith('Enter base URI:'); + expect(promptInputSecret).toHaveBeenCalledWith('Enter license key:'); + expect(setLogForwarding).toHaveBeenCalledWith( + 'orgCode', + 'projectId', + 'workspaceId', + 'meshId', + { + destination: 'newrelic', + config: { + baseUri: 'https://log-api.newrelic.com/log/v1', + licenseKey: 'abcdef0123456789abcdef0123456789abcdef01', + }, + }, + ); + expect(logSpy).toHaveBeenCalledWith('Log forwarding set successfully for meshId'); + }); + + /** Error Scenarios */ + test('throws an error if mesh ID is not found', async () => { + getMeshId.mockResolvedValueOnce(null); + + const command = new SetLogForwardingCommand([], {}); + await expect(command.run()).rejects.toThrow( + 'Unable to get meshId. No mesh found for Org(orgCode) -> Project(projectId) -> Workspace(workspaceId). Check the details and try again.', + ); + }); + + /** Input Validation */ + test('throws an error if base URI does not include protocol', async () => { + promptInput.mockResolvedValueOnce('log-api.newrelic.com/log/v1'); // Missing https:// + + const command = new SetLogForwardingCommand([], {}); + await expect(command.run()).rejects.toThrow( + 'The URI value must include the protocol (https://)', + ); + }); + + test('throws an error if license key has wrong format', async () => { + promptInputSecret.mockResolvedValueOnce('wrongformat'); // Too short + + const command = new SetLogForwardingCommand([], {}); + await expect(command.run()).rejects.toThrow( + `The license key is in the wrong format. Expected: 40 characters (received: ${11})`, + ); + }); + + test('prompts for missing destination', async () => { + parseSpy.mockResolvedValueOnce({ + flags: { + // No destination provided + ignoreCache: false, + autoConfirmAction: false, + json: false, + }, + args: [], + }); + + const command = new SetLogForwardingCommand([], {}); + await command.run(); + + expect(promptSelect).toHaveBeenCalledWith('Select log forwarding destination:', [ + 'New Relic', + ]); + }); + + test('throws an error if destination selection is cancelled', async () => { + parseSpy.mockResolvedValueOnce({ + flags: { + // No destination provided + ignoreCache: false, + autoConfirmAction: false, + json: false, + }, + args: [], + }); + + promptSelect.mockResolvedValueOnce(null); // User cancels selection + + const command = new SetLogForwardingCommand([], {}); + await expect(command.run()).rejects.toThrow('Destination is required'); + }); + + test('throws an error if base URI is empty', async () => { + promptInput.mockResolvedValueOnce(''); // Empty base URI + + const command = new SetLogForwardingCommand([], {}); + await expect(command.run()).rejects.toThrow('Base URI is required'); + }); + + test('throws an error if license key is empty', async () => { + promptInputSecret.mockResolvedValueOnce(''); // Empty license key + + const command = new SetLogForwardingCommand([], {}); + await expect(command.run()).rejects.toThrow('License key is required'); + }); + + test('returns cancellation message when user declines confirmation', async () => { + promptConfirm.mockResolvedValueOnce(false); // User declines + + const command = new SetLogForwardingCommand([], {}); + const result = await command.run(); + + expect(result).toBe('set-log-forwarding cancelled'); + expect(setLogForwarding).not.toHaveBeenCalled(); + }); + + /** Flag Handling */ + test('skips confirmation when autoConfirmAction flag is set', async () => { + parseSpy.mockResolvedValueOnce({ + flags: { + ignoreCache: false, + autoConfirmAction: true, // Auto-confirm enabled + json: false, + }, + args: [], + }); + + const command = new SetLogForwardingCommand([], {}); + await command.run(); + + expect(promptConfirm).not.toHaveBeenCalled(); + expect(setLogForwarding).toHaveBeenCalled(); + }); + + test('sets log forwarding with auto-confirmation', async () => { + parseSpy.mockResolvedValueOnce({ + flags: { + ignoreCache: false, + autoConfirmAction: true, // Auto-confirm enabled + json: false, + }, + args: [], + }); + + const command = new SetLogForwardingCommand([], {}); + await command.run(); + + expect(promptConfirm).not.toHaveBeenCalled(); + expect(setLogForwarding).toHaveBeenCalledWith( + 'orgCode', + 'projectId', + 'workspaceId', + 'meshId', + { + destination: 'newrelic', + config: { + baseUri: 'https://log-api.newrelic.com/log/v1', + licenseKey: 'abcdef0123456789abcdef0123456789abcdef01', + }, + }, + ); + expect(logSpy).toHaveBeenCalledWith('Log forwarding set successfully for meshId'); + }); + + test('logs error message when setLogForwarding fails', async () => { + const errorMessage = 'Unable to set log forwarding details'; + setLogForwarding.mockRejectedValueOnce(new Error(errorMessage)); + + const command = new SetLogForwardingCommand([], {}); + await expect(command.run()).rejects.toThrow( + 'Failed to set log forwarding details. Try again. RequestId: dummy_request_id', + ); + expect(logSpy).toHaveBeenCalledWith(errorMessage); + }); + }); +}); diff --git a/src/commands/api-mesh/set-log-forwarding.js b/src/commands/api-mesh/set-log-forwarding.js new file mode 100644 index 0000000..65e2a46 --- /dev/null +++ b/src/commands/api-mesh/set-log-forwarding.js @@ -0,0 +1,151 @@ +/* +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, + initRequestId, + promptConfirm, + promptSelect, + promptInput, + promptInputSecret, +} = require('../../helpers'); +const logger = require('../../classes/logger'); +const { ignoreCacheFlag, autoConfirmActionFlag, jsonFlag, destinations } = require('../../utils'); +const { setLogForwarding, getMeshId } = require('../../lib/devConsole'); + +class SetLogForwardingCommand extends Command { + static flags = { + ignoreCache: ignoreCacheFlag, + autoConfirmAction: autoConfirmActionFlag, + json: jsonFlag, + }; + + static enableJsonFlag = true; + + async run() { + await initRequestId(); + + logger.info(`RequestId: ${global.requestId}`); + + const { flags } = await this.parse(SetLogForwardingCommand); + + const ignoreCache = await flags.ignoreCache; + const autoConfirmAction = await flags.autoConfirmAction; + + let destinationConfig; + try { + destinationConfig = await this.inputAndValidateConfigs(destinations); + } catch (error) { + this.error(error.message); + return; + } + const { imsOrgCode, projectId, workspaceId, workspaceName } = await initSdk({ + ignoreCache, + }); + + let meshId = null; + + try { + meshId = await getMeshId(imsOrgCode, projectId, workspaceId, workspaceName); + } catch (err) { + this.log(err.message); + this.error( + `Unable to get mesh ID. Check the details and try again. RequestId: ${global.requestId}`, + ); + } + + // mesh could not be found + if (!meshId) { + this.error( + `Unable to get meshId. No mesh found for Org(${imsOrgCode}) -> Project(${projectId}) -> Workspace(${workspaceId}). Check the details and try again.`, + ); + } + + let shouldContinue = true; + + if (!autoConfirmAction) { + shouldContinue = await promptConfirm( + `Are you sure you want to set log forwarding to ${destinationConfig.destination}?`, + ); + } + + if (shouldContinue) { + try { + const response = await setLogForwarding( + imsOrgCode, + projectId, + workspaceId, + meshId, + destinationConfig, + ); + if (response && response.result) { + this.log(`Log forwarding set successfully for ${meshId}`); + return { destinationConfig, imsOrgCode, projectId, workspaceId, workspaceName }; + } else { + this.error( + `Unable to set log forwarding details. Try again. RequestId: ${global.requestId}`, + ); + return; + } + } catch (error) { + this.log(error.message); + this.error( + `Failed to set log forwarding details. Try again. RequestId: ${global.requestId}`, + ); + } + } else { + this.log('log-forwarding cancelled'); + return 'set-log-forwarding cancelled'; + } + } + + async inputAndValidateConfigs(destinations) { + // Prompt for destination + const destinationKey = await promptSelect( + 'Select log forwarding destination:', + Object.keys(destinations), + ); + if (!destinationKey) { + throw new Error('Destination is required'); + } + + const destinationConfig = destinations[destinationKey]; + const inputs = {}; + + // For each input defined in the destination config, prompt and validate + for (const inputConfig of destinationConfig.inputs) { + // Prompt for input value (regular or secret based on config) + const promptFn = inputConfig.isSecret ? promptInputSecret : promptInput; + const value = await promptFn(inputConfig.promptMessage); + + // Validate the input + if (inputConfig.validate) { + inputConfig.validate(value); + } + + // Store the validated input + inputs[inputConfig.name] = value; + } + + return { + destination: destinationConfig.name, + config: inputs, + }; + } +} + +SetLogForwardingCommand.description = `Sets the log forwarding destination for API mesh. +- Select a log forwarding destination - Choose from available options (for example, New Relic). +- Enter the base URI - Provide the URI for the log forwarding service. Ensure it includes the protocol (for example, if the hosted region of the New Relic account is the U.S, the base URI could be 'https://log-api.newrelic.com/log/v1'). +- Enter the license key - Provide the INGEST-LICENSE API key type.`; + +module.exports = SetLogForwardingCommand; diff --git a/src/helpers.js b/src/helpers.js index 842ba31..9e9c6fd 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -520,6 +520,24 @@ async function promptInput(message) { return selected.item; } +/** + * Function to prompt for a secret/password input that masks the characters + * + * @param {string} message - prompt message + * @returns {Promise} - the entered secret value + */ +async function promptInputSecret(message) { + const selected = await inquirer.prompt([ + { + name: 'item', + message, + type: 'password', + }, + ]); + + return selected.item; +} + /** * Import the files in the files array in meshConfig * @@ -949,6 +967,7 @@ module.exports = { objToString, promptInput, promptConfirm, + promptInputSecret, getLibConsoleCLI, getDevConsoleConfig, initSdk, diff --git a/src/lib/devConsole.js b/src/lib/devConsole.js index d25f4e5..6759c5d 100644 --- a/src/lib/devConsole.js +++ b/src/lib/devConsole.js @@ -1201,6 +1201,96 @@ const getLogsByRayId = async (organizationCode, projectId, workspaceId, meshId, } }; +/** + * @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 + * @param {Object} logConfig - The log forwarding configuration + */ +const setLogForwarding = async (organizationCode, projectId, workspaceId, meshId, logConfig) => { + const { accessToken } = await getDevConsoleConfig(); + const config = { + method: 'POST', + url: `${SMS_BASE_URL}/organizations/${organizationCode}/projects/${projectId}/workspaces/${workspaceId}/meshes/${meshId}/log/forwarding`, + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-request-id': global.requestId, + 'x-api-key': SMS_API_KEY, + }, + data: JSON.stringify(logConfig), + }; + + logger.info( + 'Initiating POST %s', + `${SMS_BASE_URL}/organizations/${organizationCode}/projects/${projectId}/workspaces/${workspaceId}/meshes/${meshId}/log/forwarding`, + ); + + try { + const response = await axios(config); + + logger.info('Response from POST %s', response.status); + + if (response?.status === 200) { + logger.info(`Log forwarding configuration: ${objToString(response, ['data'])}`); + return { + result: response.data.result, + message: response.data.message, + }; + } else { + // not 200 response + logger.error( + `Something went wrong: ${objToString( + response, + ['data'], + 'Unable to set log forwarding details.', + )}. Received ${response.status}, expected 200`, + ); + throw new Error(response.data.message); + } + } catch (error) { + if (error.response && error.response.status === 400) { + // The request was made and the server responded with a 400 status code + logger.error('Error setting log forwarding configuration: %j', error.response.data); + + throw new Error('Invalid input parameters.'); + } + // request made but no response received + else if (error.request && !error.response) { + logger.error('No response received from server when setting log forwarding configuration'); + throw new Error('Unable to set log forwarding details. Check the details and try again.'); + } + // response received with error + else if (error.response && error.response.data) { + logger.error( + 'Error setting log forwarding configuration: %s', + objToString(error, ['response', 'data'], 'Unable to set log forwarding'), + ); + + // response a message or messages field + + if (error.response.data.message || error.response.data.messages) { + const message = objToString( + error, + ['response', 'data', 'message' || 'messages'], + 'Unable to set log forwarding', + ); + throw new Error(message); + } + // response contains error but no specific message field + else { + const message = objToString(error, ['response', 'data'], 'Unable to set log forwarding'); + throw new Error(message); + } + } else { + // Something else happened while setting up the request + logger.error('Error setting log forwarding configuration: %s', error.message); + throw new Error(`Something went wrong while setting log forwarding. ${error.message}`); + } + } +}; + module.exports = { getApiKeyCredential, describeMesh, @@ -1222,4 +1312,5 @@ module.exports = { getPresignedUrls, getLogsByRayId, cachePurge, + setLogForwarding, }; diff --git a/src/utils.js b/src/utils.js index 382deb1..d989dff 100644 --- a/src/utils.js +++ b/src/utils.js @@ -99,6 +99,49 @@ const logFilenameFlag = Flags.string({ required: true, }); +// The `destinations` object to hold the configuration for log forwarding destinations. +// It prompts for the required inputs for the destination. +// Each destination can have different key/value pairs of configuration credentials. +// and applies the validation logic accordingly. +const destinations = { + // Configuration for the 'New Relic' destination + 'New Relic': { + name: 'newrelic', // internal value that will be used + // Required inputs for the 'New Relic' destination + inputs: [ + { + name: 'baseUri', + promptMessage: 'Enter base URI:', + isSecret: false, + validate: value => { + if (!value) { + throw new Error('Base URI is required'); + } + if (!value.startsWith('https://')) { + throw new Error('The URI value must include the protocol (https://)'); + } + }, + }, + { + name: 'licenseKey', + promptMessage: 'Enter license key:', + isSecret: true, + validate: value => { + if (!value) { + throw new Error('License key is required'); + } + if (value.length !== 40) { + throw new Error( + `The license key is in the wrong format. Expected: 40 characters (received: ${value.length})`, + ); + } + }, + }, + ], + }, + // Additional destinations can be added here +}; + /** * Parse the meshConfig and get the list of (local) files to be imported * @@ -778,4 +821,5 @@ module.exports = { validateDateTimeFormat, localToUTCTime, cachePurgeAllActionFlag, + destinations, };