serverless/lib/classes/console.js

359 lines
13 KiB
JavaScript

'use strict';
const _ = require('lodash');
const d = require('d');
const lazy = require('d/lazy');
const path = require('path');
const fsp = require('fs').promises;
const fetch = require('node-fetch');
const filesize = require('filesize');
const log = require('@serverless/utils/log').log.get('console');
const isAuthenticated = require('@serverless/dashboard-plugin/lib/is-authenticated');
const { getPlatformClientWithAccessKey } = require('@serverless/dashboard-plugin/lib/client-utils');
const ServerlessError = require('../serverless-error');
const { setBucketName } = require('../plugins/aws/lib/set-bucket-name');
const { uploadZipFile } = require('../plugins/aws/lib/upload-zip-file');
const supportedCommands = new Set(['deploy', 'deploy function', 'package', 'remove', 'rollback']);
const devVersionTimeBase = new Date(2022, 1, 17).getTime();
class Console {
constructor(serverless) {
this.serverless = serverless;
// Used to confirm that we obtained compatible console state data for deployment
this.stateSchemaVersion = '1';
}
async initialize() {
this.isEnabled = (() => {
const {
configurationInput: configuration,
processedInput: { commands, options },
} = this.serverless;
if (!_.get(configuration, 'console')) return false;
this.org = options.org || configuration.org;
if (!this.org) return false;
const command = commands.join(' ');
if (!supportedCommands.has(command)) return false;
const providerName = configuration.provider.name || configuration.provider;
if (providerName !== 'aws') {
log.error(`Provider "${providerName}" is currently not supported by the console`);
return false;
}
if (command !== 'rollback' && (command !== 'deploy' || !options.package)) {
if (
!Object.values(this.serverless.service.functions).some((functionConfig) =>
this.isFunctionSupported(functionConfig)
)
) {
log.warning(
"Cannot enable console: Service doesn't configure any function with the supported runtime"
);
return false;
}
}
return true;
})();
if (!this.isEnabled) return;
if (!isAuthenticated()) {
const errorMessage = process.env.CI
? 'You are not currently logged in. Follow instructions in http://slss.io/run-in-cicd to setup env vars for authentication.'
: 'You are not currently logged in. To log in, run "serverless login"';
throw new ServerlessError(errorMessage, 'CONSOLE_NOT_AUTHENTICATED');
}
this.packagePath =
this.serverless.processedInput.options.package ||
this.serverless.service.package.path ||
path.join(this.serverless.serviceDir, '.serverless');
this.provider = this.serverless.getProvider('aws');
this.sdk = await getPlatformClientWithAccessKey(this.org);
this.orgId = (await this.sdk.getOrgByName(this.org)).orgUid;
this.service = this.serverless.service.service;
this.stage = this.provider.getStage();
this.otelIngestionUrl = (() => {
if (process.env.SLS_CONSOLE_OTEL_INGESTION_URL) {
return process.env.SLS_CONSOLE_OTEL_INGESTION_URL;
}
return process.env.SERVERLESS_PLATFORM_STAGE === 'dev'
? 'https://core.serverless-dev.com/ingestion/kinesis'
: 'https://core.serverless.com/ingestion/kinesis';
})();
}
isFunctionSupported({ handler, runtime }) {
if (!handler) return false; // Docker container image (not supported yet)
if (!runtime) return true; // Default is supported nodejs runtime
return runtime.startsWith('nodejs');
}
async createOtelIngestionToken() {
const url = `${this.otelIngestionUrl}/org/${this.orgId}/service/${this.service}/stage/${this.stage}`;
const data = {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.sdk.accessKey}`,
'Content-Type': 'application/json',
},
};
log.debug('get token: %s %o', url, data);
const response = await fetch(url, data);
if (!response.ok) {
throw new ServerlessError(
`Cannot deploy to the Console: Cannot retrieve token (${
response.status
}: ${await response.text()})`,
'CONSOLE_TOKEN_GENERATION_FAILURE'
);
}
const responseBody = await response.json();
log.debug('get token response: %o', responseBody);
if (!_.get(responseBody, 'token.accessToken')) {
throw new Error(
`Cannot deploy to the Console: Unexpected server response (${JSON.stringify(responseBody)})`
);
}
if (responseBody.status === 'new_token') {
this.isFreshOtelIngestionToken = true;
if (this.serverless.processedInput.commands.join(' ') === 'deploy') {
log.info(
'Generated a new access token for the Console (deployment may take longer than' +
' anticipated as the configuration of all functions will be updated)'
);
}
}
return responseBody.token.accessToken;
}
async activateOtelIngestionToken() {
const url = `${this.otelIngestionUrl}/token`;
const data = {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${this.sdk.accessKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
orgId: this.orgId,
serviceId: this.service,
stage: this.stage,
token: await this.deferredOtelIngestionToken,
}),
};
log.debug('activate token %s %o', url, data);
const response = await fetch(url, data);
if (!response.ok) {
throw new ServerlessError(
`Cannot deploy to the Console: Cannot activate token (${
response.status
}: ${await response.text()})`,
'CONSOLE_TOKEN_CREATION_FAILURE'
);
}
await response.text();
}
async deactivateOtherOtelIngestionTokens() {
const searchParams = new URLSearchParams();
searchParams.set('orgId', this.orgId);
searchParams.set('serviceId', this.service);
searchParams.set('stage', this.stage);
searchParams.set('token', await this.deferredOtelIngestionToken);
const url = `${this.otelIngestionUrl}/tokens?${searchParams}`;
const data = {
method: 'DELETE',
headers: {
Authorization: `Bearer ${this.sdk.accessKey}`,
},
};
log.debug('deactivate other tokens %s %o', url, data);
const response = await fetch(url, data);
if (!response.ok) {
log.error(
'Console communication problem ' +
'when deactivating no longer used otel ingestion tokens: %d %s',
response.status,
await response.text()
);
return;
}
await response.text();
}
async deactivateAllOtelIngestionTokens() {
const searchParams = new URLSearchParams();
searchParams.set('orgId', this.orgId);
searchParams.set('serviceId', this.service);
searchParams.set('stage', this.stage);
const url = `${this.otelIngestionUrl}/tokens?${searchParams}`;
const data = {
method: 'DELETE',
headers: {
Authorization: `Bearer ${this.sdk.accessKey}`,
},
};
log.debug('deactivate all tokens %s %o', url, data);
const response = await fetch(url, data);
if (!response.ok) {
log.error(
'Console communication problem when deactivating otel ingestion tokens: %d %s',
response.status,
await response.text()
);
return;
}
await response.text();
}
async deactivateOtelIngestionToken() {
const url = `${this.otelIngestionUrl}/token?token=${await this.deferredOtelIngestionToken}`;
const data = {
method: 'DELETE',
headers: {
Authorization: `Bearer ${this.sdk.accessKey}`,
},
};
log.debug('deactivate token %s %o', url, data);
const response = await fetch(url, data);
if (!response.ok) {
log.error(
'Console communication problem when deactivating otel ingestion token: %d %s',
response.status,
await response.text()
);
return;
}
await response.text();
}
overrideSettings({ otelIngestionToken, extensionLayerVersionPostfix, service, stage }) {
Object.defineProperties(this, {
deferredOtelIngestionToken: d(Promise.resolve(otelIngestionToken)),
extensionLayerVersionPostfix: d(extensionLayerVersionPostfix),
service: d('cew', service),
stage: d('cew', stage),
});
}
compileOtelExtensionLayer() {
log.debug('compile extension resource (%s)', this.extensionLayerName);
this.serverless.service.provider.compiledCloudFormationTemplate.Resources[
this.provider.naming.getConsoleExtensionLayerLogicalId()
] = {
Type: 'AWS::Lambda::LayerVersion',
Properties: {
Content: {
S3Bucket: { Ref: 'ServerlessDeploymentBucket' },
S3Key: `${this.serverless.service.package.artifactsS3KeyDirname}/${this.extensionLayerFilename}`,
},
LayerName: this.extensionLayerName,
},
};
}
async packageOtelExtensionLayer() {
log.debug('copy extension file (%s) to package directory', this.extensionLayerFilename);
await fsp.copyFile(
require.resolve('@serverless/aws-lambda-otel-extension-dist/extension.zip'),
path.join(this.serverless.serviceDir, '.serverless', this.extensionLayerFilename)
);
}
async ensureLayerVersion() {
let layerVersionMeta = (
await this.provider.request('Lambda', 'listLayerVersions', {
LayerName: this.extensionLayerName,
})
).LayerVersions[0];
if (!layerVersionMeta) {
log.debug('publish layer version (%s)', this.extensionLayerName);
await this.uploadOtelExtensionLayer({ readFromTheSource: true });
await setBucketName.call(this);
await this.provider.request('Lambda', 'publishLayerVersion', {
LayerName: this.extensionLayerName,
Content: {
S3Bucket: this.bucketName,
S3Key: `${this.serverless.service.package.artifactsS3KeyDirname}/${this.extensionLayerFilename}`,
},
});
layerVersionMeta = (
await this.provider.request('Lambda', 'listLayerVersions', {
LayerName: this.extensionLayerName,
})
).LayerVersions[0];
} else {
log.debug('layer version already published (%s)', this.extensionLayerName);
}
log.debug('retrieved layer version arn (%s)', layerVersionMeta.LayerVersionArn);
return layerVersionMeta.LayerVersionArn;
}
async uploadOtelExtensionLayer(options = {}) {
log.debug(
'check if extension file (%s) is already uploaded to S3',
this.extensionLayerFilename
);
await setBucketName.call(this);
try {
await this.provider.request('S3', 'headObject', {
Bucket: this.bucketName,
Key: `${this.serverless.service.package.artifactsS3KeyDirname}/${this.extensionLayerFilename}`,
});
// Extension layer is already available at S3, skip
log.debug('extension file is already uploaded to S3');
return;
} catch (error) {
if (error.code !== 'AWS_S3_HEAD_OBJECT_NOT_FOUND') throw error;
const filename = options.readFromTheSource
? require.resolve('@serverless/aws-lambda-otel-extension-dist/extension.zip')
: path.join(this.packagePath, this.extensionLayerFilename);
const stats = await fsp.stat(filename);
log.info(`Uploading console otel extension file to S3 (${filesize(stats.size)})`);
await uploadZipFile.call(this, {
filename,
s3KeyDirname: this.serverless.service.package.artifactsS3KeyDirname,
basename: this.extensionLayerFilename,
});
}
}
}
Object.defineProperties(
Console.prototype,
lazy({
deferredFunctionEnvironmentVariables: d(function () {
return this.deferredOtelIngestionToken.then((otelIngestionToken) => {
const result = {
SLS_OTEL_REPORT_REQUEST_HEADERS: `serverless_token=${otelIngestionToken}`,
SLS_OTEL_REPORT_METRICS_URL: `${this.otelIngestionUrl}/v1/metrics`,
SLS_OTEL_REPORT_TRACES_URL: `${this.otelIngestionUrl}/v1/traces`,
OTEL_RESOURCE_ATTRIBUTES: `sls_service_name=${this.service},sls_stage=${this.stage},sls_org_id=${this.orgId}`,
AWS_LAMBDA_EXEC_WRAPPER: '/opt/otel-extension/otel-handler',
};
if (process.env.SLS_OTEL_LAYER_DEV_BUILD) result.DEBUG_SLS_OTEL_LAYER = '1';
return result;
});
}),
deferredOtelIngestionToken: d(function () {
return this.createOtelIngestionToken();
}),
extensionLayerName: d(function () {
return `sls-console-otel-extension-${this.extensionLayerVersionPostfix.replace(/\./g, '-')}`;
}),
extensionLayerFilename: d(function () {
return `sls-otel.${this.extensionLayerVersionPostfix}.zip`;
}),
extensionLayerVersionPostfix: d(() => {
if (process.env.SLS_OTEL_LAYER_VERSION) return process.env.SLS_OTEL_LAYER_VERSION;
const installedVersion =
require('@serverless/aws-lambda-otel-extension-dist/package').version;
if (installedVersion) return installedVersion;
// If we link to package in repository, then there's no version exposed
return (Date.now() - devVersionTimeBase).toString(32);
}),
})
);
module.exports = Console;