Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -786,17 +786,7 @@ class CloudControlHotswapStack extends cdk.Stack {
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
cdk.Tags.of(queue).add('DynamoTableArn', table.tableArn);
// TEMPORARILY DISABLED — do not re-enable without the CCAPI tag fix.
// Changing this tag makes `Tags` the Queue's only changed property, so the CCAPI
// hotswap emits `replace /Tags` with just the template-defined tags. Since 2026-09-01
// that fails against a CloudFormation-created queue with:
// ValidationException: aws: prefixed tag key names are not allowed for external use
// because reconciling to a tag set that omits the queue's reserved
// `aws:cloudformation:*` tags implies removing them, which SQS forbids
// (https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-queues.html).
// With this tag static, the Queue has no hotswappable change and the Dashboard and
// Rule still exercise the CCAPI path. This drops Queue/`Tags` hotswap coverage.
// cdk.Tags.of(queue).add('DynamicTag', process.env.DYNAMIC_CC_PROPERTY_VALUE ?? 'original');
cdk.Tags.of(queue).add('DynamicTag', process.env.DYNAMIC_CC_PROPERTY_VALUE ?? 'original');

// CloudWatch Dashboard — hotswapped via CCAPI, references the DynamoDB table name.
// (This used to be an AWS::Bedrock::Agent, but Bedrock Agents Classic went into
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export async function isHotswappableCloudControlChange(
patchOps.push({ op: 'remove', path: `/${propName}` });
} else if (diff.isAddition) {
patchOps.push({ op: 'add', path: `/${propName}`, value: newValue });
} else if (propName === 'Tags' && newValue && typeof newValue === 'object') {
patchOps.push(...await buildTagPatchOps(cloudControl, resourceType, identifier, newValue));
} else {
patchOps.push({ op: 'replace', path: `/${propName}`, value: newValue });
}
Expand All @@ -108,6 +110,186 @@ export async function isHotswappableCloudControlChange(
return ret;
}

/**
* Tag keys beginning with `aws:` are reserved for AWS-managed tags.
* They are read-only: a service rejects any external attempt to
* create, update or delete them.
*/
function isReservedTagKey(key: unknown): boolean {
return typeof key === 'string' && key.toLowerCase().startsWith('aws:');
}

/**
* Escape a single JSON Pointer reference token (RFC 6901): `~` becomes `~0` and `/` becomes
* `~1`. Required because AWS tag keys may legally contain `/`, which would otherwise be read
* as a path separator.
*/
function escapeJsonPointerToken(key: string): string {
return key.replace(/~/g, '~0').replace(/\//g, '~1');
}

/**
* Build the patch operations for a changed `Tags` property.
*
* Address tags individually so reserved tags are never named by the patch and the
* service sees no change to them. That needs the current state, which is read via Cloud
* Control `GetResource`.
*
* CloudFormation models `Tags` either as a list of `{ Key, Value }` (addressed by index) or as
* a `{ key: value }` map (addressed by key). Both are handled.
*/
async function buildTagPatchOps(
cloudControl: ReturnType<SDK['cloudControl']>,
resourceType: string,
identifier: string,
desiredTags: any,
): Promise<Array<{ op: string; path: string; value?: any }>> {
const readTags = await currentResourceTags(cloudControl, resourceType, identifier);
const desiredIsList = Array.isArray(desiredTags);
const currentTags = readTags ?? (desiredIsList ? [] : {});

if (desiredIsList && Array.isArray(currentTags)) {
return buildListTagPatchOps(currentTags, desiredTags);
}
if (!desiredIsList && !Array.isArray(currentTags) && typeof currentTags === 'object') {
return buildMapTagPatchOps(currentTags as Record<string, any>, desiredTags);
}

// The live resource reports `Tags` in a shape the template does not declare. A
// resource type does not change its tag shape, so this is not expected to happen.
throw new ToolkitError(
'HotswapTagReadFailed',
`could not interpret the current tags of ${identifier} (${resourceType}): the resource reports Tags as ${describeTagsShape(currentTags)} but the template declares ${describeTagsShape(desiredTags)}`,
);
}

/**
* Describe the shape of a `Tags` value, for error messages.
*/
function describeTagsShape(tags: unknown): string {
if (Array.isArray(tags)) {
return 'a list';
}
if (tags === null) {
return 'null';
}
if (typeof tags === 'object') {
return 'a map';
}
return typeof tags; // "string", "number", "undefined", etc.
}
/**
* `Tags` as a `{ key: value }` map: address each tag by its key. Object members have no
* position, so unlike the list form there is no ordering constraint between operations.
*/
function buildMapTagPatchOps(
currentTags: Record<string, any>,
desiredTags: Record<string, any>,
): Array<{ op: string; path: string; value?: any }> {
const ops: Array<{ op: string; path: string; value?: any }> = [];

for (const [key, value] of Object.entries(desiredTags)) {
const path = `/Tags/${escapeJsonPointerToken(key)}`;
if (!Object.hasOwn(currentTags, key)) {
ops.push({ op: 'add', path, value });
} else if (JSON.stringify(currentTags[key]) !== JSON.stringify(value)) {
ops.push({ op: 'replace', path, value });
}
}

// Drop tags the template no longer defines — but never the reserved ones.
for (const key of Object.keys(currentTags)) {
if (!Object.hasOwn(desiredTags, key) && !isReservedTagKey(key)) {
ops.push({ op: 'remove', path: `/Tags/${escapeJsonPointerToken(key)}` });
}
}

return ops;
}

/**
* `Tags` as a list of `{ Key, Value }`: address each tag by its index in the resource's
* current list.
*/
function buildListTagPatchOps(
currentTags: any[],
desiredTags: any[],
): Array<{ op: string; path: string; value?: any }> {
const isTag = (tag: any): boolean => tag && typeof tag === 'object' && typeof tag.Key === 'string';

const indexByKey = new Map<string, number>();
currentTags.forEach((tag, i) => {
if (isTag(tag) && !indexByKey.has(tag.Key)) {
indexByKey.set(tag.Key, i);
}
});

// Update tags that already exist (addressed by index); append the ones that don't.
const replacements: Array<{ op: string; path: string; value?: any }> = [];
const additions: Array<{ op: string; path: string; value?: any }> = [];
for (const tag of desiredTags) {
if (!isTag(tag)) {
continue;
}
const index = indexByKey.get(tag.Key);
if (index === undefined) {
additions.push({ op: 'add', path: '/Tags/-', value: tag });
} else if (JSON.stringify(currentTags[index]) !== JSON.stringify(tag)) {
replacements.push({ op: 'replace', path: `/Tags/${index}`, value: tag });
}
}

// Drop tags the template no longer defines — but never the reserved ones.
const desiredKeys = new Set(desiredTags.filter(isTag).map((tag) => tag.Key));
const removals = currentTags
.map((tag, i) => ({ tag, i }))
.filter(({ tag }) => isTag(tag) && !desiredKeys.has(tag.Key) && !isReservedTagKey(tag.Key))
// Descending, so removing one does not shift the indices of the others.
.sort((a, b) => b.i - a.i)
.map(({ i }) => ({ op: 'remove', path: `/Tags/${i}` }));

// Replacements first (their indices refer to the unmodified list), then removals, then
// appends, which only ever touch the end of the list.
return [...replacements, ...removals, ...additions];
}

/**
* Read the current `Tags` value of a resource via Cloud Control `GetResource`, which returns
* the resource model as a JSON string in `ResourceDescription.Properties`.
*
* Returns `undefined` when the resource simply has no tags. Throws when the current tags
* cannot be determined at all
*/
async function currentResourceTags(
cloudControl: ReturnType<SDK['cloudControl']>,
resourceType: string,
identifier: string,
): Promise<unknown> {
const unreadable = (cause: unknown) => ToolkitError.withCause(
'HotswapTagReadFailed',
`could not read the current tags of ${identifier} (${resourceType}), which are needed to update tags without removing the reserved aws: tags that AWS manages - ensure the deployment role is allowed to call cloudcontrolapi:GetResource for this resource type`,
cause,
);

let current;
try {
current = await cloudControl.getResource({ TypeName: resourceType, Identifier: identifier });
} catch (e) {
throw unreadable(e);
}

const properties = current.ResourceDescription?.Properties;
if (!properties) {
throw unreadable(new Error('GetResource returned no resource properties'));
}

try {
return JSON.parse(properties).Tags;
} catch (e) {
throw unreadable(e);
}
}

/**
* Resolves the Cloud Control API identifier for a resource.
*
Expand Down
Loading
Loading