packages/aws-cdk-lib/aws-events/lib/rule.ts (280 lines of code) (raw):
import { Node, Construct } from 'constructs';
import { IEventBus } from './event-bus';
import { EventPattern } from './event-pattern';
import { CfnEventBusPolicy, CfnRule } from './events.generated';
import { EventCommonOptions } from './on-event-options';
import { IRule } from './rule-ref';
import { Schedule } from './schedule';
import { IRuleTarget } from './target';
import { mergeEventPattern, renderEventPattern } from './util';
import { IRole, PolicyStatement, Role, ServicePrincipal } from '../../aws-iam';
import { App, IResource, Lazy, Names, Resource, Stack, Token, TokenComparison, PhysicalName, ArnFormat, Annotations, ValidationError } from '../../core';
import { addConstructMetadata, MethodMetadata } from '../../core/lib/metadata-resource';
/**
* Properties for defining an EventBridge Rule
*/
export interface RuleProps extends EventCommonOptions {
/**
* Indicates whether the rule is enabled.
*
* @default true
*/
readonly enabled?: boolean;
/**
* The schedule or rate (frequency) that determines when EventBridge
* runs the rule.
*
* You must specify this property, the `eventPattern` property, or both.
*
* For more information, see Schedule Expression Syntax for
* Rules in the Amazon EventBridge User Guide.
*
* @see https://docs.aws.amazon.com/eventbridge/latest/userguide/scheduled-events.html
*
* @default - None.
*/
readonly schedule?: Schedule;
/**
* Targets to invoke when this rule matches an event.
*
* Input will be the full matched event. If you wish to specify custom
* target input, use `addTarget(target[, inputOptions])`.
*
* @default - No targets.
*/
readonly targets?: IRuleTarget[];
/**
* The event bus to associate with this rule.
*
* @default - The default event bus.
*/
readonly eventBus?: IEventBus;
/**
* The role that is used for target invocation.
* Must be assumable by principal `events.amazonaws.com`.
*
* @default - No role associated
*/
readonly role?: IRole;
}
/**
* Defines an EventBridge Rule in this stack.
*
* @resource AWS::Events::Rule
*/
export class Rule extends Resource implements IRule {
/**
* Import an existing EventBridge Rule provided an ARN
*
* @param scope The parent creating construct (usually `this`).
* @param id The construct's name.
* @param eventRuleArn Event Rule ARN (i.e. arn:aws:events:<region>:<account-id>:rule/MyScheduledRule).
*/
public static fromEventRuleArn(scope: Construct, id: string, eventRuleArn: string): IRule {
const parts = Stack.of(scope).splitArn(eventRuleArn, ArnFormat.SLASH_RESOURCE_NAME);
class Import extends Resource implements IRule {
public ruleArn = eventRuleArn;
public ruleName = parts.resourceName || '';
}
return new Import(scope, id, {
environmentFromArn: eventRuleArn,
});
}
public readonly ruleArn: string;
public readonly ruleName: string;
private readonly targets = new Array<CfnRule.TargetProperty>();
private readonly eventPattern: EventPattern = { };
private readonly scheduleExpression?: string;
private readonly description?: string;
/** Set to keep track of what target accounts and regions we've already created event buses for */
private readonly _xEnvTargetsAdded = new Set<string>();
constructor(scope: Construct, id: string, props: RuleProps = { }) {
super(determineRuleScope(scope, props), id, {
physicalName: props.ruleName,
});
// Enhanced CDK Analytics Telemetry
addConstructMetadata(this, props);
if (props.eventBus && props.schedule) {
throw new ValidationError('Cannot associate rule with \'eventBus\' when using \'schedule\'', this);
}
this.description = props.description;
this.scheduleExpression = props.schedule?.expressionString;
// add a warning on synth when minute is not defined in a cron schedule
props.schedule?._bind(this);
const resource = new CfnRule(this, 'Resource', {
name: this.physicalName,
description: this.description,
state: props.enabled == null ? 'ENABLED' : (props.enabled ? 'ENABLED' : 'DISABLED'),
scheduleExpression: this.scheduleExpression,
eventPattern: Lazy.any({ produce: () => this._renderEventPattern() }),
targets: Lazy.any({ produce: () => this.renderTargets() }),
eventBusName: props.eventBus && props.eventBus.eventBusName,
roleArn: props.role?.roleArn,
});
this.ruleArn = this.getResourceArnAttribute(resource.attrArn, {
service: 'events',
resource: 'rule',
resourceName: this.physicalName,
});
this.ruleName = this.getResourceNameAttribute(resource.ref);
this.addEventPattern(props.eventPattern);
for (const target of props.targets || []) {
this.addTarget(target);
}
this.node.addValidation({ validate: () => this.validateRule() });
}
/**
* Adds a target to the rule. The abstract class RuleTarget can be extended to define new
* targets.
*
* No-op if target is undefined.
*/
@MethodMetadata()
public addTarget(target?: IRuleTarget): void {
if (!target) { return; }
// Simply increment id for each `addTarget` call. This is guaranteed to be unique.
const autoGeneratedId = `Target${this.targets.length}`;
const targetProps = target.bind(this, autoGeneratedId);
const inputProps = targetProps.input && targetProps.input.bind(this);
const roleArn = targetProps.role?.roleArn;
const id = targetProps.id || autoGeneratedId;
if (targetProps.targetResource) {
const targetStack = Stack.of(targetProps.targetResource);
const targetAccount = (targetProps.targetResource as IResource).env?.account || targetStack.account;
const targetRegion = (targetProps.targetResource as IResource).env?.region || targetStack.region;
const sourceStack = Stack.of(this);
const sourceAccount = sourceStack.account;
const sourceRegion = sourceStack.region;
// if the target is in a different account or region and is defined in this CDK App
// we can generate all the needed components:
// - forwarding rule in the source stack (target: default event bus of the receiver region)
// - eventbus permissions policy (creating an extra stack)
// - receiver rule in the target stack (target: the actual target)
if (!this.sameEnvDimension(sourceAccount, targetAccount) || !this.sameEnvDimension(sourceRegion, targetRegion)) {
// cross-account and/or cross-region event - strap in, this works differently than regular events!
// based on:
// https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-cross-account.html
// for cross-account or cross-region events, we require a concrete target account and region
if (!targetAccount || Token.isUnresolved(targetAccount)) {
throw new ValidationError('You need to provide a concrete account for the target stack when using cross-account or cross-region events', this);
}
if (!targetRegion || Token.isUnresolved(targetRegion)) {
throw new ValidationError('You need to provide a concrete region for the target stack when using cross-account or cross-region events', this);
}
if (Token.isUnresolved(sourceAccount)) {
throw new ValidationError('You need to provide a concrete account for the source stack when using cross-account or cross-region events', this);
}
// Don't exactly understand why this code was here (seems unlikely this rule would be violated), but
// let's leave it in nonetheless.
const sourceApp = this.node.root;
if (!sourceApp || !App.isApp(sourceApp)) {
throw new ValidationError('Event stack which uses cross-account or cross-region targets must be part of a CDK app', this);
}
const targetApp = Node.of(targetProps.targetResource).root;
if (!targetApp || !App.isApp(targetApp)) {
throw new ValidationError('Target stack which uses cross-account or cross-region event targets must be part of a CDK app', this);
}
if (sourceApp !== targetApp) {
throw new ValidationError('Event stack and target stack must belong to the same CDK app', this);
}
// The target of this Rule will be the default event bus of the target environment
this.ensureXEnvTargetEventBus(targetStack, targetAccount, targetRegion, id);
// The actual rule lives in the target stack. Other than the account, it's identical to this one,
// but only evaluated at render time (via a special subclass).
//
// FIXME: the MirrorRule is a bit silly, forwarding the exact same event to another event bus
// and trigger on it there (there will be issues with construct references, for example). Especially
// in the case of scheduled events, we will just trigger both rules in parallel in both environments.
//
// A better solution would be to have the source rule add a unique token to the the event,
// and have the mirror rule trigger on that token only (thereby properly separating triggering, which
// happens in the source env; and activating, which happens in the target env).
//
// Don't have time to do that right now.
const mirrorRuleScope = this.obtainMirrorRuleScope(targetStack, targetAccount, targetRegion);
new MirrorRule(mirrorRuleScope, `${Names.uniqueId(this)}-${id}`, {
targets: [target],
eventPattern: this.eventPattern,
schedule: this.scheduleExpression ? Schedule.expression(this.scheduleExpression) : undefined,
description: this.description,
}, this);
return;
}
}
// Here only if the target does not have a targetResource defined.
// In such case we don't have to generate any extra component.
// Note that this can also be an imported resource (i.e: EventBus target)
this.targets.push({
id,
arn: targetProps.arn,
roleArn,
ecsParameters: targetProps.ecsParameters,
httpParameters: targetProps.httpParameters,
kinesisParameters: targetProps.kinesisParameters,
runCommandParameters: targetProps.runCommandParameters,
batchParameters: targetProps.batchParameters,
deadLetterConfig: targetProps.deadLetterConfig,
retryPolicy: targetProps.retryPolicy,
sqsParameters: targetProps.sqsParameters,
redshiftDataParameters: targetProps.redshiftDataParameters,
appSyncParameters: targetProps.appSyncParameters,
input: inputProps && inputProps.input,
inputPath: inputProps && inputProps.inputPath,
inputTransformer: inputProps?.inputTemplate !== undefined ? {
inputTemplate: inputProps.inputTemplate,
inputPathsMap: inputProps.inputPathsMap,
} : undefined,
});
}
/**
* Adds an event pattern filter to this rule. If a pattern was already specified,
* these values are merged into the existing pattern.
*
* For example, if the rule already contains the pattern:
*
* {
* "resources": [ "r1" ],
* "detail": {
* "hello": [ 1 ]
* }
* }
*
* And `addEventPattern` is called with the pattern:
*
* {
* "resources": [ "r2" ],
* "detail": {
* "foo": [ "bar" ]
* }
* }
*
* The resulting event pattern will be:
*
* {
* "resources": [ "r1", "r2" ],
* "detail": {
* "hello": [ 1 ],
* "foo": [ "bar" ]
* }
* }
*
*/
@MethodMetadata()
public addEventPattern(eventPattern?: EventPattern) {
if (!eventPattern) {
return;
}
mergeEventPattern(this.eventPattern, eventPattern);
}
/**
* Not private only to be overrideen in CopyRule.
*
* @internal
*/
public _renderEventPattern(): any {
return renderEventPattern(this.eventPattern);
}
protected validateRule() {
const errors: string[] = [];
const name = this.physicalName;
if (name !== undefined && !Token.isUnresolved(name)) {
if (name.length < 1 || name.length > 64) {
errors.push(`Event rule name must be between 1 and 64 characters. Received: ${name}`);
}
if (!/^[\.\-_A-Za-z0-9]+$/.test(name)) {
errors.push(`Event rule name ${name} can contain only letters, numbers, periods, hyphens, or underscores with no spaces.`);
}
}
if (Object.keys(this.eventPattern).length === 0 && !this.scheduleExpression) {
errors.push('Either \'eventPattern\' or \'schedule\' must be defined');
}
if (this.targets.length > 5) {
errors.push('Event rule cannot have more than 5 targets.');
}
return errors;
}
private renderTargets() {
if (this.targets.length === 0) {
return undefined;
}
return this.targets;
}
/**
* Make sure we add the target environments event bus as a target, and the target has permissions set up to receive our events
*
* For cross-account rules, uses a support stack to set up a policy on the target event bus.
*/
private ensureXEnvTargetEventBus(targetStack: Stack, targetAccount: string, targetRegion: string, id: string) {
// the _actual_ target is just the event bus of the target's account
// make sure we only add it once per account per region
const key = `${targetAccount}:${targetRegion}`;
if (this._xEnvTargetsAdded.has(key)) { return; }
this._xEnvTargetsAdded.add(key);
const eventBusArn = targetStack.formatArn({
service: 'events',
resource: 'event-bus',
resourceName: 'default',
region: targetRegion,
account: targetAccount,
});
// For some reason, cross-region requires a Role (with `PutEvents` on the
// target event bus) while cross-account doesn't
const roleArn = !this.sameEnvDimension(targetRegion, Stack.of(this).region)
? this.crossRegionPutEventsRole(eventBusArn).roleArn
: undefined;
this.targets.push({
id,
arn: eventBusArn,
roleArn,
});
// Add a policy to the target Event Bus to allow the source account/region to publish into it.
//
// Since this Event Bus permission needs to be deployed before the stack containing the Rule is deployed
// (as EventBridge verifies whether you have permissions to the targets on rule creation), this needs
// to be in a support stack.
const sourceApp = this.node.root as App;
const sourceAccount = Stack.of(this).account;
// If different accounts, we need to add the permissions to the target eventbus
//
// For different region, no need for a policy on the target event bus (but a need
// for a role).
if (!this.sameEnvDimension(sourceAccount, targetAccount)) {
const stackId = `EventBusPolicy-${sourceAccount}-${targetRegion}-${targetAccount}`;
let eventBusPolicyStack: Stack = sourceApp.node.tryFindChild(stackId) as Stack;
if (!eventBusPolicyStack) {
eventBusPolicyStack = new Stack(sourceApp, stackId, {
env: {
account: targetAccount,
region: targetRegion,
},
// The region in the stack name is rather redundant (it will always be the target region)
// Leaving it in for backwards compatibility.
stackName: `${targetStack.stackName}-EventBusPolicy-support-${targetRegion}-${sourceAccount}`,
});
const statementPrefix = `Allow-account-${sourceAccount}-`;
new CfnEventBusPolicy(eventBusPolicyStack, 'GivePermToOtherAccount', {
action: 'events:PutEvents',
statementId: statementPrefix + Names.uniqueResourceName(this, {
maxLength: 64 - statementPrefix.length,
}),
principal: sourceAccount,
});
}
// deploy the event bus permissions before the source stack
Stack.of(this).addDependency(eventBusPolicyStack);
}
}
/**
* Return the scope where the mirror rule should be created for x-env event targets
*
* This is the target resource's containing stack if it shares the same region (owned
* resources), or should be a fresh support stack for imported resources.
*
* We don't implement the second yet, as I have to think long and hard on whether we
* can reuse the existing support stack or not, and I don't have time for that right now.
*/
private obtainMirrorRuleScope(targetStack: Stack, targetAccount: string, targetRegion: string): Construct {
// for cross-account or cross-region events, we cannot create new components for an imported resource
// because we don't have the target stack
if (this.sameEnvDimension(targetStack.account, targetAccount) && this.sameEnvDimension(targetStack.region, targetRegion)) {
return targetStack;
}
// For now, we don't do the work for the support stack yet
throw new ValidationError('Cannot create a cross-account or cross-region rule for an imported resource (create a stack with the right environment for the imported resource)', this);
}
/**
* Obtain the Role for the EventBridge event
*
* If a role already exists, it will be returned. This ensures that if multiple
* events have the same target, they will share a role.
* @internal
*/
private crossRegionPutEventsRole(eventBusArn: string): IRole {
const id = 'EventsRole';
let role = this.node.tryFindChild(id) as IRole;
if (!role) {
role = new Role(this, id, {
roleName: PhysicalName.GENERATE_IF_NEEDED,
assumedBy: new ServicePrincipal('events.amazonaws.com'),
});
}
role.addToPrincipalPolicy(new PolicyStatement({
actions: ['events:PutEvents'],
resources: [eventBusArn],
}));
return role;
}
/**
* Whether two string probably contain the same environment dimension (region or account)
*
* Used to compare either accounts or regions, and also returns true if one or both
* are unresolved (in which case both are expected to be "current region" or "current account").
*/
private sameEnvDimension(dim1: string, dim2: string) {
switch (Token.compareStrings(dim1, dim2)) {
case TokenComparison.ONE_UNRESOLVED:
Annotations.of(this).addWarningV2('@aws-cdk/aws-events:ruleUnresolvedEnvironment', 'Either the Event Rule or target has an unresolved environment. \n \
If they are being used in a cross-environment setup you need to specify the environment for both.');
return true;
case TokenComparison.BOTH_UNRESOLVED:
case TokenComparison.SAME:
return true;
default:
return false;
}
}
}
function determineRuleScope(scope: Construct, props: RuleProps): Construct {
if (!props.crossStackScope) {
return scope;
}
const scopeStack = Stack.of(scope);
const targetStack = Stack.of(props.crossStackScope);
if (scopeStack === targetStack) {
return scope;
}
// cross-region/account Events require their own setup,
// so we use the base scope in that case
const regionComparison = Token.compareStrings(scopeStack.region, targetStack.region);
const accountComparison = Token.compareStrings(scopeStack.account, targetStack.account);
const stacksInSameAccountAndRegion = (regionComparison === TokenComparison.SAME || regionComparison === TokenComparison.BOTH_UNRESOLVED) &&
(accountComparison === TokenComparison.SAME || accountComparison === TokenComparison.BOTH_UNRESOLVED);
return stacksInSameAccountAndRegion ? props.crossStackScope : scope;
}
/**
* A rule that mirrors another rule
*/
class MirrorRule extends Rule {
constructor(scope: Construct, id: string, props: RuleProps, private readonly source: Rule) {
super(scope, id, props);
// Enhanced CDK Analytics Telemetry
addConstructMetadata(this, props);
}
public _renderEventPattern(): any {
return this.source._renderEventPattern();
}
/**
* Override validateRule to be a no-op
*
* The rules are never stored on this object so there's nothing to validate.
*
* Instead, we mirror the other rule at render time.
*/
protected validateRule(): string[] {
return [];
}
}