packages/@aws-cdk/toolkit-lib/lib/api/refactoring/digest.ts (130 lines of code) (raw):

import * as crypto from 'node:crypto'; import { loadResourceModel } from '@aws-cdk/cloudformation-diff/lib/diff/util'; import type { CloudFormationTemplate } from './cloudformation'; /** * Computes the digest for each resource in the template. * * Conceptually, the digest is computed as: * * d(resource) = hash(type + physicalId) , if physicalId is defined * = hash(type + properties + dependencies.map(d)) , otherwise * * where `hash` is a cryptographic hash function. In other words, if a resource has * a physical ID, we use the physical ID plus its type to uniquely identify * that resource. In this case, the digest can be computed from these two fields * alone. A corollary is that such resources can be renamed and have their * properties updated at the same time, and still be considered equivalent. * * Otherwise, the digest is computed from its type, its own properties (that is, * excluding properties that refer to other resources), and the digests of each of * its dependencies. * * The digest of a resource, defined recursively this way, remains stable even if * one or more of its dependencies gets renamed. Since the resources in a * CloudFormation template form a directed acyclic graph, this function is * well-defined. */ export function computeResourceDigests(template: CloudFormationTemplate): Record<string, string> { const resources = template.Resources || {}; const graph: Record<string, Set<string>> = {}; const reverseGraph: Record<string, Set<string>> = {}; // 1. Build adjacency lists for (const id of Object.keys(resources)) { graph[id] = new Set(); reverseGraph[id] = new Set(); } // 2. Detect dependencies by searching for Ref/Fn::GetAtt const findDependencies = (value: any): string[] => { if (!value || typeof value !== 'object') return []; if (Array.isArray(value)) { return value.flatMap(findDependencies); } if ('Ref' in value) { return [value.Ref]; } if ('Fn::GetAtt' in value) { const refTarget = Array.isArray(value['Fn::GetAtt']) ? value['Fn::GetAtt'][0] : value['Fn::GetAtt'].split('.')[0]; return [refTarget]; } if ('DependsOn' in value) { return [value.DependsOn]; } return Object.values(value).flatMap(findDependencies); }; for (const [id, res] of Object.entries(resources)) { const deps = findDependencies(res || {}); for (const dep of deps) { if (dep in resources && dep !== id) { graph[id].add(dep); reverseGraph[dep].add(id); } } } // 3. Topological sort const outDegree = Object.keys(graph).reduce((acc, k) => { acc[k] = graph[k].size; return acc; }, {} as Record<string, number>); const queue = Object.keys(outDegree).filter((k) => outDegree[k] === 0); const order: string[] = []; while (queue.length > 0) { const node = queue.shift()!; order.push(node); for (const nxt of reverseGraph[node]) { outDegree[nxt]--; if (outDegree[nxt] === 0) { queue.push(nxt); } } } // 4. Compute digests in sorted order const result: Record<string, string> = {}; for (const id of order) { const resource = resources[id]; const resourceProperties = resource.Properties ?? {}; const model = loadResourceModel(resource.Type); const identifier = intersection(Object.keys(resourceProperties), model?.primaryIdentifier ?? []); let toHash: string; if (identifier.length === model?.primaryIdentifier?.length) { // The resource has a physical ID defined, so we can // use the ID and the type as the identity of the resource. toHash = resource.Type + identifier .sort() .map((attr) => JSON.stringify(resourceProperties[attr])) .join(''); } else { // The resource does not have a physical ID defined, so we need to // compute the digest based on its properties and dependencies. const depDigests = Array.from(graph[id]).map((d) => result[d]); const propertiesHash = hashObject(stripReferences(stripConstructPath(resource))); toHash = resource.Type + propertiesHash + depDigests.join(''); } result[id] = crypto.createHash('sha256').update(toHash).digest('hex'); } return result; } export function hashObject(obj: any): string { const hash = crypto.createHash('sha256'); function addToHash(value: any) { if (value == null) { addToHash('null'); } else if (typeof value === 'object') { if (Array.isArray(value)) { value.forEach(addToHash); } else { Object.keys(value) .sort() .forEach((key) => { hash.update(key); addToHash(value[key]); }); } } else { hash.update(typeof value + value.toString()); } } addToHash(obj); return hash.digest('hex'); } /** * Removes sub-properties containing Ref or Fn::GetAtt to avoid hashing * references themselves but keeps the property structure. */ function stripReferences(value: any): any { if (!value || typeof value !== 'object') return value; if (Array.isArray(value)) { return value.map(stripReferences); } if ('Ref' in value) { return { __cloud_ref__: 'Ref' }; } if ('Fn::GetAtt' in value) { return { __cloud_ref__: 'Fn::GetAtt' }; } if ('DependsOn' in value) { return { __cloud_ref__: 'DependsOn' }; } const result: any = {}; for (const [k, v] of Object.entries(value)) { result[k] = stripReferences(v); } return result; } function stripConstructPath(resource: any): any { if (resource?.Metadata?.['aws:cdk:path'] == null) { return resource; } const copy = JSON.parse(JSON.stringify(resource)); delete copy.Metadata['aws:cdk:path']; return copy; } function intersection<T>(a: T[], b: T[]): T[] { return a.filter((value) => b.includes(value)); }