Skip to content

Commit

Permalink
feat(Console): External Console integration
Browse files Browse the repository at this point in the history
  • Loading branch information
medikoo committed Oct 17, 2022
1 parent 0ae4a30 commit bb7444c
Show file tree
Hide file tree
Showing 21 changed files with 782 additions and 99 deletions.
26 changes: 3 additions & 23 deletions 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,
Expand All @@ -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';
Expand All @@ -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);
},
Expand Down
85 changes: 85 additions & 0 deletions 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'],
};
143 changes: 143 additions & 0 deletions 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'],
};
4 changes: 2 additions & 2 deletions lib/cli/interactive-setup/dashboard-login.js
Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions lib/cli/interactive-setup/dashboard-set-org.js
Expand Up @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion lib/cli/interactive-setup/deploy.js
Expand Up @@ -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'
Expand Down
24 changes: 23 additions & 1 deletion lib/cli/interactive-setup/index.js
Expand Up @@ -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]) => {
Expand All @@ -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;
Expand Down

0 comments on commit bb7444c

Please sign in to comment.