Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,8 @@
"dependencies": {
"@aws-sdk/client-cloudformation": "^3.1034.0",
"@aws-sdk/client-s3": "^3.1034.0",
"@aws-sdk/client-sts": "^3.1034.0",
"@aws-sdk/credential-provider-env": "^3.972.29",
"@aws-sdk/credential-provider-imds": "^3.370.0",
"@aws-sdk/credential-provider-ini": "^3.972.33",
"@aws-sdk/credential-provider-process": "^3.972.29",
"@aws-sdk/credential-provider-sso": "^3.972.33",
"@aws-sdk/credential-provider-web-identity": "^3.972.33",
"@aws-sdk/property-provider": "^3.366.0",
"@aws-sdk/credential-providers": "^3.975.0",
"@smithy/node-http-handler": "^4.6.1",
"@dagrejs/graphlib": "^3.0.4",
"ajv": "^8.11.0",
"cli-cursor": "^5.0.0",
Expand All @@ -42,6 +36,7 @@
"ext": "^1.7.0",
"fast-glob": "^3.3.3",
"fs-extra": "^11.3.4",
"https-proxy-agent": "^9.0.0",
"js-yaml": "^4.1.0",
"log": "^6.3.1",
"log-node": "^8.0.3",
Expand Down
6 changes: 3 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const runComponents = async (argv = process.argv.slice(2)) => {
}

method = args._;
if (!method) {
if (!method.length) {
await renderHelp();
return;
}
Expand All @@ -65,6 +65,8 @@ const runComponents = async (argv = process.argv.slice(2)) => {
}
delete options._; // remove the method name if any

validateOptions(options, method);

const configurationPath = await resolveConfigurationPath(process.cwd());
const configuration = await readConfiguration(configurationPath);
validateConfiguration(configuration, configurationPath);
Expand All @@ -82,8 +84,6 @@ const runComponents = async (argv = process.argv.slice(2)) => {
context = new Context(contextConfig);
await context.init();

validateOptions(options, method);

try {
const componentsService = new ComponentsService(context, configuration, options);
await componentsService.init();
Expand Down
8 changes: 6 additions & 2 deletions src/state/S3StateStorage.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const ServerlessError = require('../serverless-error');
const BaseStateStorage = require('./BaseStateStorage');
const normalizeState = require('./normalize-state');

const getAwsErrorCode = (error) => error && (error.Code || error.code || error.name);

class S3StateStorage extends BaseStateStorage {
constructor(config = {}) {
super();
Expand All @@ -17,7 +19,9 @@ class S3StateStorage extends BaseStateStorage {
this.bucketName = config.bucketName;
this.stateKey = config.stateKey;

this.s3Client = new S3({ region: this.region, credentials: config.credentials });
this.s3Client = new S3(
config.clientConfig || { region: this.region, credentials: config.credentials }
);

this.writeRequestQueue = pLimit(1);
}
Expand All @@ -36,7 +40,7 @@ class S3StateStorage extends BaseStateStorage {
const readState = await streamToString(stateObjectFromS3.Body);
this.state = normalizeState(JSON.parse(readState));
} catch (e) {
if (e.Code === 'NoSuchKey') {
if (getAwsErrorCode(e) === 'NoSuchKey') {
this.state = normalizeState({});
} else {
throw new ServerlessError(
Expand Down
5 changes: 3 additions & 2 deletions src/state/get-s3-state-storage-from-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,20 @@ const getS3StateStorageFromConfig = async (stateConfiguration, context) => {

const configuredBucketName = getConfiguredStateBucketName(stateConfiguration);
const region = configuredBucketName
? await getStateBucketRegion(bucketName, stateConfiguration)
? await getStateBucketRegion(bucketName, stateConfiguration, context)
: 'us-east-1';

const awsClientConfig = getAwsClientConfig({
profile: stateConfiguration.profile,
region,
stage: context.stage,
});

return new S3StateStorage({
bucketName,
stateKey,
region,
credentials: awsClientConfig.credentials,
clientConfig: awsClientConfig,
});
};

Expand Down
16 changes: 9 additions & 7 deletions src/state/utils/get-state-bucket-name.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,22 @@ const remoteStateCloudFormationTemplate = require('./remote-state-cloudformation
const ServerlessError = require('../../serverless-error');

const COMPOSE_REMOTE_STATE_STACK_NAME = 'serverless-compose-state';
const getAwsErrorCode = (error) => error && (error.Code || error.code || error.name);

const getCloudFormationClient = (stateConfiguration = {}) => {
const getCloudFormationClient = (stateConfiguration = {}, context = {}) => {
// We are enforcing us-east-1 as the intention (that might change in the future if we find a good reason)
// is to only create one such bucket across all regions in a single AWS Account
return new CloudFormation(
getAwsClientConfig({
profile: stateConfiguration.profile,
region: 'us-east-1',
stage: context.stage,
})
);
};

const monitorStackCreation = async (stackName, context, stateConfiguration) => {
const client = getCloudFormationClient(stateConfiguration);
const client = getCloudFormationClient(stateConfiguration, context);
const describeStacksResponse = await client.describeStacks({ StackName: stackName });
const status = describeStacksResponse.Stacks[0].StackStatus;

Expand All @@ -49,7 +51,7 @@ const monitorStackCreation = async (stackName, context, stateConfiguration) => {
* @param {import('../../Context')} context
*/
const ensureRemoteStateBucketStackExists = async (context, stateConfiguration) => {
const client = getCloudFormationClient(stateConfiguration);
const client = getCloudFormationClient(stateConfiguration, context);
const templateBody = JSON.stringify(remoteStateCloudFormationTemplate);

// TODO: REPLACE WITH PROGRESS
Expand All @@ -72,8 +74,8 @@ const ensureRemoteStateBucketStackExists = async (context, stateConfiguration) =
return bucketName;
};

const getStateBucketNameFromCF = async (stateConfiguration) => {
const client = getCloudFormationClient(stateConfiguration);
const getStateBucketNameFromCF = async (stateConfiguration, context) => {
const client = getCloudFormationClient(stateConfiguration, context);
const logicalResourceId = 'ServerlessComposeRemoteStateBucket';
const result = await client.describeStackResource({
StackName: COMPOSE_REMOTE_STATE_STACK_NAME,
Expand Down Expand Up @@ -104,10 +106,10 @@ const getStateBucketName = async (stateConfiguration, context) => {

// 2. Check from remote
try {
return await getStateBucketNameFromCF(stateConfiguration);
return await getStateBucketNameFromCF(stateConfiguration, context);
} catch (e) {
// If message includes 'does not exist', we need to move forward and create the stack first
if (!(e.Code === 'ValidationError' && e.message.includes('does not exist'))) {
if (!(getAwsErrorCode(e) === 'ValidationError' && e.message.includes('does not exist'))) {
throw new ServerlessError(
`Could not retrieve S3 state bucket: ${e.message}`,
'CANNOT_RETRIEVE_REMOTE_STATE_S3_BUCKET'
Expand Down
11 changes: 8 additions & 3 deletions src/state/utils/get-state-bucket-region.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,31 @@ const { S3 } = require('@aws-sdk/client-s3');
const { getAwsClientConfig } = require('../../utils/aws');
const ServerlessError = require('../../serverless-error');

const getStateBucketRegion = async (bucketName, stateConfiguration = {}) => {
const getAwsErrorCode = (error) => error && (error.Code || error.code || error.name);

const getStateBucketRegion = async (bucketName, stateConfiguration = {}, context = {}) => {
const client = new S3(
getAwsClientConfig({
profile: stateConfiguration.profile,
region: 'us-east-1',
stage: context.stage,
})
);

let result;
try {
result = await client.getBucketLocation({ Bucket: bucketName });
} catch (e) {
if (e.Code === 'NoSuchBucket') {
const code = getAwsErrorCode(e);

if (code === 'NoSuchBucket') {
throw new ServerlessError(
`Provided bucket: "${bucketName}" could not be found.`,
'CANNOT_FIND_PROVIDED_REMOTE_STATE_BUCKET'
);
}

if (e.Code === 'AccessDenied') {
if (code === 'AccessDenied') {
throw new ServerlessError(
`Access to provided bucket: "${bucketName}" has been denied.`,
'CANNOT_ACCESS_PROVIDED_REMOTE_STATE_BUCKET'
Expand Down
142 changes: 142 additions & 0 deletions src/utils/aws/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
'use strict';

const { HttpsProxyAgent } = require('https-proxy-agent');
const https = require('https');
const fs = require('fs');
const { NodeHttpHandler } = require('@smithy/node-http-handler');

/**
* Build AWS SDK v3 client configuration from environment and options
* @param {Object} options - Configuration options
* @param {string} options.region - AWS region
* @param {Object|Function} options.credentials - AWS credentials or SDK v3 credential provider
* @param {number} options.maxAttempts - Maximum retry attempts
* @param {string} options.retryMode - Retry mode ('legacy', 'standard', 'adaptive')
* @returns {Object} AWS SDK v3 client configuration
*/
function buildClientConfig(options = {}) {
const { credentials, maxAttempts, region, requestHandler, retryMode, ...clientOptions } = options;
const config = {
...clientOptions,
region:
region === undefined
? process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1'
: region,
maxAttempts: maxAttempts === undefined ? getMaxAttempts() : maxAttempts,
retryMode: retryMode || 'standard',
};

// Add credentials if provided
if (credentials) {
config.credentials = credentials;
}

if (requestHandler) {
config.requestHandler = requestHandler;
} else {
// Configure HTTP options (proxy, timeout, certificates)
const httpOptions = buildHttpOptions();
if (httpOptions) {
config.requestHandler = new NodeHttpHandler(httpOptions);
}
}

return config;
}

/**
* Get maximum retry attempts from environment
* @returns {number} Maximum retry attempts
*/
function getMaxRetries() {
const userValue = Number(process.env.SLS_AWS_REQUEST_MAX_RETRIES);
return userValue >= 0 ? userValue : 4;
}

function getMaxAttempts() {
return getMaxRetries() + 1;
}

/**
* Build HTTP options for AWS SDK v3 clients
* @returns {Object|null} HTTP configuration or null if no special config needed
*/
function buildHttpOptions() {
const httpOptions = {};

// Configure timeout
const timeout = process.env.AWS_CLIENT_TIMEOUT || process.env.aws_client_timeout;
if (timeout) {
httpOptions.requestTimeout = parseInt(timeout, 10);
}

// Configure proxy
const proxy = getProxyUrl();

// Configure custom CA certificates
const caCerts = getCACertificates();
const agentOptions = {};
if (caCerts.length > 0) {
Object.assign(agentOptions, {
rejectUnauthorized: true,
ca: caCerts,
});
}

if (proxy) {
httpOptions.httpsAgent = new HttpsProxyAgent(proxy, agentOptions);
} else if (caCerts.length > 0) {
httpOptions.httpsAgent = new https.Agent(agentOptions);
}

return httpOptions.httpsAgent || 'requestTimeout' in httpOptions ? httpOptions : null;
}

/**
* Get proxy URL from environment variables
* @returns {string|null} Proxy URL or null if not configured
*/
function getProxyUrl() {
return (
process.env.proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy ||
process.env.HTTPS_PROXY ||
process.env.https_proxy ||
null
);
}

/**
* Get CA certificates from environment variables and files
* @returns {Array} Array of CA certificates
*/
function getCACertificates() {
let caCerts = [];

// Get certificates from environment variable
const ca = process.env.ca || process.env.HTTPS_CA || process.env.https_ca;
if (ca) {
// Can be a single certificate or multiple, comma separated.
const caArr = ca.split(',');
// Replace the newline -- https://stackoverflow.com/questions/30400341
caCerts = caCerts.concat(caArr.map((cert) => cert.replace(/\\n/g, '\n')));
}

// Get certificates from files
const cafile = process.env.cafile || process.env.HTTPS_CAFILE || process.env.https_cafile;
if (cafile) {
// Can be a single certificate file path or multiple paths, comma separated.
const caPathArr = cafile.split(',');
caCerts = caCerts.concat(caPathArr.map((cafilePath) => fs.readFileSync(cafilePath.trim())));
}

return caCerts;
}

module.exports = {
buildClientConfig,
buildHttpOptions,
getMaxAttempts,
getMaxRetries,
};
Loading