Skip to content

Commit 112ed67

Browse files
authored
feat(mediapackagev2): add region attribute on mediapackagev2 resources and extra naming validation (#37526)
### Reason for this change Allow cross-region references on an imported MediaPackageV2 channel and extra runtime validation on manifest naming. Docs: https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-properties-medialive-channel-mediapackageoutputdestinationsettings.html (MediaPackageRegionName) ### Description of changes Overview of change: 1. Add region attribute on mediapackagev2 resources 2. add "from***Arn()" functions for imports 3. Added validation to unique manifest names ### Describe any new or updated permissions being added N/A ### Description of how you validated changes Added new unit tests ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 689c661 commit 112ed67

19 files changed

Lines changed: 1257 additions & 4 deletions

packages/@aws-cdk/aws-mediapackagev2-alpha/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,25 @@ const channelGroup = ChannelGroup.fromChannelGroupAttributes(stack, 'ImportedCha
9393
});
9494
```
9595

96+
You can also import from an ARN, which automatically extracts the name and region:
97+
98+
```ts
99+
declare const stack: Stack;
100+
const channelGroup = ChannelGroup.fromChannelGroupArn(stack, 'ImportedChannelGroup',
101+
'arn:aws:mediapackagev2:us-west-2:123456789012:channelGroup/MyChannelGroup',
102+
);
103+
```
104+
105+
For cross-region imports, pass the `region` parameter to ensure the correct ARN is constructed:
106+
107+
```ts
108+
declare const stack: Stack;
109+
const channelGroup = ChannelGroup.fromChannelGroupAttributes(stack, 'ImportedChannelGroup', {
110+
channelGroupName: 'MyChannelGroup',
111+
region: 'us-west-2',
112+
});
113+
```
114+
96115
## Channel
97116

98117
A channel is part of a channel group and represents the entry point for a content stream into MediaPackage.
@@ -140,6 +159,17 @@ const channel = Channel.fromChannelAttributes(stack, 'ImportedChannel', {
140159
});
141160
```
142161

162+
You can also import from an ARN:
163+
164+
```ts
165+
declare const stack: Stack;
166+
const channel = Channel.fromChannelArn(stack, 'ImportedChannel',
167+
'arn:aws:mediapackagev2:us-west-2:123456789012:channelGroup/MyGroup/channel/MyChannel',
168+
);
169+
```
170+
171+
Imported channels expose a `region` property, which is parsed from the ARN or falls back to the importing stack's region.
172+
143173
### Channel Resource Policy
144174

