diff --git a/docs/guides/dev.md b/docs/guides/dev.md new file mode 100644 index 00000000000..c273ef10a1a --- /dev/null +++ b/docs/guides/dev.md @@ -0,0 +1,49 @@ + + + + +### [Read this on the main serverless docs site](https://www.serverless.com/framework/docs/guides/dev/) + + + +# Serverless Console Dev Mode + +The `serverless dev` command will launch a [Serverless Console Dev Mode](https://www.serverless.com/console/docs/application-guide/dev-mode) session in your terminal. + +```bash +serverless dev +``` + +## Options + +- `--org` The organization that your AWS account is associated with in Serverless Console. +- `--region` or `-r` The region in that your function was deployed to. +- `--stage` or `-s` The stage in your service was deploy to. +- `--function` or `-f` The name of the function that you want to focus your dev mode activity on. If this option is excluded then all function activity will be streamed to your terminal. +- `--verbose` or `-v` If this flag is included all span input/output and lambda request/response data will be streamed to the terminal. + +## Examples + +### Start dev mode interactively selecting an organization + +```bash +serverless dev +``` + +### Start dev mode with an org pre selected + +```bash +serverless dev --org myorg +``` + +### Start dev mode with an org pre selected and all input output information logged + +```bash +serverless deploy function --function helloWorld --update-config +``` diff --git a/lib/cli/commands-schema/no-service.js b/lib/cli/commands-schema/no-service.js index 3ae1197ce18..eff6ee2761f 100644 --- a/lib/cli/commands-schema/no-service.js +++ b/lib/cli/commands-schema/no-service.js @@ -30,6 +30,15 @@ commands.set('', { usage: 'Enable Serverless Console integration. See: http://slss.io/console', type: 'boolean', }, + 'dev': { + usage: 'Launch dev mode activity feed. See: http://slss.io/console', + type: 'boolean', + }, + 'function': { + usage: 'Name of the function you would like the dev mode activity feed to observe.', + type: 'string', + shortcut: 'f', + }, }, lifecycleEvents: ['initializeService', 'setupAws', 'autoUpdate', 'end'], }); diff --git a/lib/cli/interactive-setup/console-dev-mode-feed.js b/lib/cli/interactive-setup/console-dev-mode-feed.js new file mode 100644 index 00000000000..e641993bad2 --- /dev/null +++ b/lib/cli/interactive-setup/console-dev-mode-feed.js @@ -0,0 +1,294 @@ +'use strict'; + +const { writeText, style, log, progress } = require('@serverless/utils/log'); +const { frontend } = require('@serverless/utils/lib/auth/urls'); +const colorize = require('json-colorizer'); +const WebSocket = require('ws'); +const chalk = require('chalk'); +const { devModeFeed } = require('@serverless/utils/lib/auth/urls'); +const consoleUi = require('@serverless/utils/console-ui'); +const streamBuffers = require('stream-buffers'); +const apiRequest = require('@serverless/utils/api-request'); +const promptWithHistory = require('@serverless/utils/inquirer/prompt-with-history'); + +const streamBuff = new streamBuffers.ReadableStreamBuffer({ + frequency: 500, + chunkSize: 2048 * 1000000, +}); + +const consoleMonitoringCounter = { + logBatches: 0, + events: 0, + responses: 0, +}; + +const jsonError = { + colors: { + BRACE: '#FD5750', + BRACKET: '#FD5750', + COLON: '#FD5750', + COMMA: '#FD5750', + STRING_KEY: '#FD5750', + STRING_LITERAL: '#FD5750', + NUMBER_LITERAL: '#FD5750', + BOOLEAN_LITERAL: '#FD5750', + NULL_LITERAL: '#FD5750', + }, +}; + +const jsonColors = { + colors: { + BRACE: 'white', + BRACKET: 'white', + COLON: 'white.bold', + COMMA: 'white', + STRING_KEY: 'white.bold', + STRING_LITERAL: 'white', + NUMBER_LITERAL: 'white', + BOOLEAN_LITERAL: 'white', + NULL_LITERAL: 'white', + }, +}; +const headerChalk = chalk.grey; +const errorTracker = {}; + +const handleSocketMessage = (context) => (data) => { + try { + const verbose = context.options.verbose; + const splitData = data.toString('utf-8').split(';;;'); + const jsonArray = splitData + .filter((item) => item !== '' && item.startsWith('[')) + .flatMap((item) => JSON.parse(item)); + const sortedItems = consoleUi.omitAndSortDevModeActivity(jsonArray); + + for (const activity of sortedItems) { + const resourceName = ((activity.tags || {}).aws || {}).resourceName; + const time = consoleUi.formatConsoleDate(new Date(activity.timestamp)); + + const tryPrintJSON = (str) => { + try { + const parsedBody = JSON.parse(str); + if (typeof parsedBody === 'string') { + throw new Error('Not a JSON object'); + } + const colors = activity.severityText === 'ERROR' ? jsonError : jsonColors; + process.stdout.write(`${colorize(JSON.stringify(parsedBody, null, 2), colors)}\n`); + } catch (error) { + process.stdout.write(chalk.white(`${str}${str.endsWith('\n') ? '' : '\n'}`)); + } + }; + + switch (activity.type) { + case 'log': + consoleMonitoringCounter.logBatches += 1; + process.stdout.write(headerChalk(`\n${time} • ${resourceName} • Log\n`)); + tryPrintJSON(activity.body); + break; + case 'span': { + const span = consoleUi.formatConsoleSpan(activity); + process.stdout.write( + headerChalk(`\n${time} • ${resourceName} • Span • ${span.niceName}\n`) + ); + if (verbose) { + if (activity.input) { + process.stdout.write(headerChalk('Input\n')); + tryPrintJSON(activity.input); + } + if (activity.output) { + process.stdout.write(headerChalk('Output\n')); + tryPrintJSON(activity.output); + } + } + break; + } + case 'aws-lambda-request': + process.stdout.write(headerChalk(`\n${time} • ${resourceName} • Invocation Started\n`)); + if (verbose) { + tryPrintJSON(activity.body); + } + break; + case 'aws-lambda-response': + consoleMonitoringCounter.responses += 1; + process.stdout.write(headerChalk(`\n${time} • ${resourceName} • Invocation Ended\n`)); + if (verbose) { + tryPrintJSON(activity.body); + } + if (errorTracker[activity.traceId]) { + const uiLink = `${frontend}/${ + context.org.orgName + }/explorer?explorerSubScope=invocations&explorerTraceId=${encodeURIComponent( + activity.traceId + )}&globalScope=awsLambda&globalTimeFrame=24h`; + process.stdout.write(chalk.white(`View full trace: ${uiLink}\n`)); + delete errorTracker[activity.traceId]; + } + break; + case 'event': { + consoleMonitoringCounter.events += 1; + const { message, payload } = consoleUi.formatConsoleEvent(activity); + const isError = /ERROR •/.test(message); + const headerWriter = isError ? chalk.hex('#FD5750') : headerChalk; + const options = isError ? jsonError : jsonColors; + process.stdout.write(headerWriter(`\n${time} • ${resourceName} • ${message}\n`)); + process.stdout.write(`${colorize(JSON.stringify(payload, null, 2), options)}\n`); + if (isError) { + errorTracker[activity.traceId] = true; + } + break; + } + default: + } + } + } catch (error) { + process.stdout.write(error, '\n'); + } +}; + +const connectToWebSocket = async ({ functionName, region, accountId, org, state }) => { + const { token } = await apiRequest(`/api/identity/orgs/${org.orgId}/token`); + const ws = new WebSocket(`${devModeFeed}?Auth=${token}`, { + perMessageDeflate: false, + }); + + ws.on('open', () => { + if (state && state === 'firstConnection') { + const functionNameQueryParams = functionName + .map((name) => `devModeFunctionName=${encodeURIComponent(name)}`) + .join('&'); + const uiLink = `${frontend}/${org.orgName}/dev-mode?devModeCloudAccountId=${accountId}&${functionNameQueryParams}`; + writeText( + style.aside( + '\n• Use the `--verbose` flag to see inputs and outputs of all requests (e.g. DynamoDB inputs/outputs).', + `• Use the Console Dev Mode UI for deeper inspection: ${uiLink}\n`, + 'Waiting for activity... Invoke your functions now.' + ) + ); + } else if (state && state === 'resume') { + writeText(style.aside('\nResuming for dev mode activity...')); + } + ws.send( + JSON.stringify({ filters: { functionName, region: [region], accountId: [accountId] } }) + ); + }); + ws.on('message', (data) => { + streamBuff.put(`${data.toString('utf-8')};;;`); + }); + return ws; +}; + +const startDevModeFeed = async (context, devModeFeedConnection) => + new Promise((resolve) => { + const createStillWorkingTimeout = () => + setTimeout(async () => { + clearInterval(eventPublishTimer); + clearInterval(connectionRefreshTimer); + devModeFeedConnection.terminate(); + writeText(style.aside('Pausing for dev mode activity.\n')); + const shouldContinue = await promptWithHistory({ + name: 'shouldContinue', + message: 'Are you still working?', + stepHistory: context.stepHistory, + type: 'confirm', + }); + + if (shouldContinue) { + await startDevModeFeed(context, 'resume'); + } + resolve(); + }, 1000 * 60 * 60 * 1.5); // Check for activity every 1.5 hours + + let stillWorkingTimer = createStillWorkingTimeout(); + + const connectionRefreshTimer = setInterval(async () => { + const newConnection = await connectToWebSocket({ + functionName: context.consoleDevModeTargetFunctions, + accountId: context.awsAccountId, + region: context.serverless.service.provider.region, + org: context.org, + state: false, + }); + const oldConnection = devModeFeedConnection; + oldConnection.terminate(); + devModeFeedConnection = newConnection; + watchStream(devModeFeedConnection); + }, 1000 * 60 * 60); // Refresh every hour + + const eventPublishTimer = setInterval(async () => { + const { userId } = await apiRequest('/api/identity/me'); + const body = { + source: 'web.dev_mode.activity.v1', + event: { + orgUid: context.org.orgId, + userId, + logBatches: consoleMonitoringCounter.logBatches, + responses: consoleMonitoringCounter.responses, + events: consoleMonitoringCounter.events, + source: 'cli:serverless', + }, + }; + await apiRequest('/api/events/publish', { + method: 'POST', + body, + }); + consoleMonitoringCounter.logBatches = 0; + consoleMonitoringCounter.responses = 0; + consoleMonitoringCounter.events = 0; + }, 1000 * 60); // Publish every 60 seconds + + const watchStream = (feed) => { + feed.on('message', (data) => { + // Ignore connection message + const parsedData = JSON.parse(data.toString('utf-8')); + if (!parsedData.resetThrottle) { + clearTimeout(stillWorkingTimer); + stillWorkingTimer = createStillWorkingTimeout(); + } + }); + feed.on('close', () => { + // Clean up if we receive a close event + clearInterval(eventPublishTimer); + clearInterval(connectionRefreshTimer); + clearTimeout(stillWorkingTimer); + resolve(); + }); + }; + watchStream(devModeFeedConnection); + }); + +module.exports = { + async isApplicable(context) { + const { isConsoleDevMode, org, consoleDevModeTargetFunctions } = context; + + if (!isConsoleDevMode) { + context.inapplicabilityReasonCode = 'NON_DEV_MODE_CONTEXT'; + return false; + } + + if (!org) { + context.inapplicabilityReasonCode = 'UNRESOLVED_ORG'; + return false; + } + + if (!consoleDevModeTargetFunctions) { + context.inapplicabilityReasonCode = 'NO_TARGET_FUNCTIONS'; + return false; + } + + return true; + }, + + async run(context) { + const devModeProgress = progress.get('dev-mode-progress'); + devModeProgress.remove(); + log.notice.success('Dev Mode Initialized.'); + streamBuff.on('data', handleSocketMessage(context)); + const devModeFeedConnection = await connectToWebSocket({ + functionName: context.consoleDevModeTargetFunctions, + accountId: context.awsAccountId, + region: context.serverless.service.provider.region, + org: context.org, + state: 'firstConnection', + }); + await startDevModeFeed(context, devModeFeedConnection); + }, +}; diff --git a/lib/cli/interactive-setup/console-enable-dev-mode.js b/lib/cli/interactive-setup/console-enable-dev-mode.js new file mode 100644 index 00000000000..647502f88c7 --- /dev/null +++ b/lib/cli/interactive-setup/console-enable-dev-mode.js @@ -0,0 +1,191 @@ +'use strict'; +const wait = require('timers-ext/promise/sleep'); +const { log, progress } = require('@serverless/utils/log'); +const apiRequest = require('@serverless/utils/api-request'); + +const progressKey = 'dev-mode-progress'; + +const allFunctionsExist = async (context) => { + const { total, hits } = await apiRequest(`/api/search/orgs/${context.org.orgId}/search`, { + method: 'POST', + body: { + from: 0, + size: context.consoleDevModeTargetFunctions.length, + query: { + bool: { + must: [ + { + match: { type: 'resource_aws_lambda' }, + }, + { + match: { tag_account_id: context.awsAccountId }, + }, + { + terms: { 'aws_lambda_name.keyword': context.consoleDevModeTargetFunctions }, + }, + ], + }, + }, + }, + }); + + return { + hits, + allExist: total === context.consoleDevModeTargetFunctions.length, + total: context.consoleDevModeTargetFunctions.length, + functionCount: total, + }; +}; + +const checkInstrumentationStatus = async (context) => { + const { total } = await apiRequest(`/api/search/orgs/${context.org.orgId}/search`, { + method: 'POST', + body: { + from: 0, + size: context.consoleDevModeTargetFunctions.length, + query: { + bool: { + must: [ + { + match: { type: 'resource_aws_lambda' }, + }, + { + match: { tag_account_id: context.awsAccountId }, + }, + { + match: { instrument_mode: 'dev' }, + }, + { + terms: { 'aws_lambda_name.keyword': context.consoleDevModeTargetFunctions }, + }, + ], + }, + }, + }, + }); + + return { + isInstrumented: total === context.consoleDevModeTargetFunctions.length, + total: context.consoleDevModeTargetFunctions.length, + instrumented: total, + }; +}; + +const waitForInstrumentation = async (context) => { + const instrumentationProgress = progress.get(progressKey); + let isInstrumenting = true; + while (isInstrumenting) { + const { isInstrumented: done, total, instrumented } = await checkInstrumentationStatus(context); + instrumentationProgress.update(`Instrumenting ${instrumented}/${total} functions`); + if (done) { + isInstrumenting = false; + } else { + await wait(1000); + } + } +}; + +module.exports = { + async isApplicable(context) { + const { isConsoleDevMode, org } = context; + + if (!isConsoleDevMode) { + context.inapplicabilityReasonCode = 'NON_DEV_MODE_CONTEXT'; + return false; + } + + if (!org) { + context.inapplicabilityReasonCode = 'UNRESOLVED_ORG'; + return false; + } + + const instrumentationProgress = progress.get(progressKey); + instrumentationProgress.update('Validating Serverless Console instrumentation status'); + + // Add single function name or all function names to the list + const targetFunctions = []; + const targetInstrumentations = []; + context.serverless.service.setFunctionNames(context.options); + if (context.options.function) { + const func = context.serverless.service.getFunction(context.options.function); + const functionName = func.name; + targetInstrumentations.push({ + instrumentations: { + mode: 'dev', + }, + resourceKey: `aws_${context.awsAccountId}_function_${context.serverless.service.provider.region}_${functionName}`, + }); + targetFunctions.push(functionName); + } else { + const names = context.serverless.service.getAllFunctionsNames(); + for (const name of names) { + const functionName = name; + targetInstrumentations.push({ + instrumentations: { + mode: 'dev', + }, + resourceKey: `aws_${context.awsAccountId}_function_${context.serverless.service.provider.region}_${functionName}`, + }); + targetFunctions.push(functionName); + } + } + + context.targetInstrumentations = targetInstrumentations; + context.consoleDevModeTargetFunctions = targetFunctions; + const { allExist, total, functionCount, hits } = await allFunctionsExist(context); + if (!allExist) { + const foundFunctionNames = hits.map(({ aws_lambda_name: awsLambdaName }) => awsLambdaName); + log.notice(); + const promptLogger = functionCount === 0 ? log.error : log.warning; + promptLogger( + `${functionCount} of ${total} functions exist in your console integration. Deploy your service now to add these functions to your integration.\n` + ); + if (functionCount === 0) { + context.inapplicabilityReasonCode = 'NO_FUNCTIONS_EXIST'; + context.targetInstrumentations = undefined; + context.consoleDevModeTargetFunctions = undefined; + return false; + } + context.consoleDevModeTargetFunctions = foundFunctionNames; + context.targetInstrumentations = context.targetInstrumentations.filter((target) => { + const name = target.resourceKey.split('_').pop(); + return foundFunctionNames.includes(name); + }); + } + + const { isInstrumented } = await checkInstrumentationStatus(context); + if (isInstrumented) { + context.inapplicabilityReasonCode = 'ALREADY_INSTRUMENTED'; + } + return !isInstrumented; + }, + + async run(context) { + const instrumentationProgress = progress.get(progressKey); + instrumentationProgress.notice('Instrumenting functions', 'This may take a few minutes...'); + // Chunk targetInstrumentations into 50 resources per request + const distributeArrayBy50 = (array) => { + const result = []; + let index = 0; + while (index < array.length) result.push(array.slice(index, (index += 50))); + return result; + }; + const chunkedResources = distributeArrayBy50(context.targetInstrumentations); + + // Send requests to instrument + for (const chunk of chunkedResources) { + await apiRequest('/api/integrations/aws/instrumentations', { + urlName: 'integrationsBackend', + method: 'POST', + body: { + orgId: context.org.orgId, + resources: chunk, + }, + }); + } + + // Wait for instrumentation to complete + await waitForInstrumentation(context); + return true; + }, +}; diff --git a/lib/cli/interactive-setup/console-login.js b/lib/cli/interactive-setup/console-login.js index 5e731973ce3..05d14882a5c 100644 --- a/lib/cli/interactive-setup/console-login.js +++ b/lib/cli/interactive-setup/console-login.js @@ -30,6 +30,8 @@ module.exports = { return false; } + showOnboardingWelcome(context); + if (await resolveAuthMode()) { context.inapplicabilityReasonCode = 'ALREADY_LOGGED_IN'; return false; @@ -38,8 +40,6 @@ module.exports = { return true; }, async run(context) { - showOnboardingWelcome(context); - return steps.loginOrRegister(context); }, steps, diff --git a/lib/cli/interactive-setup/console-resolve-org.js b/lib/cli/interactive-setup/console-resolve-org.js index 156b1d0d2f2..dc7f8d3708b 100644 --- a/lib/cli/interactive-setup/console-resolve-org.js +++ b/lib/cli/interactive-setup/console-resolve-org.js @@ -4,11 +4,10 @@ const { log } = require('@serverless/utils/log'); const resolveAuthMode = require('@serverless/utils/auth/resolve-mode'); const apiRequest = require('@serverless/utils/api-request'); const promptWithHistory = require('@serverless/utils/inquirer/prompt-with-history'); -const { showOnboardingWelcome } = require('./utils'); const orgsChoice = async (orgs, stepHistory) => promptWithHistory({ - message: 'What org do you want to add this service to?', + message: 'What org do you want to use this service with?', type: 'list', name: 'orgName', choices: [ @@ -45,9 +44,6 @@ module.exports = { return false; } - log.notice(); - showOnboardingWelcome(context); - if (orgName) { const org = orgs.find((someOrg) => someOrg.orgName === orgName); if (org) { diff --git a/lib/cli/interactive-setup/console-setup-iam-role.js b/lib/cli/interactive-setup/console-setup-iam-role.js index 4208a5d6fe9..ac4f3d68d26 100644 --- a/lib/cli/interactive-setup/console-setup-iam-role.js +++ b/lib/cli/interactive-setup/console-setup-iam-role.js @@ -54,7 +54,7 @@ const waitUntilIntegrationIsReady = async (context) => { module.exports = { async isApplicable(context) { - const { isConsole } = context; + const { isConsole, isConsoleDevMode } = context; if (!isConsole) { context.inapplicabilityReasonCode = 'NON_CONSOLE_CONTEXT'; @@ -91,7 +91,9 @@ module.exports = { } log.notice(); - log.notice.success('Your AWS account is integrated with Serverless Console'); + if (!isConsoleDevMode) { + log.notice.success('Your AWS account is integrated with Serverless Console'); + } context.inapplicabilityReasonCode = 'INTEGRATED'; return false; } @@ -100,10 +102,13 @@ module.exports = { await awsRequest(context, cloudFormationServiceConfig, 'describeStacks', { StackName: iamRoleStackName, }); - log.warning( + log.error( 'Cannot integrate with Serverless Console: ' + - 'AWS account is already integrated with another org' + 'AWS account is already integrated with another org. ' + + 'You can set the AWS_PROFILE environment variable to use a different AWS Profile.' ); + context.isConsole = false; + context.isConsoleDevMode = false; context.inapplicabilityReasonCode = 'AWS_ACCOUNT_ALREADY_INTEGRATED'; return false; } catch (error) { @@ -114,19 +119,20 @@ module.exports = { }, async run(context) { - const { stepHistory } = context; + const { stepHistory, isConsoleDevMode } = context; if ( !(await promptWithHistory({ message: `Press [Enter] to enable Serverless Console's next-generation monitoring.\n\n${style.aside( [ 'This will create an IAM Role in your AWS account with the following permissions:', - '- Subscribe to CloudWatch logs and metrics', - '- Update Lambda layers and env vars to add tracing and real-time logging', - '- Read resource info for security alerts', + '• Subscribe to CloudWatch logs and metrics', + '• Update Lambda layers and env vars to add tracing and real-time logging', + '• Read resource info for security alerts', `See the IAM Permissions transparently here: ${style.link( 'https://slss.io/iam-role-permissions' )}`, + 'Would you like to proceed?', ] )}`, type: 'confirm', @@ -168,7 +174,9 @@ module.exports = { await waitUntilIntegrationIsReady(context); - log.notice.success('Your AWS account is integrated with Serverless Console'); + if (!isConsoleDevMode) { + log.notice.success('Your AWS account is integrated with Serverless Console'); + } } finally { integrationSetupProgress.remove(); } diff --git a/lib/cli/interactive-setup/dashboard-login.js b/lib/cli/interactive-setup/dashboard-login.js index e1fcac36cf9..999c144e223 100644 --- a/lib/cli/interactive-setup/dashboard-login.js +++ b/lib/cli/interactive-setup/dashboard-login.js @@ -26,9 +26,9 @@ const steps = { module.exports = { async isApplicable(context) { - const { configuration, options, serviceDir } = context; + const { isDashboard, configuration, options, serviceDir } = context; - if (options.console) { + if (!isDashboard) { context.inapplicabilityReasonCode = 'CONSOLE_CONTEXT'; return false; } diff --git a/lib/cli/interactive-setup/dashboard-set-org.js b/lib/cli/interactive-setup/dashboard-set-org.js index 7568bdb0a4c..d5e07989974 100644 --- a/lib/cli/interactive-setup/dashboard-set-org.js +++ b/lib/cli/interactive-setup/dashboard-set-org.js @@ -193,15 +193,15 @@ const steps = { module.exports = { async isApplicable(context) { - const { configuration, options, serviceDir } = context; + const { isDashboard, configuration, options, serviceDir } = context; - if (!serviceDir) { - context.inapplicabilityReasonCode = 'NOT_IN_SERVICE_DIRECTORY'; + if (!isDashboard) { + context.inapplicabilityReasonCode = 'CONSOLE_CONTEXT'; return false; } - if (options.console) { - context.inapplicabilityReasonCode = 'CONSOLE_CONTEXT'; + if (!serviceDir) { + context.inapplicabilityReasonCode = 'NOT_IN_SERVICE_DIRECTORY'; return false; } @@ -243,6 +243,7 @@ module.exports = { context.inapplicabilityReasonCode = 'NO_ORGS_AVAILABLE'; return false; } + if (!usesServerlessAccessKey) { user = configUtils.getLoggedInUser(); // Refreshed, as new token might have been generated } diff --git a/lib/cli/interactive-setup/deploy.js b/lib/cli/interactive-setup/deploy.js index 50b544f80e7..3a7812270a2 100644 --- a/lib/cli/interactive-setup/deploy.js +++ b/lib/cli/interactive-setup/deploy.js @@ -23,13 +23,14 @@ const printMessage = () => { module.exports = { async isApplicable(context) { - const { configuration, serviceDir, options, initial } = context; + const { isConsole, isOnboarding, configuration, serviceDir, options, initial } = context; + if (!serviceDir) { context.inapplicabilityReasonCode = 'NOT_IN_SERVICE_DIRECTORY'; return false; } - if (options.console && initial.isInServiceContext) { + if ((isConsole || !isOnboarding) && initial.isInServiceContext) { context.inapplicabilityReasonCode = 'CONSOLE_INTEGRATION'; return false; } diff --git a/lib/cli/interactive-setup/index.js b/lib/cli/interactive-setup/index.js index 2ab17d45545..d3fe96ad584 100644 --- a/lib/cli/interactive-setup/index.js +++ b/lib/cli/interactive-setup/index.js @@ -1,6 +1,8 @@ 'use strict'; +const apiRequest = require('@serverless/utils/api-request'); const inquirer = require('@serverless/utils/inquirer'); +const ServerlessError = require('@serverless/utils/serverless-error'); const { StepHistory } = require('@serverless/utils/telemetry'); const log = require('@serverless/utils/log').log.get('onboarding'); const { resolveInitialContext } = require('./utils'); @@ -15,6 +17,8 @@ const steps = { dashboardSetOrg: require('./dashboard-set-org'), awsCredentials: require('./aws-credentials'), deploy: require('./deploy'), + consoleEnableDevMode: require('./console-enable-dev-mode'), + consoleDevModeFeed: require('./console-dev-mode-feed'), }; const resolveAwsAccountId = async (context) => { @@ -51,8 +55,9 @@ module.exports = async (context) => { commandUsage.initialContext = initialContext; context.initial = initialContext; context.awsAccountId = await resolveAwsAccountId(context); - - if (options.console) { + context.isOnboarding = !options.dev; + context.isDashboard = !options.console && !options.dev; + if (options.console || (options.dev && initialContext.isInServiceContext)) { if (!context.awsAccountId) { log.error( 'We’re unable to connect Console via the CLI - No local AWS credentials found\n' + @@ -63,6 +68,26 @@ module.exports = async (context) => { } } + if (context.isConsole && options.dev && initialContext.isInServiceContext) { + const compatibilityMap = await apiRequest('/api/inventories/compatibility', { + method: 'GET', + noAuth: true, + }); + const devModeRuntimeCompatibility = compatibilityMap.mode.dev.runtimes; + const { provider } = context.serverless.service; + if (!devModeRuntimeCompatibility.includes(provider.runtime)) { + log.error('This services runtime is not currently supported by Serverless Console Dev Mode.'); + context.isConsole = false; + } else { + context.isConsoleDevMode = true; + } + } else if (options.dev && !initialContext.isInServiceContext) { + throw new ServerlessError( + 'Cannot launch dev mode when not in a service context.', + 'NOT_APPLICABLE_DEV_MODE_CONTEXT' + ); + } + for (const [stepName, step] of Object.entries(steps)) { delete context.stepHistory; delete context.inapplicabilityReasonCode; diff --git a/lib/cli/interactive-setup/utils.js b/lib/cli/interactive-setup/utils.js index e0dde388f02..da953f77ee0 100644 --- a/lib/cli/interactive-setup/utils.js +++ b/lib/cli/interactive-setup/utils.js @@ -114,7 +114,9 @@ module.exports = { }, showOnboardingWelcome: memoizee( (context) => { - if (context.isConsole) { + if (context.isConsoleDevMode) { + log.notice('Initializing Dev Mode via Serverless Console...'); + } else if (context.isConsole) { log.notice("Enabling Serverless Console's next-generation monitoring."); log.notice(style.aside('Learn more at https://serverless.com/console')); } else { diff --git a/package.json b/package.json index 3c1852d87b3..65e50910780 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "https-proxy-agent": "^5.0.1", "is-docker": "^2.2.1", "js-yaml": "^4.1.0", + "json-colorizer": "^2.2.2", "json-cycle": "^1.3.0", "json-refs": "^3.0.15", "lodash": "^4.17.21", @@ -67,6 +68,7 @@ "require-from-string": "^2.0.2", "semver": "^7.3.8", "signal-exit": "^3.0.7", + "stream-buffers": "^3.0.2", "strip-ansi": "^6.0.1", "supports-color": "^8.1.1", "tar": "^6.1.13", @@ -74,6 +76,7 @@ "type": "^2.7.2", "untildify": "^4.0.0", "uuid": "^9.0.0", + "ws": "^7.5.9", "yaml-ast-parser": "0.0.43" }, "devDependencies": { @@ -105,7 +108,6 @@ "sinon": "^13.0.2", "sinon-chai": "^3.7.0", "standard-version": "^9.5.0", - "ws": "^7.5.9", "xml2js": "^0.4.23" }, "eslintConfig": { diff --git a/scripts/serverless.js b/scripts/serverless.js index 3d69e55ba45..2c8a402ed04 100755 --- a/scripts/serverless.js +++ b/scripts/serverless.js @@ -135,11 +135,18 @@ processSpanPromise = (async () => { (() => { // Rewrite eventual `sls deploy -f` into `sls deploy function -f` + // Also rewrite `serverless dev` to `serverless --dev`` const isParamName = RegExp.prototype.test.bind(require('../lib/cli/param-reg-exp')); const args = process.argv.slice(2); const firstParamIndex = args.findIndex(isParamName); const commands = args.slice(0, firstParamIndex === -1 ? Infinity : firstParamIndex); + + if (commands.join('') === 'dev') { + process.argv[2] = '--dev'; + return; + } + if (commands.join(' ') !== 'deploy') return; if (!args.includes('-f') && !args.includes('--function')) return; logDeprecation( diff --git a/test/unit/lib/cli/interactive-setup/console-dev-mode-feed.test.js b/test/unit/lib/cli/interactive-setup/console-dev-mode-feed.test.js new file mode 100644 index 00000000000..6376ecbd7f3 --- /dev/null +++ b/test/unit/lib/cli/interactive-setup/console-dev-mode-feed.test.js @@ -0,0 +1,381 @@ +'use strict'; + +const chai = require('chai'); +const WebSocket = require('ws'); +const sinon = require('sinon'); +const sleep = require('timers-ext/promise/sleep'); +const consoleUi = require('@serverless/utils/console-ui'); +const proxyquire = require('proxyquire').noPreserveCache(); + +const { expect } = chai; +chai.use(require('chai-as-promised')); + +let step; +const originalSetInterval = setInterval; +describe('test/unit/lib/cli/interactive-setup/console-dev-mode-feed.test.js', function () { + this.timeout(1000 * 60 * 3); + const fakeOrgId = '123'; + const fakeAWSAccountId = 'account1'; + const publishFake = sinon.fake(); + const fakeRegion = 'us-east-1'; + const fakeTime = 'fakeTime'; + const consoleDevModeTargetFunctions = ['function1']; + + const fakeGreyWriter = sinon.fake.returns(''); + const fakeJSONWriter = sinon.fake.returns(''); + const fakeErrorWriter = sinon.fake.returns(''); + let socketConnection; + let socketServer; + let timers = []; + + before(() => { + step = proxyquire('../../../../../lib/cli/interactive-setup/console-dev-mode-feed', { + '@serverless/utils/api-request': async (pathname, options) => { + if (pathname === `/api/identity/orgs/${fakeOrgId}/token`) { + return { token: 'fakeToken' }; + } + if (pathname === '/api/identity/me') { + return { userId: 'user123' }; + } + if (pathname === '/api/events/publish') { + publishFake(options); + return { success: true }; + } + throw new Error(`Unexpected pathname "${pathname}"`); + }, + '@serverless/utils/console-ui': { + omitAndSortDevModeActivity: consoleUi.omitAndSortDevModeActivity, + formatConsoleDate: () => fakeTime, + formatConsoleSpan: (span) => ({ + niceName: span.name, + }), + formatConsoleEvent: (event) => ({ + message: /\.error\./.test(event.eventName) ? 'ERROR • fake' : 'WARNING • fake', + payload: /\.error\./.test(event.eventName) ? event.tags.error : event.tags.warning, + }), + }, + '@serverless/utils/lib/auth/urls': { + devModeFeed: 'ws://localhost:9988', + }, + 'chalk': { + white: fakeGreyWriter, + grey: fakeGreyWriter, + hex: () => fakeErrorWriter, + }, + 'json-colorizer': fakeJSONWriter, + }); + }); + + beforeEach(() => { + timers = []; + // eslint-disable-next-line no-global-assign + setInterval = (cb) => { + timers.push(cb); + }; + }); + + afterEach(() => { + if (socketConnection) { + socketConnection.terminate(); + } + if (socketServer) { + socketServer.close(); + } + // eslint-disable-next-line no-global-assign + setInterval = originalSetInterval; + }); + + it('Should be ineffective, when not in console dev mode context', async () => { + const context = { isConsoleDevMode: false, options: {} }; + expect(await step.isApplicable(context)).to.be.false; + expect(context.inapplicabilityReasonCode).to.equal('NON_DEV_MODE_CONTEXT'); + }); + + it('Should be ineffective, when no org is selected', async () => { + const context = { isConsoleDevMode: true, options: {}, org: null }; + expect(await step.isApplicable(context)).to.be.false; + expect(context.inapplicabilityReasonCode).to.equal('UNRESOLVED_ORG'); + }); + + it('Should be ineffective, when functions are targeted', async () => { + const context = { isConsoleDevMode: true, options: {}, org: { orgId: fakeOrgId } }; + expect(await step.isApplicable(context)).to.be.false; + expect(context.inapplicabilityReasonCode).to.equal('NO_TARGET_FUNCTIONS'); + }); + + it('Should be effective and connect to websocket', async () => { + const context = { + isConsoleDevMode: true, + options: { + verbose: true, + }, + org: { orgId: fakeOrgId }, + consoleDevModeTargetFunctions, + awsAccountId: fakeAWSAccountId, + serverless: { + service: { + provider: fakeRegion, + }, + }, + }; + expect(await step.isApplicable(context)).to.be.true; + + const waitForConnection = () => + new Promise((resolve) => { + socketServer = new WebSocket.Server({ port: 9988 }); + step.run(context); + socketServer.on('connection', (ws) => { + ws.on('message', () => { + ws.send( + JSON.stringify({ message: 'filters successfully applied', resetThrottle: true }) + ); + }); + resolve(ws); + }); + }); + socketConnection = await waitForConnection(); + + /** + * Set of messages containing 👇 + * + * 1. request + * 2. JSON log + * 3. text log + * 4. JSON parsable text log + * 5. s3 span + * 6. Warning event + * 7. Error event + * 8. response + * + * It also included the aws.lambda* spans that should be ignored :) + */ + const mockMessages = [ + [ + { + body: '{"key1":"value1","key2":"value2","key3":"value3"}', + timestamp: '2023-03-20T21:26:10.790Z', + tags: { + aws: { + resourceName: 'example-dev-function1', + }, + }, + type: 'aws-lambda-request', + sequenceId: 1679347571057, + }, + ], + [ + { + name: 'aws.lambda.initialization', + timestamp: '2023-03-20T21:26:10.365Z', + tags: { + aws: { + resourceName: 'example-dev-function1', + }, + }, + type: 'span', + sequenceId: 1679347571276, + }, + ], + [ + { + body: '{"message":"Hi dev mode 👋"}\n', + severityNumber: '1', + severityText: 'INFO', + timestamp: '2023-03-20T21:26:10.802Z', + tags: { + aws: { + resourceName: 'example-dev-function1', + }, + }, + type: 'log', + sequenceId: 1679344258090, + }, + { + body: 'text log\n', + severityNumber: '1', + severityText: 'INFO', + timestamp: '2023-03-20T21:26:10.802Z', + tags: { + aws: { + resourceName: 'example-dev-function1', + }, + }, + type: 'log', + sequenceId: 1679344258091, + }, + { + body: '"hello"', + severityNumber: '1', + severityText: 'INFO', + timestamp: '2023-03-20T21:26:10.802Z', + tags: { + aws: { + resourceName: 'example-dev-function1', + }, + }, + type: 'log', + sequenceId: 1679344258091, + }, + ], + [ + { + customTags: '{}', + input: '{"Bucket":"fake-bucket"}', + name: 'aws.sdk.s3.listobjectsv2', + output: '{"message": "s3 output"}', + timestamp: '2023-03-20T21:26:10.804Z', + tags: { + aws: { + resourceName: 'example-dev-function1', + }, + }, + type: 'span', + sequenceId: 1679347571306, + }, + { + customTags: '{"foo":"bar"}', + eventName: 'telemetry.warning.generated.v1', + tags: { + aws: { + resourceName: 'example-dev-function1', + }, + warning: { + message: 'This is a warning', + stacktrace: + 'at module.exports.handler (/var/task/index.js:12:7)\nat process.processTicksAndRejections (node:internal/process/task_queues:95:5)', + type: 'WARNING_TYPE_USER', + }, + }, + timestamp: '2023-03-20T21:26:10.916Z', + type: 'event', + sequenceId: 1679347571307, + }, + { + customTags: '{"foo":"bar"}', + eventName: 'telemetry.error.generated.v1', + tags: { + aws: { + resourceName: 'example-dev-function1', + }, + error: { + message: 'Oh no!', + name: 'Error', + stacktrace: + 'at module.exports.handler (/var/task/index.js:13:20)\nat process.processTicksAndRejections (node:internal/process/task_queues:95:5)', + type: 'ERROR_TYPE_CAUGHT_USER', + }, + }, + timestamp: '2023-03-20T21:26:10.924Z', + type: 'event', + sequenceId: 1679347571308, + }, + ], + [ + { + customTags: '{}', + name: 'aws.lambda.invocation', + timestamp: '2023-03-20T21:26:10.790Z', + type: 'span', + tags: { + aws: { + resourceName: 'example-dev-function1', + }, + }, + sequenceId: 1679347572067, + }, + { + customTags: '{}', + isHistorical: false, + name: 'aws.lambda', + timestamp: '2023-03-20T21:26:10.365Z', + tags: { + aws: { + resourceName: 'example-dev-function1', + }, + }, + type: 'span', + sequenceId: 1679347572068, + }, + ], + [ + { + body: '{"response":"hello there"}', + timestamp: '2023-03-20T21:26:11.934Z', + tags: { + aws: { + resourceName: 'example-dev-function1', + }, + }, + type: 'aws-lambda-response', + sequenceId: 1679347572127, + }, + ], + ]; + + // Send all messages + for (const message of mockMessages) { + socketConnection.send(JSON.stringify(message)); + } + + // Wait for all messages to be processed + await sleep(600); + + // Publish dev mode events + await timers[1](); + + // Close connection to socket + socketConnection.terminate(); + + // Assert that each message had a header and our text log was written + expect(fakeGreyWriter.callCount).to.equal(12); + expect(fakeGreyWriter.getCall(0).args[0]).to.equal( + `\n${fakeTime} • example-dev-function1 • Invocation Started\n` + ); + // Plain text log message + expect(fakeGreyWriter.getCall(3).args[0]).to.equal('text log\n'); + // Empty text log message + expect(fakeGreyWriter.getCall(5).args[0]).to.equal('"hello"\n'); + expect(fakeGreyWriter.getCall(6).args[0]).to.equal( + `\n${fakeTime} • example-dev-function1 • Span • aws.sdk.s3.listobjectsv2\n` + ); + // Check end message is last + expect(fakeGreyWriter.getCall(10).args[0]).to.equal( + `\n${fakeTime} • example-dev-function1 • Invocation Ended\n` + ); + + // Assert that our first log message was processed as JSON and both the warning and error event were printed to the console + expect(fakeJSONWriter.callCount).to.equal(7); + expect(fakeJSONWriter.getCall(0).args[0]).to.equal( + `${JSON.stringify(JSON.parse(mockMessages[0][0].body), null, 2)}` + ); + expect(fakeJSONWriter.getCall(1).args[0]).to.equal( + `${JSON.stringify(JSON.parse(mockMessages[2][0].body), null, 2)}` + ); + expect(fakeJSONWriter.getCall(2).args[0]).to.equal( + `${JSON.stringify(JSON.parse(mockMessages[3][0].input), null, 2)}` + ); + expect(fakeJSONWriter.getCall(3).args[0]).to.equal( + `${JSON.stringify(JSON.parse(mockMessages[3][0].output), null, 2)}` + ); + expect(fakeJSONWriter.getCall(4).args[0]).to.equal( + `${JSON.stringify(mockMessages[3][1].tags.warning, null, 2)}` + ); + expect(fakeJSONWriter.getCall(5).args[0]).to.equal( + `${JSON.stringify(mockMessages[3][2].tags.error, null, 2)}` + ); + expect(fakeJSONWriter.getCall(5).args[1].colors.BRACE).to.equal('#FD5750'); + + // Assert that the error event was printed with the error + expect(fakeErrorWriter.callCount).to.equal(1); + expect(fakeErrorWriter.getCall(0).args[0]).to.equal( + `\n${fakeTime} • example-dev-function1 • ERROR • fake\n` + ); + + // Validate publish event was called + expect(publishFake.callCount).to.equal(1); + expect(publishFake.getCall(0).args[0].body.event.logBatches).to.equal(3); + expect(publishFake.getCall(0).args[0].body.event.responses).to.equal(1); + expect(publishFake.getCall(0).args[0].body.event.events).to.equal(2); + expect(publishFake.getCall(0).args[0].body.event.source).to.equal('cli:serverless'); + }); +}); diff --git a/test/unit/lib/cli/interactive-setup/console-enable-dev-mode.test.js b/test/unit/lib/cli/interactive-setup/console-enable-dev-mode.test.js new file mode 100644 index 00000000000..f5156ff1b15 --- /dev/null +++ b/test/unit/lib/cli/interactive-setup/console-enable-dev-mode.test.js @@ -0,0 +1,298 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire').noPreserveCache(); + +const { expect } = chai; +chai.use(require('chai-as-promised')); + +let step; +describe('test/unit/lib/cli/interactive-setup/console-enable-dev-mode.test.js', () => { + let fakeOrgId; + let expectedFunctionCount; + let fakeRegion; + let expectedFunctionHits; + let expectedServiceFunctionNames; + + beforeEach(() => { + fakeOrgId = '123'; + expectedFunctionHits = [ + { + aws_lambda_name: 'function1', + }, + ]; + expectedServiceFunctionNames = expectedFunctionHits.map((hit) => hit.aws_lambda_name); + expectedFunctionCount = expectedFunctionHits.length; + fakeRegion = 'us-east-1'; + }); + + const configureStep = ({ + functionExistResponse, + checkInstrumentationResponse, + instrumentFunctionResponse = { success: true }, + }) => { + step = proxyquire('../../../../../lib/cli/interactive-setup/console-enable-dev-mode', { + '@serverless/utils/api-request': async (pathname, options) => { + if ( + pathname === `/api/search/orgs/${fakeOrgId}/search` && + options.body.query.bool.must.length === 3 + ) { + return functionExistResponse; + } + if ( + pathname === `/api/search/orgs/${fakeOrgId}/search` && + options.body.query.bool.must.length === 4 + ) { + return checkInstrumentationResponse; + } + if (pathname === '/api/integrations/aws/instrumentations') { + if (options.body.resources.length > 50) { + throw new Error('Too many resources to instrument'); + } + return instrumentFunctionResponse; + } + throw new Error(`Unexpected pathname "${pathname}"`); + }, + }); + }; + + it('Should be ineffective, when not in console dev mode context', async () => { + configureStep({ + functionExistResponse: {}, + checkInstrumentationResponse: {}, + }); + const context = { isConsoleDevMode: false, options: {} }; + expect(await step.isApplicable(context)).to.be.false; + expect(context.inapplicabilityReasonCode).to.equal('NON_DEV_MODE_CONTEXT'); + }); + + it('Should be ineffective, when no org is selected', async () => { + configureStep({ + functionExistResponse: {}, + checkInstrumentationResponse: {}, + }); + const context = { isConsoleDevMode: true, options: {}, org: null }; + expect(await step.isApplicable(context)).to.be.false; + expect(context.inapplicabilityReasonCode).to.equal('UNRESOLVED_ORG'); + }); + + it('Should be ineffective, when functions are already instrumented', async () => { + configureStep({ + functionExistResponse: { + total: expectedFunctionCount, + hits: expectedFunctionHits, + }, + checkInstrumentationResponse: { + total: expectedFunctionCount, + hits: expectedFunctionHits, + }, + }); + + const context = { + isConsoleDevMode: true, + options: {}, + org: { + orgId: fakeOrgId, + }, + serverless: { + service: { + provider: { + region: fakeRegion, + }, + setFunctionNames: () => {}, + getAllFunctionsNames: () => expectedServiceFunctionNames, + }, + }, + }; + expect(await step.isApplicable(context)).to.be.false; + expect(context.inapplicabilityReasonCode).to.equal('ALREADY_INSTRUMENTED'); + expect(context.targetInstrumentations.length).to.equal(1); + expect(context.consoleDevModeTargetFunctions.length).to.equal(1); + }); + + it('Should be ineffective and cancel, when only one function exists and it is not included in the integration', async () => { + configureStep({ + functionExistResponse: { + total: 0, + hits: [], + }, + checkInstrumentationResponse: { + total: 0, + hits: [], + }, + }); + + const context = { + isConsoleDevMode: true, + options: {}, + org: { + orgId: fakeOrgId, + }, + serverless: { + service: { + provider: { + region: fakeRegion, + }, + setFunctionNames: () => {}, + getAllFunctionsNames: () => expectedServiceFunctionNames, + }, + }, + }; + expect(await step.isApplicable(context)).to.be.false; + expect(context.inapplicabilityReasonCode).to.equal('NO_FUNCTIONS_EXIST'); + expect(context.targetInstrumentations).to.be.undefined; + expect(context.consoleDevModeTargetFunctions).to.be.undefined; + }); + + it('Should be effective and only update functions that were found in the integration', async () => { + // Add a function that is not in the integration to the serverless service + expectedServiceFunctionNames.push('function2'); + // Set up the expected responses from the API + const functionExistResponse = { + total: expectedFunctionCount, + hits: expectedFunctionHits, + }; + const checkInstrumentationResponse = { + total: 0, + hits: [], + }; + configureStep({ + functionExistResponse, + checkInstrumentationResponse, + }); + + const context = { + isConsoleDevMode: true, + options: {}, + org: { + orgId: fakeOrgId, + }, + serverless: { + service: { + provider: { + region: fakeRegion, + }, + setFunctionNames: () => {}, + getAllFunctionsNames: () => expectedServiceFunctionNames, + }, + }, + }; + expect(await step.isApplicable(context)).to.be.true; + expect(context.targetInstrumentations.length).to.equal(1); + expect(context.consoleDevModeTargetFunctions.length).to.equal(1); + + // Re-proxyquire step so we can update the response to the checkInstrumentation call + configureStep({ + functionExistResponse, + checkInstrumentationResponse: { + total: expectedFunctionCount, + hits: expectedFunctionHits, + }, + }); + + expect(await step.run(context)).to.be.true; + }); + + it('Should be effective and only target function from -f option', async () => { + const functionExistResponse = { + total: expectedFunctionCount, + hits: expectedFunctionHits, + }; + const checkInstrumentationResponse = { + total: 0, + hits: [], + }; + configureStep({ + functionExistResponse, + checkInstrumentationResponse, + }); + + const context = { + isConsoleDevMode: true, + options: { + function: expectedServiceFunctionNames[0], + }, + org: { + orgId: fakeOrgId, + }, + serverless: { + service: { + provider: { + region: fakeRegion, + }, + setFunctionNames: () => {}, + getFunction: (name) => ({ + name, + }), + getAllFunctionsNames: () => [], + }, + }, + }; + expect(await step.isApplicable(context)).to.be.true; + expect(context.targetInstrumentations.length).to.equal(1); + expect(context.consoleDevModeTargetFunctions.length).to.equal(1); + + // Re-proxyquire step so we can update the response to the checkInstrumentation call + configureStep({ + functionExistResponse, + checkInstrumentationResponse: { + total: expectedFunctionCount, + hits: expectedFunctionHits, + }, + }); + + expect(await step.run(context)).to.be.true; + }); + + it('Should be effective and update 50 functions at a time', async () => { + expectedFunctionHits = new Array(100) + .fill(0) + .map((_, i) => ({ aws_lambda_name: `function${i + 1}` })); + expectedFunctionCount = expectedFunctionHits.length; + expectedServiceFunctionNames = expectedFunctionHits.map((hit) => hit.aws_lambda_name); + const functionExistResponse = { + total: expectedFunctionCount, + hits: expectedFunctionHits, + }; + const checkInstrumentationResponse = { + total: 0, + hits: [], + }; + configureStep({ + functionExistResponse, + checkInstrumentationResponse, + }); + + const context = { + isConsoleDevMode: true, + options: {}, + org: { + orgId: fakeOrgId, + }, + serverless: { + service: { + provider: { + region: fakeRegion, + }, + setFunctionNames: () => {}, + getFunction: () => ({}), + getAllFunctionsNames: () => expectedServiceFunctionNames, + }, + }, + }; + expect(await step.isApplicable(context)).to.be.true; + expect(context.targetInstrumentations.length).to.equal(expectedFunctionCount); + expect(context.consoleDevModeTargetFunctions.length).to.equal(expectedFunctionCount); + + // Re-proxyquire step so we can update the response to the checkInstrumentation call + configureStep({ + functionExistResponse, + checkInstrumentationResponse: { + total: expectedFunctionCount, + hits: expectedFunctionHits, + }, + }); + + expect(await step.run(context)).to.be.true; + }); +}); diff --git a/test/unit/lib/cli/interactive-setup/dashboard-login.test.js b/test/unit/lib/cli/interactive-setup/dashboard-login.test.js index f51ea2c613f..acff8332b3b 100644 --- a/test/unit/lib/cli/interactive-setup/dashboard-login.test.js +++ b/test/unit/lib/cli/interactive-setup/dashboard-login.test.js @@ -53,18 +53,25 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-login.test.js', function loginStub.resetHistory(); }); - it('Should be ineffective in console context', async () => { - const context = { isConsole: true, options: { console: true } }; - expect(await step.isApplicable(context)).to.be.false; - expect(context.inapplicabilityReasonCode).to.equal('CONSOLE_CONTEXT'); - }); - it('Should be ineffective, when not at service path', async () => { - const context = { options: {} }; + const context = { options: {}, isDashboard: true }; expect(await step.isApplicable(context)).to.be.false; expect(context.inapplicabilityReasonCode).to.equal('NOT_IN_SERVICE_DIRECTORY'); }); + it('Should be ineffective, when not in dashboard context', async () => { + const context = { + serviceDir: process.cwd(), + configuration: {}, + configurationFilename: 'serverless.yml', + options: {}, + initial: {}, + inquirer, + }; + expect(await step.isApplicable(context)).to.equal(false); + expect(context.inapplicabilityReasonCode).to.equal('CONSOLE_CONTEXT'); + }); + it('Should be ineffective, when not at AWS service path', async () => { const context = { serviceDir: process.cwd(), @@ -72,6 +79,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-login.test.js', function configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, }; expect(await step.isApplicable(context)).to.equal(false); @@ -85,6 +93,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-login.test.js', function configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, }; expect(await step.isApplicable(context)).to.equal(false); @@ -101,6 +110,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-login.test.js', function configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, }; expect(await overrideCwd(serviceDir, async () => await step.isApplicable(context))).to.equal( @@ -125,6 +135,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-login.test.js', function configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -148,6 +159,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-login.test.js', function configurationFilename: 'serverless.yml', options: { org: 'someorg' }, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -168,6 +180,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-login.test.js', function configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -191,6 +204,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-login.test.js', function configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; diff --git a/test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js b/test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js index 78fec049fa0..d0652d52cef 100644 --- a/test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js +++ b/test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js @@ -116,21 +116,23 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi it('Should be ineffective, when not at service path', async () => { const context = { initial: {}, + isDashboard: true, }; expect(await step.isApplicable(context)).to.be.false; expect(context.inapplicabilityReasonCode).to.equal('NOT_IN_SERVICE_DIRECTORY'); }); - it('Should be ineffective, when in console context', async () => { + it('Should be ineffective, when not in dashboard context', async () => { const context = { initial: {}, serviceDir: process.cwd(), configuration: {}, configurationFilename: 'serverless.yml', - options: { console: true }, - isConsole: true, + options: {}, + isDashboard: false, + isConsole: false, }; - expect(await step.isApplicable(context)).to.be.false; + expect(await step.isApplicable(context)).to.equal(false); expect(context.inapplicabilityReasonCode).to.equal('CONSOLE_CONTEXT'); }); @@ -141,6 +143,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, }; expect(await step.isApplicable(context)).to.equal(false); @@ -154,6 +157,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, }; expect(await step.isApplicable(context)).to.equal(false); @@ -170,6 +174,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, }; expect(await step.isApplicable(context)).to.equal(false); @@ -208,6 +213,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, }; await overrideCwd(serviceDir, async () => { @@ -226,6 +232,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, }; await overrideCwd(serviceDir, async () => { @@ -251,6 +258,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -283,6 +291,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -309,6 +318,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -336,6 +346,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -373,6 +384,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -411,6 +423,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -444,6 +457,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -489,6 +503,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -520,6 +535,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, stepHistory: new StepHistory(), }; @@ -551,6 +567,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, history: new Map([['dashboardLogin', []]]), stepHistory: new StepHistory(), @@ -580,6 +597,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: {}, initial: {}, + isDashboard: true, inquirer, history: new Map([ ['dashboardLogin', []], @@ -615,6 +633,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: { org: 'testinteractivecli', app: 'other-app' }, initial: {}, + isDashboard: true, inquirer, history: new Map(), stepHistory: new StepHistory(), @@ -648,6 +667,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: { org: 'otherorg', app: 'app-from-flag' }, initial: {}, + isDashboard: true, inquirer, history: new Map(), stepHistory: new StepHistory(), @@ -684,6 +704,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: { org: 'otherorg', app: 'app-from-flag' }, initial: {}, + isDashboard: true, inquirer, history: new Map(), stepHistory: new StepHistory(), @@ -714,6 +735,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: { org: 'invalid-testinteractivecli', app: 'irrelevant' }, initial: {}, + isDashboard: true, inquirer, history: new Map(), stepHistory: new StepHistory(), @@ -755,6 +777,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: { org: 'invalid-testinteractivecli', app: 'irrelevant' }, initial: {}, + isDashboard: true, inquirer, history: new Map(), stepHistory: new StepHistory(), @@ -796,6 +819,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: { org: 'invalid-testinteractivecli', app: 'irrelevant' }, initial: {}, + isDashboard: true, inquirer, history: new Map(), stepHistory: new StepHistory(), @@ -834,6 +858,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi configurationFilename: 'serverless.yml', options: { org: 'testinteractivecli', app: 'invalid' }, initial: {}, + isDashboard: true, inquirer, history: new Map(), stepHistory: new StepHistory(), @@ -872,6 +897,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi inquirer, options: {}, initial: {}, + isDashboard: true, history: new Map(), stepHistory: new StepHistory(), }; @@ -914,6 +940,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi inquirer, options: {}, initial: {}, + isDashboard: true, history: new Map(), stepHistory: new StepHistory(), }; @@ -954,6 +981,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi inquirer, options: {}, initial: {}, + isDashboard: true, history: new Map(), stepHistory: new StepHistory(), }; @@ -988,6 +1016,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi inquirer, options: { app: 'app-from-flag' }, initial: {}, + isDashboard: true, history: new Map(), stepHistory: new StepHistory(), }; @@ -1019,6 +1048,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi inquirer, options: {}, initial: {}, + isDashboard: true, history: new Map(), stepHistory: new StepHistory(), }; @@ -1052,6 +1082,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi inquirer, options: { app: 'invalid-app-from-flag' }, initial: {}, + isDashboard: true, history: new Map(), stepHistory: new StepHistory(), }; @@ -1095,6 +1126,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi inquirer, options: {}, initial: {}, + isDashboard: true, history: new Map(), stepHistory: new StepHistory(), }; @@ -1136,6 +1168,7 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-set-org.test.js', functi inquirer, options: {}, initial: {}, + isDashboard: true, history: new Map(), stepHistory: new StepHistory(), }; diff --git a/test/unit/lib/cli/interactive-setup/deploy.test.js b/test/unit/lib/cli/interactive-setup/deploy.test.js index 2d1c5cbb123..2b7194d5500 100644 --- a/test/unit/lib/cli/interactive-setup/deploy.test.js +++ b/test/unit/lib/cli/interactive-setup/deploy.test.js @@ -29,12 +29,40 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { configuration: { provider: { name: 'notaws' } }, serviceDir: '/foo', options: {}, + isOnboarding: true, history: new Map([['service', []]]), }; expect(await step.isApplicable(context)).to.equal(false); expect(context.inapplicabilityReasonCode).to.equal('NON_AWS_PROVIDER'); }); + it('Should be not applied, when service is not in onboarding context', async () => { + const context = { + configuration: { provider: { name: 'aws' } }, + serviceDir: '/foo', + options: {}, + isOnboarding: false, + history: new Map([['awsCredentials', []]]), + initial: { isInServiceContext: true }, + }; + expect(await step.isApplicable(context)).to.equal(false); + expect(context.inapplicabilityReasonCode).to.equal('CONSOLE_INTEGRATION'); + }); + + it('Should be not applied, when in console context', async () => { + const context = { + configuration: { provider: { name: 'aws' } }, + serviceDir: '/foo', + options: {}, + isOnboarding: true, + isConsole: true, + history: new Map([['awsCredentials', []]]), + initial: { isInServiceContext: true }, + }; + expect(await step.isApplicable(context)).to.equal(false); + expect(context.inapplicabilityReasonCode).to.equal('CONSOLE_INTEGRATION'); + }); + it('Should be applied if user configured local credentials', async () => { await overrideEnv( { variables: { AWS_ACCESS_KEY_ID: 'somekey', AWS_SECRET_ACCESS_KEY: 'somesecret' } }, @@ -44,6 +72,7 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { configuration: { provider: { name: 'aws' } }, serviceDir: '/foo', options: {}, + isOnboarding: true, history: new Map([['awsCredentials', []]]), }) ).to.equal(true); @@ -64,6 +93,7 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { configuration: { provider: { name: 'aws' }, org: 'someorg', app: 'someapp' }, serviceDir: '/foo', options: {}, + isOnboarding: true, history: new Map([['awsCredentials', []]]), }) ).to.equal(true); @@ -87,6 +117,7 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { }, serviceDir: '/foo', options: {}, + isOnboarding: true, history: new Map([['awsCredentials', []]]), }) ).to.equal(true); @@ -110,6 +141,7 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { initial: { isInServiceContext: false, }, + isOnboarding: true, }; await step.run(context); @@ -133,6 +165,7 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { initial: { isInServiceContext: true, }, + isOnboarding: true, }; await step.run(context); @@ -158,6 +191,7 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { initial: { isInServiceContext: false, }, + isOnboarding: true, }; await step.run(context); @@ -183,6 +217,7 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { initial: { isInServiceContext: true, }, + isOnboarding: true, }; await step.run(context); @@ -236,6 +271,7 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { initial: { isInServiceContext: false, }, + isOnboarding: true, }; await mockedStep.run(context); @@ -289,6 +325,7 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { initial: { isInServiceContext: true, }, + isOnboarding: true, }; await mockedStep.run(context); @@ -336,6 +373,7 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { initial: { isInServiceContext: false, }, + isOnboarding: true, }; await mockedStep.run(context); @@ -383,6 +421,7 @@ describe('test/unit/lib/cli/interactive-setup/deploy.test.js', () => { initial: { isInServiceContext: true, }, + isOnboarding: true, }; await mockedStep.run(context);