cf-custom-resources/lib/env-controller.js (284 lines of code) (raw):

// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 "use strict"; const { CloudFormation, waitUntilStackUpdateComplete, DescribeStacksCommand, UpdateStackCommand } = require("@aws-sdk/client-cloudformation"); // These are used for test purposes only let defaultResponseURL; let defaultLogGroup; let defaultLogStream; const AliasParamKey = "Aliases"; // Per the doc at https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref-responses.html // the size of the response body should not exceed 4096 bytes. // Therefore, we should ignore any outputs that we don't need. let ignoredEnvOutputs = new Set(["EnabledFeatures", "LastForceDeployID"]); /** * Upload a CloudFormation response object to S3. * * @param {object} event the Lambda event payload received by the handler function * @param {object} context the Lambda context received by the handler function * @param {string} responseStatus the response status, either 'SUCCESS' or 'FAILED' * @param {string} physicalResourceId CloudFormation physical resource ID * @param {object} [responseData] arbitrary response data object * @param {string} [reason] reason for failure, if any, to convey to the user * @returns {Promise} Promise that is resolved on success, or rejected on connection error or HTTP error response */ let report = function ( event, context, responseStatus, physicalResourceId, responseData, reason ) { return new Promise((resolve, reject) => { const https = require("https"); const { URL } = require("url"); var responseBody = JSON.stringify({ Status: responseStatus, Reason: reason, PhysicalResourceId: physicalResourceId || context.logStreamName, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, Data: responseData, }); const parsedUrl = new URL(event.ResponseURL || defaultResponseURL); const options = { hostname: parsedUrl.hostname, port: 443, path: parsedUrl.pathname + parsedUrl.search, method: "PUT", headers: { "Content-Type": "", "Content-Length": responseBody.length, }, }; https .request(options) .on("error", reject) .on("response", (res) => { res.resume(); if (res.statusCode >= 400) { reject(new Error(`Error ${res.statusCode}: ${res.statusMessage}`)); } else { resolve(); } }) .end(responseBody, "utf8"); }); }; /** * Update the environment stack's parameters by adding or removing {workload} from the provided {parameters}. * * @param {string} stackName Name of the stack. * @param {string} workload Name of the copilot workload. * @param {string[]} envControllerParameters List of parameters from the environment stack to update. * * @returns {parameters} The updated parameters. */ const controlEnv = async function ( stackName, workload, aliases, envControllerParameters ) { var cfn = new CloudFormation(); aliases = aliases || []; envControllerParameters = envControllerParameters || []; while (true) { var describeStackResp = await cfn .send(new DescribeStacksCommand({ StackName: stackName })); if (describeStackResp.Stacks.length !== 1) { throw new Error(`Cannot find environment stack ${stackName}`); } const updatedEnvStack = describeStackResp.Stacks[0]; const envParams = JSON.parse(JSON.stringify(updatedEnvStack.Parameters)); const envSet = setOfParameterKeysWithWorkload(envParams, workload); const controllerSet = new Set( envControllerParameters.filter((param) => param.endsWith("Workloads")) ); const parametersToRemove = [...envSet].filter( (param) => !controllerSet.has(param) ); const parametersToAdd = [...controllerSet].filter( (param) => !envSet.has(param) ); const exportedValues = getExportedValues(updatedEnvStack); // If there are no changes in env-controller managed parameters, the custom // resource may have been triggered because the env template is upgraded, // and the service template is attempting to retrieve the latest Outputs // from the env stack (see PR #3957). Return the updated Outputs instead // of triggering an env-controller update of the environment. const shouldUpdateAliases = needUpdateAliases(envParams, workload, aliases); if ( parametersToRemove.length + parametersToAdd.length === 0 && !shouldUpdateAliases ) { return exportedValues; } for (const envParam of envParams) { if (envParam.ParameterKey === AliasParamKey) { if (shouldUpdateAliases) { envParam.ParameterValue = updateAliases( envParam.ParameterValue, workload, aliases ); } continue; } if (parametersToRemove.includes(envParam.ParameterKey)) { const values = new Set( envParam.ParameterValue.split(",").filter(Boolean) ); // Filter out the empty string // in the output array to prevent a leading comma in the parameters list. values.delete(workload); envParam.ParameterValue = [...values].join(","); } if (parametersToAdd.includes(envParam.ParameterKey)) { const values = new Set( envParam.ParameterValue.split(",").filter(Boolean) ); values.add(workload); envParam.ParameterValue = [...values].join(","); } } try { await cfn .send(new UpdateStackCommand({ StackName: stackName, Parameters: envParams, UsePreviousTemplate: true, RoleARN: exportedValues["CFNExecutionRoleARN"], Capabilities: updatedEnvStack.Capabilities, })); } catch (err) { if ( !err.message.match( /^Stack.*is in UPDATE_IN_PROGRESS state and can not be updated/ ) ) { throw err; } // If the other workload is updating the env stack, wait until update completes. await exports.waitForStackUpdate(cfn, stackName); continue; } // Wait until update complete, then return the updated env stack output. await exports.waitForStackUpdate(cfn, stackName); describeStackResp = await cfn .send(new DescribeStacksCommand({ StackName: stackName, })); if (describeStackResp.Stacks.length !== 1) { throw new Error(`Cannot find environment stack ${stackName}`); } return getExportedValues(describeStackResp.Stacks[0]); } }; const waitForStackUpdate = async function (cfn, stackName) { await waitUntilStackUpdateComplete({ client: cfn, maxWaitTime: 30 * 29, minDelay: 30, maxDelay: 30, },{ StackName: stackName, }); }; /** * Environment controller handler, invoked by Lambda. */ exports.handler = async function (event, context) { var responseData = {}; const props = event.ResourceProperties; const physicalResourceId = event.PhysicalResourceId || `envcontoller/${props.EnvStack}/${props.Workload}`; try { switch (event.RequestType) { case "Create": responseData = await Promise.race([ exports.deadlineExpired(), controlEnv( props.EnvStack, props.Workload, props.Aliases, props.Parameters ), ]); break; case "Update": responseData = await Promise.race([ exports.deadlineExpired(), controlEnv( props.EnvStack, props.Workload, props.Aliases, props.Parameters ), ]); break; case "Delete": responseData = await Promise.race([ exports.deadlineExpired(), controlEnv( props.EnvStack, props.Workload, [] // Set to empty to denote that Workload should not be included in any env stack parameter. ), ]); break; default: throw new Error(`Unsupported request type ${event.RequestType}`); } await report(event, context, "SUCCESS", physicalResourceId, responseData); } catch (err) { console.log(`Caught error ${err}.`); console.log( `Responding FAILED for physical resource id: ${physicalResourceId}` ); await report( event, context, "FAILED", physicalResourceId, null, `${err.message} (Log: ${defaultLogGroup || context.logGroupName}/${ defaultLogStream || context.logStreamName })` ); } }; function setOfParameterKeysWithWorkload(cfnParams, workload) { const envSet = new Set(); cfnParams.forEach((param) => { if (!param.ParameterKey.endsWith("Workloads")) { return; } let values = new Set(param.ParameterValue.split(",")); if (!values.has(workload)) { return; } envSet.add(param.ParameterKey); }); return envSet; } function needUpdateAliases(cfnParams, workload, aliases) { for (const param of cfnParams) { if (param.ParameterKey !== AliasParamKey) { continue; } let obj = JSON.parse(param.ParameterValue || "{}"); if ((obj[workload] || []).toString() !== aliases.toString()) { return true; } } return false; } const updateAliases = function (cfnAliases, workload, aliases) { let obj = JSON.parse(cfnAliases || "{}"); if (aliases.length !== 0) { obj[workload] = aliases; } else { obj[workload] = undefined; } const updatedAliases = JSON.stringify(obj); return updatedAliases === "{}" ? "" : updatedAliases; }; const getExportedValues = function (stack) { const exportedValues = {}; stack.Outputs.forEach((output) => { if (ignoredEnvOutputs.has(output.OutputKey)) { return; } exportedValues[output.OutputKey] = output.OutputValue; }); return exportedValues; }; /** * Update parameter by adding workload to the parameter values. * * @param {string} requestType type of the request. * @param {string} workload name of the workload. * @param {string} paramValue value of the parameter. * * @returns {string} The updated parameter. * @returns {bool} whether the parameter is modified. */ exports.deadlineExpired = function () { return new Promise(function (resolve, reject) { setTimeout( reject, 14 * 60 * 1000 + 30 * 1000 /* 14.5 minutes*/, new Error("Lambda took longer than 14.5 minutes to update environment") ); }); }; /** * @private */ exports.withDefaultResponseURL = function (url) { defaultResponseURL = url; }; /** * @private */ exports.withDefaultLogStream = function (logStream) { defaultLogStream = logStream; }; /** * @private */ exports.withDefaultLogGroup = function (logGroup) { defaultLogGroup = logGroup; }; /** * @private */ exports.waitForStackUpdate = function(cfn, stackName) { return waitForStackUpdate(cfn, stackName); }