145175
The following code creates a resource policy directly on the channel. This
@@ -184,6 +214,15 @@ const originEndpoint = OriginEndpoint.fromOriginEndpointAttributes(stack, 'Impor
184214
});
185215
```
186216

217+
You can also import from an ARN:
218+
219+
```ts
220+
declare const stack: Stack;
221+
const originEndpoint = OriginEndpoint.fromOriginEndpointArn(stack, 'ImportedOriginEndpoint',
222+
'arn:aws:mediapackagev2:us-west-2:123456789012:channelGroup/MyGroup/channel/MyChannel/originEndpoint/MyEndpoint',
223+
);
224+
```
225+
187226
The following code creates a resource policy on the origin endpoint. This
188227
will automatically create a policy on the first call:
189228

packages/@aws-cdk/aws-mediapackagev2-alpha/lib/channel.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export interface IChannel extends IResource, IChannelRef {
8585
*/
8686
readonly channelGroup?: IChannelGroup;
8787

88+
/**
89+
* The AWS region where this channel lives.
90+
*/
91+
readonly region: string;
92+
8893
/**
8994
* Grants IAM resource policy to the role used to write to MediaPackage V2 Channel.
9095
*/
@@ -351,12 +356,52 @@ export interface ChannelAttributes {
351356
* @attribute
352357
*/
353358
readonly channelGroupName: string;
359+
360+
/**
361+
* The AWS region where the channel lives.
362+
*
363+
* Required for cross-region imports to construct the correct ARN.
364+
*
365+
* @default - the importing stack's region
366+
*/
367+
readonly region?: string;
354368
}
355369

356370
/**
357371
* A new or imported Channel.
358372
*/
359373
abstract class ChannelBase extends Resource implements IChannel {
374+
/**
375+
* Creates a Channel construct that represents an external (imported) Channel from its ARN.
376+
*
377+
* The ARN is expected to be in the format:
378+
* `arn:<partition>:mediapackagev2:<region>:<account>:channelGroup/<groupName>/channel/<channelName>`
379+
*/
380+
public static fromChannelArn(scope: Construct, id: string, channelArn: string): IChannel {
381+
if (Token.isUnresolved(channelArn)) {
382+
throw new ValidationError(
383+
lit`TokenArnNotSupported`,
384+
'Cannot parse a token ARN. Use Channel.fromChannelAttributes() with explicit channelName, channelGroupName, and region values instead.',
385+
scope,
386+
);
387+
}
388+
const parsedArn = Stack.of(scope).splitArn(channelArn, ArnFormat.SLASH_RESOURCE_NAME);
389+
// resourceName is "<groupName>/channel/<channelName>"
390+
const [channelGroupName, , channelName] = parsedArn.resourceName?.split('/') ?? [];
391+
if (!channelGroupName || !channelName) {
392+
throw new ValidationError(
393+
lit`InvalidChannelArn`,
394+
`Could not parse channel ARN: ${channelArn}. Expected format: arn:<partition>:mediapackagev2:<region>:<account>:channelGroup/<groupName>/channel/<channelName>`,
395+
scope,
396+
);
397+
}
398+
return ChannelBase.fromChannelAttributes(scope, id, {
399+
channelGroupName,
400+
channelName,
401+
region: parsedArn.region,
402+
});
403+
}
404+
360405
/**
361406
* Creates a Channel construct that represents an external (imported) Channel.
362407
*/
@@ -367,6 +412,7 @@ abstract class ChannelBase extends Resource implements IChannel {
367412
public readonly channelName = attrs.channelName;
368413
public readonly createdAt = undefined;
369414
public readonly modifiedAt = undefined;
415+
public readonly region = attrs.region ?? Stack.of(this).region;
370416
protected autoCreatePolicy = false;
371417

372418
public get ingestEndpointUrls(): string[] {
@@ -381,6 +427,7 @@ abstract class ChannelBase extends Resource implements IChannel {
381427
resource: `channelGroup/${attrs.channelGroupName}/channel`,
382428
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
383429
resourceName: this.channelName,
430+
region: this.region,
384431
});
385432
}
386433

@@ -393,6 +440,7 @@ abstract class ChannelBase extends Resource implements IChannel {
393440
public abstract readonly createdAt?: string;
394441
public abstract readonly modifiedAt?: string;
395442
public abstract readonly ingestEndpointUrls: string[];
443+
public abstract readonly region: string;
396444

397445
/**
398446
* A reference to this Channel resource
@@ -568,6 +616,7 @@ export class Channel extends ChannelBase implements IChannel {
568616
public readonly channelName: string;
569617
public readonly channelArn: string;
570618
public readonly channelGroup?: IChannelGroup;
619+
public readonly region: string;
571620

572621
/**
573622
* The date and time the channel was created.
@@ -634,6 +683,7 @@ export class Channel extends ChannelBase implements IChannel {
634683
this.channelArn = channel.attrArn;
635684
this.createdAt = channel.attrCreatedAt;
636685
this.modifiedAt = channel.attrModifiedAt;
686+
this.region = Stack.of(this).region;
637687
this.ingestEndpointUrls = [Fn.select(0, channel.attrIngestEndpointUrls), Fn.select(1, channel.attrIngestEndpointUrls)];
638688

639689
channel.applyRemovalPolicy(props?.removalPolicy ?? RemovalPolicy.DESTROY);

packages/@aws-cdk/aws-mediapackagev2-alpha/lib/endpoint.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1880,6 +1880,15 @@ export interface OriginEndpointAttributes {
18801880
* @attribute
18811881
*/
18821882
readonly originEndpointName: string;
1883+
1884+
/**
1885+
* The AWS region where the origin endpoint lives.
1886+
*
1887+
* Required for cross-region imports to construct the correct ARN.
1888+
*
1889+
* @default - the importing stack's region
1890+
*/
1891+
readonly region?: string;
18831892
}
18841893

18851894
/**
@@ -2537,6 +2546,38 @@ export class Segment {
25372546
}
25382547

25392548
abstract class OriginEndpointBase extends Resource implements IOriginEndpoint {
2549+
/**
2550+
* Creates an OriginEndpoint construct that represents an external (imported) Origin Endpoint from its ARN.
2551+
*
2552+
* The ARN is expected to be in the format:
2553+
* `arn:<partition>:mediapackagev2:<region>:<account>:channelGroup/<groupName>/channel/<channelName>/originEndpoint/<endpointName>`
2554+
*/
2555+
public static fromOriginEndpointArn(scope: Construct, id: string, originEndpointArn: string): IOriginEndpoint {
2556+
if (Token.isUnresolved(originEndpointArn)) {
2557+
throw new ValidationError(
2558+
lit`TokenArnNotSupported`,
2559+
'Cannot parse a token ARN. Use OriginEndpoint.fromOriginEndpointAttributes() with explicit channelGroupName, channelName, originEndpointName, and region values instead.',
2560+
scope,
2561+
);
2562+
}
2563+
const parsedArn = Stack.of(scope).splitArn(originEndpointArn, ArnFormat.SLASH_RESOURCE_NAME);
2564+
// resourceName is "<groupName>/channel/<channelName>/originEndpoint/<endpointName>"
2565+
const [channelGroupName, , channelName, , originEndpointName] = parsedArn.resourceName?.split('/') ?? [];
2566+
if (!channelGroupName || !channelName || !originEndpointName) {
2567+
throw new ValidationError(
2568+
lit`InvalidOriginEndpointArn`,
2569+
`Could not parse origin endpoint ARN: ${originEndpointArn}. Expected format: arn:<partition>:mediapackagev2:<region>:<account>:channelGroup/<groupName>/channel/<channelName>/originEndpoint/<endpointName>`,
2570+
scope,
2571+
);
2572+
}
2573+
return OriginEndpointBase.fromOriginEndpointAttributes(scope, id, {
2574+
channelGroupName,
2575+
channelName,
2576+
originEndpointName,
2577+
region: parsedArn.region,
2578+
});
2579+
}
2580+
25402581
/**
25412582
* Creates an OriginEndpoint construct that represents an external (imported) Origin Endpoint.
25422583
*/
@@ -2563,6 +2604,7 @@ abstract class OriginEndpointBase extends Resource implements IOriginEndpoint {
25632604
resource: `channelGroup/${attrs.channelGroupName}/channel/${this.channelName}/originEndpoint`,
25642605
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
25652606
resourceName: this.originEndpointName,
2607+
region: attrs.region,
25662608
});
25672609
}
25682610

@@ -2887,6 +2929,23 @@ export class OriginEndpoint extends OriginEndpointBase implements IOriginEndpoin
28872929
});
28882930
});
28892931

2932+
// Validate manifest name uniqueness across all manifest types
2933+
const allManifestNames = [
2934+
...this.hlsManifests.map(m => m.manifestName),
2935+
...this.llHlsManifests.map(m => m.manifestName),
2936+
...this.dashManifests.map(m => m.manifestName),
2937+
...this.mssManifests.map(m => m.manifestName),
2938+
].filter(name => !Token.isUnresolved(name));
2939+
2940+
const duplicateNames = [...new Set(allManifestNames.filter((name, i) => allManifestNames.indexOf(name) !== i))];
2941+
if (duplicateNames.length > 0) {
2942+
throw new ValidationError(
2943+
lit`DuplicateManifestName`,
2944+
`Duplicate manifest names: [${duplicateNames.join(', ')}]. Each manifest in an OriginEndpoint must have a unique manifestName.`,
2945+
this,
2946+
);
2947+
}
2948+
28902949
// Validate manifest and container type compatibility
28912950
if (this.mssManifests.length > 0 && containerType !== ContainerType.ISM) {
28922951
throw new ValidationError(lit`MssRequiresIsm`, 'MSS manifests require ISM container type. Use Segment.ism() for MSS manifests.', this);

packages/@aws-cdk/aws-mediapackagev2-alpha/lib/group.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,12 +162,47 @@ export interface ChannelGroupAttributes {
162162
* @default - not available on imported channel groups
163163
*/
164164
readonly egressDomain?: string;
165+
166+
/**
167+
* The AWS region where the channel group lives.
168+
*
169+
* Required for cross-region imports to construct the correct ARN.
170+
*
171+
* @default - the importing stack's region
172+
*/
173+
readonly region?: string;
165174
}
166175

167176
/**
168177
* A new or imported Channel Group.
169178
*/
170179
abstract class ChannelGroupBase extends Resource implements IChannelGroup {
180+
/**
181+
* Creates a Channel Group construct that represents an external (imported) Channel Group from its ARN.
182+
*/
183+
public static fromChannelGroupArn(scope: Construct, id: string, channelGroupArn: string): IChannelGroup {
184+
if (Token.isUnresolved(channelGroupArn)) {
185+
throw new ValidationError(
186+
lit`TokenArnNotSupported`,
187+
'Cannot parse a token ARN. Use ChannelGroup.fromChannelGroupAttributes() with explicit channelGroupName and region values instead.',
188+
scope,
189+
);
190+
}
191+
const parsedArn = Stack.of(scope).splitArn(channelGroupArn, ArnFormat.SLASH_RESOURCE_NAME);
192+
const channelGroupName = parsedArn.resourceName;
193+
if (!channelGroupName) {
194+
throw new ValidationError(
195+
lit`InvalidChannelGroupArn`,
196+
`Could not parse channel group name from ARN: ${channelGroupArn}`,
197+
scope,
198+
);
199+
}
200+
return ChannelGroupBase.fromChannelGroupAttributes(scope, id, {
201+
channelGroupName,
202+
region: parsedArn.region,
203+
});
204+
}
205+
171206
/**
172207
* Creates a Channel Group construct that represents an external (imported) Channel Group.
173208
*/
@@ -189,6 +224,7 @@ abstract class ChannelGroupBase extends Resource implements IChannelGroup {
189224
resource: 'channelGroup',
190225
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
191226
resourceName: attrs.channelGroupName,
227+
region: attrs.region,
192228
});
193229

194230
public get egressDomain(): string {

packages/@aws-cdk/aws-mediapackagev2-alpha/test/channel.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,37 @@ test('existing Channel can be imported', () => {
8888
expect(importedChannel.channelArn).toMatch(/^arn:.*:mediapackagev2:us-east-1:123456789012:channelGroup\/MyChannelGroup\/channel\/test$/);
8989
});
9090

91+
test('imported Channel with cross-region override', () => {
92+
const importedChannel = mediapackagev2.Channel.fromChannelAttributes(stack, 'ImportedChannel', {
93+
channelName: 'test',
94+
channelGroupName: 'MyChannelGroup',
95+
region: 'eu-west-1',
96+
});
97+
98+
expect(importedChannel.channelArn).toMatch(/^arn:.*:mediapackagev2:eu-west-1:123456789012:channelGroup\/MyChannelGroup\/channel\/test$/);
99+
});
100+
101+
test('Channel can be imported from ARN', () => {
102+
const importedChannel = mediapackagev2.Channel.fromChannelArn(stack, 'ImportedChannel', 'arn:aws:mediapackagev2:eu-west-1:123456789012:channelGroup/MyGroup/channel/MyChannel');
103+
104+
expect(importedChannel.channelGroupName).toBe('MyGroup');
105+
expect(importedChannel.channelName).toBe('MyChannel');
106+
expect(importedChannel.channelArn).toMatch(/mediapackagev2:eu-west-1:123456789012/);
107+
expect(importedChannel.region).toBe('eu-west-1');
108+
});
109+
110+
test('Channel.fromChannelArn throws on invalid ARN', () => {
111+
expect(() => {
112+
mediapackagev2.Channel.fromChannelArn(stack, 'Bad', 'arn:aws:mediapackagev2:us-east-1:123456789012:channelGroup/MyGroup');
113+
}).toThrow(/Could not parse channel ARN/);
114+
});
115+
116+
test('Channel.fromChannelArn throws on token ARN', () => {
117+
expect(() => {
118+
mediapackagev2.Channel.fromChannelArn(stack, 'TokenArn', Lazy.string({ produce: () => 'arn:aws:mediapackagev2:us-east-1:123456789012:channelGroup/G/channel/C' }));
119+
}).toThrow(/Cannot parse a token ARN/);
120+
});
121+
91122
test('Channel has accessible ingest URLs - Tokens returned in Array', () => {
92123
const group = new mediapackagev2.ChannelGroup(stack, 'MyChannelGroup', {
93124
channelGroupName: 'test',
@@ -361,3 +392,32 @@ test('imported channel has undefined for createdAt and modifiedAt, throws for in
361392
expect(imported.modifiedAt).toBeUndefined();
362393
expect(() => imported.ingestEndpointUrls).toThrow(/ingestEndpointUrls.*is not available/);
363394
});
395+
396+
test('Channel exposes region from Stack', () => {
397+
const group = new mediapackagev2.ChannelGroup(stack, 'MyChannelGroup');
398+
const channel = new mediapackagev2.Channel(stack, 'myChannel', {
399+
channelGroup: group,
400+
input: mediapackagev2.InputConfiguration.cmaf(),
401+
});
402+
403+
expect(channel.region).toBe('us-east-1');
404+
});
405+
406+
test('imported channel region falls back to importing stack region', () => {
407+
const imported = mediapackagev2.Channel.fromChannelAttributes(stack, 'ImportedChannel3', {
408+
channelName: 'test',
409+
channelGroupName: 'MyChannelGroup',
410+
});
411+
412+
expect(imported.region).toBe('us-east-1');
413+
});
414+
415+
test('imported channel uses explicit region from attributes', () => {
416+
const imported = mediapackagev2.Channel.fromChannelAttributes(stack, 'ImportedChannel4', {
417+
channelName: 'test',
418+
channelGroupName: 'MyChannelGroup',
419+
region: 'eu-west-1',
420+
});
421+
422+
expect(imported.region).toBe('eu-west-1');
423+
});

0 commit comments

Comments
 (0)