AssetFreeCustomResource.java
package com.cloudforgeci.api.core.customresource;
import software.amazon.awscdk.CustomResource;
import software.amazon.awscdk.Duration;
import software.amazon.awscdk.Stack;
import software.amazon.awscdk.services.iam.PolicyStatement;
import software.amazon.awscdk.services.iam.ServicePrincipal;
import software.amazon.awscdk.services.lambda.Code;
import software.amazon.awscdk.services.lambda.CfnFunction;
import software.amazon.awscdk.services.lambda.Function;
import software.amazon.awscdk.services.lambda.Permission;
import software.amazon.awscdk.services.lambda.Runtime;
import software.constructs.Construct;
import java.util.List;
import java.util.Map;
/**
* Shared plumbing for asset-free, Lambda-backed CloudFormation custom resources -- a raw {@link
* CustomResource} wired directly to a {@code Code.fromInline} Lambda's ARN, not {@code
* AwsCustomResource}/{@code Provider} (both of those pull in CDK's own bundled framework Lambda,
* staged to the deployer's private {@code cdk-hnb659fds-assets} bootstrap bucket -- unusable by
* an AWS Marketplace buyer launching a template directly in their own, unbootstrapped account).
*
* <p>Handles the three things that are easy to get wrong doing this by hand, each found by real
* review on the first custom resource rewritten this way (AlbFactory's ALB-logs-bucket SSM
* writer): the raw CloudFormation custom-resource response protocol (a rejected or non-2xx
* ResponseURL PUT must be treated as failure, not silently swallowed as success), the {@code
* cloudformation.amazonaws.com} invoke permission (implicit with {@code AwsCustomResource}/{@code
* Provider}, not automatic on a raw {@code CustomResource}), and scoping that permission to this
* exact stack (otherwise any AWS account's CloudFormation could invoke the function by pointing
* their own custom resource's serviceToken at its ARN -- a confused-deputy path).
*
* <p>Callers supply only their own business logic (the AWS SDK call(s) to make) as a Node.js
* fragment defining {@code exports.handler}; this class supplies the shared response-protocol
* wrapper, Lambda/permission/CustomResource wiring, and IAM policy attachment.
*/
public final class AssetFreeCustomResource {
private AssetFreeCustomResource() {
}
/** Metadata key on each function this class creates; the compliance guards use it to skip them. */
public static final String METADATA_KEY = "CloudForgeAssetFreeCustomResource";
/**
* The shared CloudFormation custom-resource response protocol, implementing the raw PUT-to-
* ResponseURL contract directly (not CDK's Provider framework, which pulls in a bundled,
* asset-staged Lambda). Every caller's handler source is this snippet followed by their own
* {@code exports.handler}, which must call {@code respond(event, 'SUCCESS'|'FAILED', reason,
* data)} exactly once (data optional) instead of returning a value directly.
*/
public static final String RESPONSE_PROTOCOL_JS = """
const https = require('https');
function respond(event, status, reason, data) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
Status: status,
Reason: reason || 'See the function\\'s own CloudWatch Logs group for details.',
PhysicalResourceId: event.PhysicalResourceId || event.LogicalResourceId,
StackId: event.StackId,
RequestId: event.RequestId,
LogicalResourceId: event.LogicalResourceId,
Data: data || {}
});
const url = new URL(event.ResponseURL);
const req = https.request({
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: 'PUT',
headers: { 'content-type': '', 'content-length': Buffer.byteLength(body) },
timeout: 10000
}, (res) => {
res.on('data', () => {});
res.on('error', reject);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve();
} else {
reject(new Error('ResponseURL PUT failed with HTTP status ' + res.statusCode));
}
});
});
req.on('timeout', () => req.destroy(new Error('ResponseURL PUT timed out')));
req.on('error', reject);
req.write(body);
req.end();
});
}
""";
/**
* A complete, ready-to-use handler body (append to {@link #RESPONSE_PROTOCOL_JS}, not on its
* own) for the single most common case: write one SSM parameter at deploy time. Takes {@code
* ParameterName}/{@code ParameterValue}/{@code ParameterDescription}/{@code Region} from
* {@code ResourceProperties}; does nothing on delete (the parameter is left in place, matching
* every caller's own reason for using SSM here -- tracking a resource across stack updates,
* not tying its lifecycle to one).
*/
public static final String SSM_PUT_PARAMETER_HANDLER_JS = """
const { SSMClient, PutParameterCommand } = require('@aws-sdk/client-ssm');
exports.handler = async (event) => {
try {
if (event.RequestType !== 'Delete') {
const p = event.ResourceProperties;
const endpoint = process.env.AWS_ENDPOINT_URL;
const client = new SSMClient({
region: p.Region,
...(endpoint ? { endpoint } : {})
});
await client.send(new PutParameterCommand({
Name: p.ParameterName,
Value: p.ParameterValue,
Type: 'String',
Description: p.ParameterDescription,
Overwrite: true
}));
}
await respond(event, 'SUCCESS');
} catch (err) {
console.error('SSM parameter writer failed: ' + err);
try {
await respond(event, 'FAILED', String(err));
} catch (deliveryErr) {
console.error('Could not deliver FAILED response to CloudFormation: ' + deliveryErr);
}
}
};
""";
/** The Lambda function and the CustomResource wired to it, returned together so callers can
* add NagSuppressions, dependencies, or read response attributes via {@code
* customResource.getAttString(...)} as needed. */
public static final class Result {
public final Function function;
public final CustomResource customResource;
Result(Function function, CustomResource customResource) {
this.function = function;
this.customResource = customResource;
}
}
/**
* Wires a complete asset-free custom resource: an inline Lambda running {@code handlerBody}
* (appended to {@link #RESPONSE_PROTOCOL_JS}), a CloudFormation invoke permission scoped to
* this exact stack, the given IAM policy statements on the function's execution role, and a
* raw {@link CustomResource} pointed at it.
*
* @param scope construct scope (the calling factory)
* @param idPrefix used to derive the Lambda's construct id ({@code idPrefix + "Fn"})
* and the CustomResource's own construct id ({@code idPrefix})
* @param handlerBody the Node.js source defining {@code exports.handler}, using {@code
* respond} from {@link #RESPONSE_PROTOCOL_JS} instead of returning a
* value directly
* @param timeout Lambda execution timeout
* @param policyStatements granted to the function's execution role (IAM, not CFN invoke --
* the invoke grant is always added automatically and always scoped to
* this stack)
* @param properties passed through as the CustomResource's ResourceProperties
*/
public static Result create(Construct scope, String idPrefix, String handlerBody,
Duration timeout, List<PolicyStatement> policyStatements,
Map<String, Object> properties) {
Function fn = Function.Builder.create(scope, idPrefix + "Fn")
.runtime(Runtime.NODEJS_22_X)
.handler("index.handler")
.code(Code.fromInline(RESPONSE_PROTOCOL_JS + handlerBody))
.timeout(timeout)
.build();
// Lets the compliance guards tell this deploy-time helper from an application's own Lambda.
((CfnFunction) fn.getNode().getDefaultChild()).addMetadata(METADATA_KEY, true);
for (PolicyStatement statement : policyStatements) {
fn.addToRolePolicy(statement);
}
// See this class's own javadoc: cloudformation.amazonaws.com is a shared, multi-tenant
// service principal, so this grant is always scoped to this exact stack (sourceAccount +
// sourceArn), never left open to any AWS account's CloudFormation.
fn.addPermission("InvokeByCloudFormation", Permission.builder()
.principal(new ServicePrincipal("cloudformation.amazonaws.com"))
.action("lambda:InvokeFunction")
.sourceAccount(Stack.of(scope).getAccount())
.sourceArn(Stack.of(scope).getStackId())
.build());
CustomResource cr = CustomResource.Builder.create(scope, idPrefix)
.serviceToken(fn.getFunctionArn())
.properties(properties)
.build();
// serviceToken references the function itself, not the separate AWS::Lambda::Permission
// above -- without this, CloudFormation has no ordering guarantee that the permission
// exists before this resource's first invoke, and can fail with an access-denied error.
cr.getNode().addDependency(fn.getNode().findChild("InvokeByCloudFormation"));
// fn.addToRolePolicy(...) above attaches each statement to the role's own generated
// DefaultPolicy (a separate AWS::IAM::Policy resource), which CDK does not automatically
// make the function -- or anything invoking it -- wait on. Without this, CloudFormation
// can invoke the function before that policy exists, failing with AccessDenied.
if (!policyStatements.isEmpty()) {
cr.getNode().addDependency(fn.getRole().getNode().findChild("DefaultPolicy"));
}
return new Result(fn, cr);
}
}