diff --git a/lib/cli/interactive-setup/console-login.js b/lib/cli/interactive-setup/console-login.js index 37d74e3e2f2f..74aa5277bdac 100644 --- a/lib/cli/interactive-setup/console-login.js +++ b/lib/cli/interactive-setup/console-login.js @@ -1,20 +1,13 @@ 'use strict'; -const _ = require('lodash'); const resolveAuthMode = require('@serverless/utils/auth/resolve-mode'); const promptWithHistory = require('@serverless/utils/inquirer/prompt-with-history'); const login = require('../../commands/login/console'); const { showOnboardingWelcome } = require('./utils'); const loginOrRegisterQuestion = async (context) => { - let message; - if (context.initial.isInServiceContext) { - message = 'Press [Enter] to create a free Serverless Console account'; - } else { - message = 'Do you want to login/register to Serverless Console?'; - } return promptWithHistory({ - message, + message: 'Press [Enter] to login to Serverless Console.', type: 'confirm', name: 'shouldLoginOrRegister', stepHistory: context.stepHistory, @@ -31,7 +24,7 @@ const steps = { module.exports = { async isApplicable(context) { - const { isConsole, configuration, serviceDir } = context; + const { isConsole, serviceDir } = context; if (!isConsole) { context.inapplicabilityReasonCode = 'NON_CONSOLE_CONTEXT'; @@ -48,23 +41,10 @@ module.exports = { return false; } - if ( - _.get(configuration, 'provider') !== 'aws' && - _.get(configuration, 'provider.name') !== 'aws' - ) { - context.inapplicabilityReasonCode = 'NON_AWS_PROVIDER'; - return false; - } - - const runtime = _.get(configuration.provider, 'runtime') || 'nodejs14.x'; - if (!runtime.startsWith('nodejs')) { - context.inapplicabilityReasonCode = 'UNSUPPORTED_RUNTIME'; - return false; - } return true; }, async run(context) { - if (context.initial.isInServiceContext) showOnboardingWelcome(context); + showOnboardingWelcome(context); return steps.loginOrRegister(context); }, diff --git a/lib/cli/interactive-setup/console-resolve-org.js b/lib/cli/interactive-setup/console-resolve-org.js new file mode 100644 index 000000000000..156b1d0d2f29 --- /dev/null +++ b/lib/cli/interactive-setup/console-resolve-org.js @@ -0,0 +1,85 @@ +'use strict'; + +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?', + type: 'list', + name: 'orgName', + choices: [ + ...orgs.map((org) => ({ name: org.orgName, value: org })), + { name: '[Skip]', value: '_skip_' }, + ], + stepHistory, + }); + +const resolveOrgs = async () => { + const { userId } = await apiRequest('/api/identity/me'); + return (await apiRequest(`/api/identity/users/${userId}/orgs`)).orgs; +}; + +module.exports = { + async isApplicable(context) { + const { options, isConsole } = context; + + if (!isConsole) { + context.inapplicabilityReasonCode = 'NON_CONSOLE_CONTEXT'; + return false; + } + + if (!(await resolveAuthMode())) { + context.inapplicabilityReasonCode = 'NOT_LOGGED_IN'; + return false; + } + + const orgs = await resolveOrgs(); + + const orgName = options.org; + if (!orgs.length) { + context.inapplicabilityReasonCode = 'NO_ORGS_AVAILABLE'; + return false; + } + + log.notice(); + showOnboardingWelcome(context); + + if (orgName) { + const org = orgs.find((someOrg) => someOrg.orgName === orgName); + if (org) { + context.org = org; + context.inapplicabilityReasonCode = 'RESOLVED_FROM_OPTIONS'; + return false; + } + + log.error( + 'Passed value for "--org" doesn\'t seem to correspond to account with which ' + + "you're logged in with. Please choose applicable org" + ); + + return { orgs, isOrgMismatch: true }; + } else if (orgs.length === 1) { + context.org = orgs[0]; + context.inapplicabilityReasonCode = 'ONLY_ORG'; + return false; + } + return { orgs }; + }, + async run(context, stepData) { + const { stepHistory } = context; + + const org = await orgsChoice(stepData.orgs, stepHistory); + + if (org === '_skip_') { + log.error('Console integraton aborted'); + context.isConsole = false; + return; + } + context.org = org; + }, + configuredQuestions: ['orgName'], +}; diff --git a/lib/cli/interactive-setup/console-setup-iam-role.js b/lib/cli/interactive-setup/console-setup-iam-role.js new file mode 100644 index 000000000000..25b341638c30 --- /dev/null +++ b/lib/cli/interactive-setup/console-setup-iam-role.js @@ -0,0 +1,143 @@ +'use strict'; + +const wait = require('timers-ext/promise/sleep'); +const { log, style, progress } = 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 { awsRequest } = require('./utils'); + +const iamRoleStackName = 'Serverless-Inc-Role-Stack'; +const cloudFormationServiceConfig = { name: 'CloudFormation', params: { region: 'us-east-1' } }; + +const waitUntilStackIsCreated = async (context) => { + await wait(2000); + const stackEvents = ( + await awsRequest(context, cloudFormationServiceConfig, 'describeStackEvents', { + StackName: iamRoleStackName, + }) + ).StackEvents; + const failedStatusReasons = stackEvents + .filter(({ ResourceStatus: status }) => { + return status && status.endsWith('_FAILED'); + }) + .map(({ ResourceStatusReason: reason }) => reason); + + if (failedStatusReasons.length) { + log.error(`Creating IAM Role failed:\n - ${failedStatusReasons.join('\n - ')}`); + return false; + } + const statusEvent = stackEvents.find( + ({ ResourceType: resourceType }) => resourceType === 'AWS::CloudFormation::Stack' + ); + const status = statusEvent ? statusEvent.ResourceStatus : null; + if (status && status.endsWith('_COMPLETE')) { + if (status === 'CREATE_COMPLETE') return true; + log.error('Creating IAM Role failed'); + return false; + } + return waitUntilStackIsCreated(context); +}; + +const waitUntilIntegrationIsReady = async (context) => { + await wait(2000); + const { integrations } = await apiRequest(`/api/integrations/?orgId=${context.org.orgId}`, { + urlName: 'integrationsBackend', + }); + if (integrations.some(({ vendorAccount }) => vendorAccount === context.awsAccountId)) return true; + return waitUntilIntegrationIsReady(context); +}; + +module.exports = { + async isApplicable(context) { + const { isConsole } = context; + + if (!isConsole) { + context.inapplicabilityReasonCode = 'NON_CONSOLE_CONTEXT'; + return false; + } + + if (!(await resolveAuthMode())) { + context.inapplicabilityReasonCode = 'NOT_LOGGED_IN'; + return false; + } + + if (!context.org) { + context.inapplicabilityReasonCode = 'UNRESOLVED_ORG'; + return false; + } + + const { integrations } = await apiRequest(`/api/integrations/?orgId=${context.org.orgId}`, { + urlName: 'integrationsBackend', + }); + + if (integrations.some(({ vendorAccount }) => vendorAccount === context.awsAccountId)) { + log.notice(); + log.notice.success('Your service is configured with Serverless Console'); + context.inapplicabilityReasonCode = 'INTEGRATED'; + return false; + } + return true; + }, + + async run(context) { + const { stepHistory } = 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', + `See the IAM Permissions transparently here: ${style.link( + 'https://slss.io/iam-role-permissions' + )}`, + ] + )}`, + type: 'confirm', + name: 'shouldSetupConsoleIamRole', + stepHistory, + })) + ) { + return false; + } + + log.notice(); + + const iamRoleCreationProgress = progress.get('iam-role-creation'); + iamRoleCreationProgress.notice('Creating IAM Role for Serverless Console'); + + try { + const { cfnTemplateUrl, params } = await apiRequest( + `/api/integrations/aws/initial?orgId=${context.org.orgId}`, + { urlName: 'integrationsBackend' } + ); + + await awsRequest(context, cloudFormationServiceConfig, 'createStack', { + Capabilities: ['CAPABILITY_NAMED_IAM'], + StackName: iamRoleStackName, + TemplateURL: cfnTemplateUrl, + Parameters: [ + { ParameterKey: 'AccountId', ParameterValue: params.accountId }, + { ParameterKey: 'ReportServiceToken', ParameterValue: params.reportServiceToken }, + { ParameterKey: 'ExternalId', ParameterValue: params.externalId }, + { ParameterKey: 'Version', ParameterValue: params.version }, + ], + }); + + if (!(await waitUntilStackIsCreated(context))) return false; + + iamRoleCreationProgress.notice('Enabling Serverless Console Integration'); + + await waitUntilIntegrationIsReady(context); + + log.notice.success('Your service is configured with Serverless Console'); + } finally { + iamRoleCreationProgress.remove(); + } + return true; + }, + configuredQuestions: ['shouldSetupConsoleIamRole'], +}; diff --git a/lib/cli/interactive-setup/dashboard-login.js b/lib/cli/interactive-setup/dashboard-login.js index bdb36261461b..968059a74606 100644 --- a/lib/cli/interactive-setup/dashboard-login.js +++ b/lib/cli/interactive-setup/dashboard-login.js @@ -25,9 +25,9 @@ const steps = { module.exports = { async isApplicable(context) { - const { isConsole, configuration, options, serviceDir } = context; + const { configuration, options, serviceDir } = context; - if (isConsole) { + if (options.console) { 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 3e2e5c0c0246..7568bdb0a4ce 100644 --- a/lib/cli/interactive-setup/dashboard-set-org.js +++ b/lib/cli/interactive-setup/dashboard-set-org.js @@ -193,14 +193,14 @@ const steps = { module.exports = { async isApplicable(context) { - const { configuration, options, serviceDir, isConsole } = context; + const { configuration, options, serviceDir } = context; if (!serviceDir) { context.inapplicabilityReasonCode = 'NOT_IN_SERVICE_DIRECTORY'; return false; } - if (isConsole) { + if (options.console) { context.inapplicabilityReasonCode = 'CONSOLE_CONTEXT'; return false; } diff --git a/lib/cli/interactive-setup/deploy.js b/lib/cli/interactive-setup/deploy.js index a9a8d23f5e0f..c1d2b29f0407 100644 --- a/lib/cli/interactive-setup/deploy.js +++ b/lib/cli/interactive-setup/deploy.js @@ -23,12 +23,17 @@ const printMessage = () => { module.exports = { async isApplicable(context) { - const { configuration, serviceDir, options } = context; + const { configuration, serviceDir, options, initial } = context; if (!serviceDir) { context.inapplicabilityReasonCode = 'NOT_IN_SERVICE_DIRECTORY'; return false; } + if (options.console && initial.isInServiceContext) { + context.inapplicabilityReasonCode = 'CONSOLE_INTEGRATION'; + return false; + } + if ( _.get(configuration, 'provider') !== 'aws' && _.get(configuration, 'provider.name') !== 'aws' diff --git a/lib/cli/interactive-setup/index.js b/lib/cli/interactive-setup/index.js index cf32558ad3b2..c3aaa92fc255 100644 --- a/lib/cli/interactive-setup/index.js +++ b/lib/cli/interactive-setup/index.js @@ -4,16 +4,27 @@ const inquirer = require('@serverless/utils/inquirer'); const { StepHistory } = require('@serverless/utils/telemetry'); const log = require('@serverless/utils/log').log.get('onboarding'); const { resolveInitialContext } = require('./utils'); +const awsRequest = require('../../aws/request'); const steps = { service: require('./service'), consoleLogin: require('./console-login'), dashboardLogin: require('./dashboard-login'), + consoleResolveOrg: require('./console-resolve-org'), + consoleSetupIamRole: require('./console-setup-iam-role'), dashboardSetOrg: require('./dashboard-set-org'), awsCredentials: require('./aws-credentials'), deploy: require('./deploy'), }; +const resolveAwsAccountId = async () => { + try { + return (await awsRequest('STS', 'getCallerIdentity')).Account; + } catch { + return null; + } +}; + module.exports = async (context) => { const stepsDetails = new Map( Object.entries(steps).map(([stepName, step]) => { @@ -39,7 +50,18 @@ module.exports = async (context) => { const initialContext = resolveInitialContext(context); commandUsage.initialContext = initialContext; context.initial = initialContext; - context.isConsole = Boolean(options.console); + context.awsAccountId = await resolveAwsAccountId(); + + if (options.console) { + if (!context.awsAccountId) { + log.error( + 'We’re unable to connect Console via the CLI - No local AWS credentials found\n' + + 'Visit https://console.serverless.com/ to set up Console from the web' + ); + } else { + context.isConsole = true; + } + } for (const [stepName, step] of Object.entries(steps)) { delete context.stepHistory; diff --git a/lib/cli/interactive-setup/utils.js b/lib/cli/interactive-setup/utils.js index 561ef87ffeed..cad88eea3982 100644 --- a/lib/cli/interactive-setup/utils.js +++ b/lib/cli/interactive-setup/utils.js @@ -7,6 +7,7 @@ const { log, style } = require('@serverless/utils/log'); const resolveProviderCredentials = require('@serverless/dashboard-plugin/lib/resolve-provider-credentials'); const isAuthenticated = require('@serverless/dashboard-plugin/lib/is-authenticated'); const hasLocalCredentials = require('../../aws/has-local-credentials'); +const awsRequest = require('../../aws/request'); const fsp = require('fs').promises; @@ -48,6 +49,14 @@ module.exports = { return Boolean(result); }, + awsRequest: async ({ serverless }, serviceConfig, method, params) => { + const awsProvider = serverless && serverless.getProvider('aws'); + if (awsProvider) { + // This method supports only direct service name input + return awsProvider.request(serviceConfig.name || serviceConfig, method, params); + } + return awsRequest(serviceConfig, method, params); + }, resolveInitialContext: ({ configuration, serviceDir }) => { return { isInServiceContext: Boolean(serviceDir), @@ -105,8 +114,8 @@ module.exports = { log.notice(style.aside('Learn more at https://serverless.com/console')); } else { log.notice(`Onboarding "${context.configuration.service}" to the Serverless Dashboard`); + log.notice(); } - log.notice(); }, { length: 0 } ), diff --git a/lib/plugins/aws/info/display.js b/lib/plugins/aws/info/display.js index 32ee7c1df0fc..2c109f51f1b1 100644 --- a/lib/plugins/aws/info/display.js +++ b/lib/plugins/aws/info/display.js @@ -1,16 +1,58 @@ 'use strict'; -const filesize = require('../../../utils/filesize'); const { isVerboseMode, style } = require('@serverless/utils/log'); +const apiRequest = require('@serverless/utils/api-request'); +const resolveAuthMode = require('@serverless/utils/auth/resolve-mode'); +const urls = require('@serverless/utils/lib/auth/urls'); + +const filesize = require('../../../utils/filesize'); module.exports = { - displayServiceInfo() { + async resolveConsoleUrl() { + if (!(await resolveAuthMode())) return null; + const awsAccountId = await (async () => { + try { + return (await this.provider.request('STS', 'getCallerIdentity')).Account; + } catch { + return null; + } + })(); + if (!awsAccountId) return null; + + const { userId } = await apiRequest('/api/identity/me'); + const { orgs } = await apiRequest(`/api/identity/users/${userId}/orgs`); + + const org = ( + await Promise.all( + orgs.map(async (orgCandidate) => { + return ( + await apiRequest(`/api/integrations/?orgId=${orgCandidate.orgId}`, { + urlName: 'integrationsBackend', + }) + ).integrations.some(({ vendorAccount }) => vendorAccount === awsAccountId) + ? orgCandidate + : null; + }) + ) + ).filter(Boolean)[0]; + + if (!org) return false; + return `${urls.frontend}/${ + org.orgName + }/metrics/awsLambda?globalEnvironments=${this.provider.getStage()}&globalNamespace=${ + this.serverless.service.service + }&globalRegions=${this.provider.getRegion()}&globalScope=awsLambda&globalTimeFrame=15m`; + }, + + async displayServiceInfo() { if (this.serverless.processedInput.commands.join(' ') === 'info') { this.serverless.serviceOutputs.set('service', this.serverless.service.service); this.serverless.serviceOutputs.set('stage', this.provider.getStage()); this.serverless.serviceOutputs.set('region', this.provider.getRegion()); this.serverless.serviceOutputs.set('stack', this.provider.naming.getStackName()); } + const consoleUrl = await this.resolveConsoleUrl(); + if (consoleUrl) this.serverless.serviceOutputs.set('console', consoleUrl); }, displayApiKeys() { diff --git a/lib/utils/telemetry/generate-payload.js b/lib/utils/telemetry/generate-payload.js index 812f8027b312..e8500d01b0c8 100644 --- a/lib/utils/telemetry/generate-payload.js +++ b/lib/utils/telemetry/generate-payload.js @@ -138,6 +138,7 @@ module.exports = ({ serverless, commandUsage, variableSources, + isConsoleAuthenticated, }) => { let commandDurationMs; @@ -199,6 +200,9 @@ module.exports = ({ cliName: 'serverless', command, commandOptionNames, + console: { + isAuthenticated: isConsoleAuthenticated, + }, dashboard: { userId, }, diff --git a/package.json b/package.json index 1c1d022a5350..b047c84e3e66 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "dependencies": { "@serverless/dashboard-plugin": "^6.2.2", "@serverless/platform-client": "^4.3.2", - "@serverless/utils": "^6.7.0", + "@serverless/utils": "^6.8.0", "ajv": "^8.11.0", "ajv-formats": "^2.1.1", "archiver": "^5.3.1", diff --git a/scripts/serverless.js b/scripts/serverless.js index 3a03a2ec7f44..333adf0aa192 100755 --- a/scripts/serverless.js +++ b/scripts/serverless.js @@ -22,6 +22,7 @@ const { const generateTelemetryPayload = require('../lib/utils/telemetry/generate-payload'); const isTelemetryDisabled = require('../lib/utils/telemetry/are-disabled'); const logDeprecation = require('../lib/utils/log-deprecation'); +const resolveConsoleAuthMode = require('@serverless/utils/auth/resolve-mode'); let command; let isHelpRequest; @@ -30,6 +31,7 @@ let commandSchema; let serviceDir = null; let configuration = null; let serverless; +let isConsoleAuthenticated = false; const commandUsage = {}; const variableSourcesInConfig = new Set(); @@ -57,7 +59,15 @@ const finalize = async ({ error, shouldBeSync, telemetryData, shouldSendTelemetr clearTimeout(keepAliveTimer); progress.clear(); if (error) ({ telemetryData } = await handleError(error, { serverless })); - if (!shouldBeSync) await logDeprecation.printSummary(); + if (!shouldBeSync) { + await logDeprecation.printSummary(); + await resolveConsoleAuthMode().then( + (mode) => { + isConsoleAuthenticated = Boolean(mode); + }, + () => {} + ); + } if (isTelemetryDisabled || !commandSchema) return null; if (!error && isHelpRequest) return null; storeTelemetryLocally({ @@ -70,6 +80,7 @@ const finalize = async ({ error, shouldBeSync, telemetryData, shouldSendTelemetr serverless, commandUsage, variableSources: variableSourcesInConfig, + isConsoleAuthenticated, }), ...telemetryData, }); @@ -95,6 +106,12 @@ processSpanPromise = (async () => { const wait = require('timers-ext/promise/sleep'); await wait(); // Ensure access to "processSpanPromise" + resolveConsoleAuthMode().then( + (mode) => { + isConsoleAuthenticated = Boolean(mode); + }, + () => {} + ); require('signal-exit/signals').forEach((signal) => { process.once(signal, () => { processLog.debug('exit signal %s', signal); diff --git a/test/fixtures/programmatic/aws-loggedin-console-monitored-service/.serverlessrc b/test/fixtures/programmatic/aws-loggedin-console-monitored-service/.serverlessrc deleted file mode 100644 index 43b8223bc1ce..000000000000 --- a/test/fixtures/programmatic/aws-loggedin-console-monitored-service/.serverlessrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "frameworkId": "00000000-0000-0000-0000-000000000000", - "meta": { - "created_at": 1560000000, - "updated_at": 1560000000 - }, - "auth": { - "refreshToken": "foo", - "idToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTTFMiLCJpYXQiOjE2MTE3NDAzNTgsImV4cCI6MTk1ODgwOTE1OCwiYXVkIjoic2xzIiwic3ViIjoic2xzQHNscy5jb20ifQ.fy_DY4cWWADDREVYrSy3U5-p7cKT4evEOCjQtQJl9ww" - } -} diff --git a/test/fixtures/programmatic/aws-loggedin-console-monitored-service/serverless.yml b/test/fixtures/programmatic/aws-loggedin-console-monitored-service/serverless.yml deleted file mode 100644 index fbae0701fe24..000000000000 --- a/test/fixtures/programmatic/aws-loggedin-console-monitored-service/serverless.yml +++ /dev/null @@ -1,5 +0,0 @@ -service: 'some-aws-service' -provider: 'aws' - -org: 'testinteractivecli' -console: true diff --git a/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/.serverlessrc b/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/.serverlessrc deleted file mode 100644 index 43b8223bc1ce..000000000000 --- a/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/.serverlessrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "frameworkId": "00000000-0000-0000-0000-000000000000", - "meta": { - "created_at": 1560000000, - "updated_at": 1560000000 - }, - "auth": { - "refreshToken": "foo", - "idToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTTFMiLCJpYXQiOjE2MTE3NDAzNTgsImV4cCI6MTk1ODgwOTE1OCwiYXVkIjoic2xzIiwic3ViIjoic2xzQHNscy5jb20ifQ.fy_DY4cWWADDREVYrSy3U5-p7cKT4evEOCjQtQJl9ww" - } -} diff --git a/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/serverless.yml b/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/serverless.yml deleted file mode 100644 index a1d8adc90ff6..000000000000 --- a/test/fixtures/programmatic/aws-loggedin-console-wrongorg-service/serverless.yml +++ /dev/null @@ -1,5 +0,0 @@ -service: 'some-aws-service' -provider: 'aws' - -org: 'some-other' -console: true diff --git a/test/unit/lib/cli/interactive-setup/console-login.test.js b/test/unit/lib/cli/interactive-setup/console-login.test.js index d50fd5a8ee78..ba57b2d61a1c 100644 --- a/test/unit/lib/cli/interactive-setup/console-login.test.js +++ b/test/unit/lib/cli/interactive-setup/console-login.test.js @@ -37,34 +37,6 @@ describe('test/unit/lib/cli/interactive-setup/console-login.test.js', function ( expect(context.inapplicabilityReasonCode).to.equal('NOT_IN_SERVICE_DIRECTORY'); }); - it('Should be ineffective, when not at AWS service path', async () => { - const context = { - isConsole: true, - serviceDir: process.cwd(), - configuration: {}, - configurationFilename: 'serverless.yml', - options: { console: true }, - initial: {}, - inquirer, - }; - expect(await step.isApplicable(context)).to.equal(false); - expect(context.inapplicabilityReasonCode).to.equal('NON_AWS_PROVIDER'); - }); - - it('Should be ineffective, when not at supported runtime service path', async () => { - const context = { - isConsole: true, - serviceDir: process.cwd(), - configuration: { provider: { name: 'aws', runtime: 'java8' } }, - configurationFilename: 'serverless.yml', - options: { console: true }, - initial: {}, - inquirer, - }; - expect(await step.isApplicable(context)).to.equal(false); - expect(context.inapplicabilityReasonCode).to.equal('UNSUPPORTED_RUNTIME'); - }); - it('Should be ineffective, when logged in', async () => { const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( 'aws-loggedin-console-service' diff --git a/test/unit/lib/cli/interactive-setup/console-resolve-org.test.js b/test/unit/lib/cli/interactive-setup/console-resolve-org.test.js new file mode 100644 index 000000000000..b844508eb655 --- /dev/null +++ b/test/unit/lib/cli/interactive-setup/console-resolve-org.test.js @@ -0,0 +1,218 @@ +'use strict'; + +const chai = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); +const overrideCwd = require('process-utils/override-cwd'); +const overrideEnv = require('process-utils/override-env'); +const configureInquirerStub = require('@serverless/test/configure-inquirer-stub'); +const { StepHistory } = require('@serverless/utils/telemetry'); +const inquirer = require('@serverless/utils/inquirer'); + +const fixtures = require('../../../../fixtures/programmatic'); + +const { expect } = chai; + +chai.use(require('chai-as-promised')); + +describe('test/unit/lib/cli/interactive-setup/console-resolve-org.test.js', function () { + this.timeout(1000 * 60 * 3); + + let step; + let authMode = 'user'; + let mockOrgNames = []; + + before(() => { + step = proxyquire('../../../../../lib/cli/interactive-setup/console-resolve-org', { + '@serverless/utils/auth/resolve-mode': async () => authMode, + '@serverless/utils/api-request': async (pathname) => { + if (pathname === '/api/identity/me') return { userId: 'user' }; + if (pathname === '/api/identity/users/user/orgs') { + return { orgs: mockOrgNames.map((orgName) => ({ orgName })) }; + } + throw new Error(`Unexpected pathname "${pathname}"`); + }, + }); + }); + + after(() => { + sinon.restore(); + }); + + afterEach(async () => { + mockOrgNames = []; + authMode = 'user'; + sinon.reset(); + }); + + it('Should be ineffective, when --console not passed', async () => { + const context = { + initial: {}, + options: {}, + }; + expect(await step.isApplicable(context)).to.be.false; + expect(context.inapplicabilityReasonCode).to.equal('NON_CONSOLE_CONTEXT'); + }); + + it('Should be ineffective, when not logged in', async () => { + const context = { + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + }; + authMode = null; + expect(await step.isApplicable(context)).to.equal(false); + expect(context.inapplicabilityReasonCode).to.equal('NOT_LOGGED_IN'); + }); + + it('Should be ineffective, when no orgs are resolved', async () => { + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + }; + await overrideCwd(serviceDir, async () => { + expect(await step.isApplicable(context)).to.be.false; + }); + expect(context.inapplicabilityReasonCode).to.equal('NO_ORGS_AVAILABLE'); + }); + + describe('Monitoring setup', () => { + it('Should recognize and skip, when single org is assigned to the account', async () => { + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + }; + mockOrgNames = ['testinteractivecli']; + await overrideCwd(serviceDir, async () => { + expect(await step.isApplicable(context)).to.be.false; + }); + expect(context.inapplicabilityReasonCode).to.equal('ONLY_ORG'); + expect(context.org).to.deep.equal({ orgName: 'testinteractivecli' }); + }); + + it('Should ask for org if passed in one is invalid', async () => { + configureInquirerStub(inquirer, { + list: { orgName: 'testinteractivecli' }, + }); + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true, org: 'foo' }, + isConsole: true, + initial: {}, + inquirer, + stepHistory: new StepHistory(), + }; + mockOrgNames = ['testinteractivecli']; + await overrideCwd(serviceDir, async () => { + const stepData = await step.isApplicable(context); + if (!stepData) throw new Error('Step resolved as not applicable'); + await step.run(context, stepData); + }); + expect(Array.from(context.stepHistory.valuesMap())).to.deep.equal( + Array.from(new Map([['orgName', '_user_choice_']])) + ); + expect(context.org).to.deep.equal({ orgName: 'testinteractivecli' }); + }); + + it('Should setup monitoring for chosen org', async () => { + configureInquirerStub(inquirer, { + list: { orgName: 'testinteractivecli' }, + }); + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + stepHistory: new StepHistory(), + }; + + mockOrgNames = ['testinteractivecli', 'someotherorg']; + await overrideCwd(serviceDir, async () => { + const stepData = await step.isApplicable(context); + if (!stepData) throw new Error('Step resolved as not applicable'); + await step.run(context, stepData); + }); + expect(context.stepHistory.valuesMap()).to.deep.equal( + new Map([['orgName', '_user_choice_']]) + ); + expect(context.org).to.deep.equal({ orgName: 'testinteractivecli' }); + }); + + it('Should setup monitoring for org based on access key', async () => { + configureInquirerStub(inquirer, { + list: { orgName: 'fromaccesskey' }, + }); + const context = { + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + stepHistory: new StepHistory(), + }; + authMode = 'org'; + // TODO: Decide whether that test needs to stay + mockOrgNames = ['fromaccesskey']; + await overrideEnv({ variables: { SLS_ORG_TOKEN: 'token' } }, async () => { + expect(await step.isApplicable(context)).to.be.false; + }); + expect(context.inapplicabilityReasonCode).to.equal('ONLY_ORG'); + expect(context.org).to.deep.equal({ orgName: 'fromaccesskey' }); + }); + + it('Should allow to skip setting monitoring when selecting org', async () => { + configureInquirerStub(inquirer, { + list: { orgName: '_skip_' }, + }); + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + stepHistory: new StepHistory(), + }; + mockOrgNames = ['testinteractivecli', 'someother']; + await overrideCwd(serviceDir, async () => { + const stepData = await step.isApplicable(context); + if (!stepData) throw new Error('Step resolved as not applicable'); + await step.run(context, stepData); + }); + + expect(context.stepHistory.valuesMap()).to.deep.equal(new Map([['orgName', '_skip_']])); + expect(context).to.not.have.property('org'); + }); + }); +}); diff --git a/test/unit/lib/cli/interactive-setup/console-setup-iam-role.test.js b/test/unit/lib/cli/interactive-setup/console-setup-iam-role.test.js new file mode 100644 index 000000000000..912a40a86770 --- /dev/null +++ b/test/unit/lib/cli/interactive-setup/console-setup-iam-role.test.js @@ -0,0 +1,218 @@ +'use strict'; + +const chai = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); +const wait = require('timers-ext/promise/sleep'); +const overrideCwd = require('process-utils/override-cwd'); +const inquirer = require('@serverless/utils/inquirer'); +const { StepHistory } = require('@serverless/utils/telemetry'); +const configureInquirerStub = require('@serverless/test/configure-inquirer-stub'); + +const fixtures = require('../../../../fixtures/programmatic'); + +const { expect } = chai; + +chai.use(require('chai-as-promised')); + +describe('test/unit/lib/cli/interactive-setup/console-setup-iam-role.test.js', function () { + this.timeout(1000 * 60 * 3); + + let step; + let authMode = 'user'; + let mockOrgNames = []; + let isIntegrated = false; + let stackCreationOutcome = 'success'; + + before(() => { + step = proxyquire('../../../../../lib/cli/interactive-setup/console-setup-iam-role', { + '@serverless/utils/auth/resolve-mode': async () => authMode, + '@serverless/utils/api-request': async (pathname) => { + if (pathname === '/api/identity/me') return { userId: 'user' }; + if (pathname === '/api/identity/users/user/orgs') { + return { orgs: mockOrgNames.map((orgName) => ({ orgName })) }; + } + if (pathname === '/api/integrations/?orgId=integrated') { + return { integrations: [{ vendorAccount: '12345' }] }; + } + if (pathname === '/api/integrations/?orgId=tobeintegrated') { + return { integrations: isIntegrated ? [{ vendorAccount: '12345' }] : [] }; + } + if (pathname.startsWith('/api/integrations/aws/initial?orgId=')) { + return { cfnTemplateUrl: 'someUrl', params: {} }; + } + + throw new Error(`Unexpected pathname "${pathname}"`); + }, + './utils': { + awsRequest: async (ingore, serviceConfig, method) => { + switch (method) { + case 'createStack': + return {}; + case 'describeStackEvents': + switch (stackCreationOutcome) { + case 'success': + return { + StackEvents: [ + { + EventId: 'fdc5ed10-4a41-11ed-b15b-12151bc3f4d1', + StackName: 'test', + LogicalResourceId: 'test', + ResourceType: 'AWS::CloudFormation::Stack', + ResourceStatus: 'CREATE_COMPLETE', + }, + ], + }; + case 'failure': + return { + StackEvents: [ + { + EventId: 'fdc5ed10-4a41-11ed-b15b-12151bc3f4d1', + StackName: 'test', + LogicalResourceId: 'test', + ResourceType: 'AWS::Lambda::Function', + ResourceStatus: 'CREATE_FAILED', + ResourceStatusReason: 'Cannot create due to some reason', + }, + ], + }; + default: + throw new Error(`Unexpexted stack creation outcome: ${stackCreationOutcome}`); + } + default: + throw new Error(`Unexpexted AWS method: ${method}`); + } + }, + }, + }); + }); + + after(() => { + sinon.restore(); + }); + + afterEach(async () => { + mockOrgNames = []; + authMode = 'user'; + stackCreationOutcome = 'success'; + isIntegrated = false; + sinon.reset(); + }); + + it('Should be ineffective, when --console not passed', async () => { + const context = { + initial: {}, + options: {}, + }; + expect(await step.isApplicable(context)).to.be.false; + expect(context.inapplicabilityReasonCode).to.equal('NON_CONSOLE_CONTEXT'); + }); + + it('Should be ineffective, when not logged in', async () => { + const context = { + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + }; + authMode = null; + expect(await step.isApplicable(context)).to.equal(false); + expect(context.inapplicabilityReasonCode).to.equal('NOT_LOGGED_IN'); + }); + + it('Should be ineffective, when no org is resolved', async () => { + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + }; + await overrideCwd(serviceDir, async () => { + expect(await step.isApplicable(context)).to.be.false; + }); + expect(context.inapplicabilityReasonCode).to.equal('UNRESOLVED_ORG'); + }); + + it('Should be ineffective, when logged in account is already integrated', async () => { + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + org: { orgId: 'integrated' }, + awsAccountId: '12345', + }; + await overrideCwd(serviceDir, async () => { + expect(await step.isApplicable(context)).to.be.false; + }); + expect(context.inapplicabilityReasonCode).to.equal('INTEGRATED'); + }); + + it('Should setup integration', async () => { + configureInquirerStub(inquirer, { + confirm: { shouldSetupConsoleIamRole: true }, + }); + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + org: { orgId: 'tobeintegrated' }, + awsAccountId: '12345', + stepHistory: new StepHistory(), + }; + await overrideCwd(serviceDir, async () => { + const stepData = await step.isApplicable(context); + if (!stepData) throw new Error('Step resolved as not applicable'); + const deferredRun = step.run(context, stepData); + await wait(1000); + isIntegrated = true; + expect(await deferredRun).to.be.true; + }); + }); + + it('Should abort gently if CF deployment fails', async () => { + configureInquirerStub(inquirer, { + confirm: { shouldSetupConsoleIamRole: true }, + }); + const { servicePath: serviceDir, serviceConfig: configuration } = await fixtures.setup( + 'aws-loggedin-console-service' + ); + const context = { + serviceDir, + configuration, + configurationFilename: 'serverless.yml', + options: { console: true }, + isConsole: true, + initial: {}, + inquirer, + org: { orgId: 'tobeintegrated' }, + awsAccountId: '12345', + stepHistory: new StepHistory(), + }; + await overrideCwd(serviceDir, async () => { + const stepData = await step.isApplicable(context); + if (!stepData) throw new Error('Step resolved as not applicable'); + stackCreationOutcome = 'failure'; + expect(await step.run(context, stepData)).to.be.false; + }); + }); +}); 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 99d24bb1e9fb..f51ea2c613f1 100644 --- a/test/unit/lib/cli/interactive-setup/dashboard-login.test.js +++ b/test/unit/lib/cli/interactive-setup/dashboard-login.test.js @@ -54,13 +54,13 @@ describe('test/unit/lib/cli/interactive-setup/dashboard-login.test.js', function }); it('Should be ineffective in console context', async () => { - const context = { isConsole: true }; + 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 = {}; + const context = { options: {} }; expect(await step.isApplicable(context)).to.be.false; expect(context.inapplicabilityReasonCode).to.equal('NOT_IN_SERVICE_DIRECTORY'); }); diff --git a/test/unit/lib/cli/interactive-setup/index.test.js b/test/unit/lib/cli/interactive-setup/index.test.js index 685eb063153f..69a1b0405a29 100644 --- a/test/unit/lib/cli/interactive-setup/index.test.js +++ b/test/unit/lib/cli/interactive-setup/index.test.js @@ -12,7 +12,7 @@ describe('test/unit/lib/cli/interactive-setup/index.test.js', () => { it('should configure interactive setup flow', async () => { const slsProcessPromise = spawn( 'node', - [serverlessPath, '--console', '--template-path', path.join(fixturesPath, 'aws')], + [serverlessPath, '--template-path', path.join(fixturesPath, 'aws')], { env: { ...process.env, @@ -31,9 +31,9 @@ describe('test/unit/lib/cli/interactive-setup/index.test.js', () => { input: 'interactive-setup-test', }, - // console-login + // dashboard-login { - instructionString: 'Do you want to login/register to Serverless Console?', + instructionString: 'Do you want to login/register to Serverless Dashboard?', input: 'n', // Move cursor down by one line },