Skip to content

Commit 465dabf

Browse files
authored
fix(pipelines): can't use exports from very long stack names (#18039)
The variable namespace identifier in CodePipeline allows a maximum of 100 characters. If we ever come up with an identifier that would be too long, trim it down. While writing tests for this, discovered that actions exhibited the same length issues, and did the same there too. Fixes #17436. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent f11766e commit 465dabf

File tree

6 files changed

+130
-26
lines changed

6 files changed

+130
-26
lines changed

packages/@aws-cdk/pipelines/lib/codepipeline/_codebuild-factory.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import * as crypto from 'crypto';
21
import * as fs from 'fs';
32
import * as path from 'path';
43
import * as codebuild from '@aws-cdk/aws-codebuild';
@@ -8,9 +7,10 @@ import * as ec2 from '@aws-cdk/aws-ec2';
87
import * as iam from '@aws-cdk/aws-iam';
98
import { IDependable, Stack } from '@aws-cdk/core';
109
import { Construct, Node } from 'constructs';
11-
import { FileSetLocation, ShellStep, StackDeployment, StackOutputReference } from '../blueprint';
10+
import { FileSetLocation, ShellStep, StackOutputReference } from '../blueprint';
1211
import { PipelineQueries } from '../helpers-internal/pipeline-queries';
1312
import { cloudAssemblyBuildSpecDir, obtainScope } from '../private/construct-internals';
13+
import { hash, stackVariableNamespace } from '../private/identifiers';
1414
import { mapValues, mkdict, noEmptyObject, noUndefined, partition } from '../private/javascript';
1515
import { ArtifactMap } from './artifact-map';
1616
import { CodeBuildStep } from './codebuild-step';
@@ -426,12 +426,6 @@ function isDefined<A>(x: A | undefined): x is NonNullable<A> {
426426
return x !== undefined;
427427
}
428428

429-
function hash<A>(obj: A) {
430-
const d = crypto.createHash('sha256');
431-
d.update(JSON.stringify(obj));
432-
return d.digest('hex');
433-
}
434-
435429
/**
436430
* Serialize a build environment to data (get rid of constructs & objects), so we can JSON.stringify it
437431
*/
@@ -447,10 +441,6 @@ function serializeBuildEnvironment(env: codebuild.BuildEnvironment) {
447441
};
448442
}
449443

450-
export function stackVariableNamespace(stack: StackDeployment) {
451-
return stack.stackArtifactId;
452-
}
453-
454444
/**
455445
* Whether the given string contains a reference to a CodePipeline variable
456446
*/

packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ import * as cxapi from '@aws-cdk/cx-api';
99
import { Construct, Node } from 'constructs';
1010
import { AssetType, FileSet, IFileSetProducer, ManualApprovalStep, ShellStep, StackAsset, StackDeployment, Step } from '../blueprint';
1111
import { DockerCredential, dockerCredentialsInstallCommands, DockerCredentialUsage } from '../docker-credentials';
12-
import { GraphNode, GraphNodeCollection, isGraph, AGraphNode, PipelineGraph } from '../helpers-internal';
12+
import { GraphNodeCollection, isGraph, AGraphNode, PipelineGraph } from '../helpers-internal';
1313
import { PipelineBase } from '../main';
1414
import { AssetSingletonRole } from '../private/asset-singleton-role';
1515
import { appOf, assemblyBuilderOf, embeddedAsmPath, obtainScope } from '../private/construct-internals';
1616
import { toPosixPath } from '../private/fs';
17+
import { actionName, stackVariableNamespace } from '../private/identifiers';
1718
import { enumerate, flatten, maybeSuffix, noUndefined } from '../private/javascript';
1819
import { writeTemplateConfiguration } from '../private/template-configuration';
19-
import { CodeBuildFactory, mergeCodeBuildOptions, stackVariableNamespace } from './_codebuild-factory';
20+
import { CodeBuildFactory, mergeCodeBuildOptions } from './_codebuild-factory';
2021
import { ArtifactMap } from './artifact-map';
2122
import { CodeBuildStep } from './codebuild-step';
2223
import { CodePipelineActionFactoryResult, ICodePipelineActionFactory } from './codepipeline-action-factory';
@@ -839,15 +840,6 @@ enum CodeBuildProjectType {
839840
STEP = 'STEP',
840841
}
841842

842-
function actionName<A>(node: GraphNode<A>, parent: GraphNode<A>) {
843-
const names = node.ancestorPath(parent).map(n => n.id);
844-
return names.map(sanitizeName).join('.').substr(0, 100); // Cannot exceed 100 chars
845-
}
846-
847-
function sanitizeName(x: string): string {
848-
return x.replace(/[^A-Za-z0-9.@\-_]/g, '_');
849-
}
850-
851843
/**
852844
* Take a set of tranches and split them up into groups so
853845
* that no set of tranches has more than n items total
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as crypto from 'crypto';
2+
import { StackDeployment } from '../blueprint/stack-deployment';
3+
import { GraphNode } from '../helpers-internal/graph';
4+
5+
export function hash<A>(obj: A) {
6+
const d = crypto.createHash('sha256');
7+
d.update(JSON.stringify(obj));
8+
return d.digest('hex');
9+
}
10+
11+
export function actionName<A>(node: GraphNode<A>, parent: GraphNode<A>) {
12+
const names = node.ancestorPath(parent).map(n => n.id).map(sanitizeName);
13+
14+
// Something slightly complicated here:
15+
//
16+
// Because we'll have structured action names like:
17+
//
18+
// 'VeryLongStackName.Prepare', 'VeryLongStackName.Deploy'
19+
//
20+
// it would be shitty if cut and hashed them differently:
21+
//
22+
// 'VeryLongSAAAAA.Prepare', 'VeryLonBBBBBme.Deploy'
23+
//
24+
// wouldn't sort and comprehend nicely. We will therefore trim each component individually.
25+
const totalMax = 100; // Max length of everything
26+
27+
// No need to do anything
28+
if (names.join('.').length <= totalMax) {
29+
return names.join('.');
30+
}
31+
32+
const componentMin = 15; // Can't really go shorter than this, becomes unreadable
33+
const dots = names.length - 1;
34+
const maxLength = Math.max(componentMin, Math.floor((totalMax - dots) / names.length));
35+
const trimmedNames = names.map(name => limitIdentifierLength(name, maxLength));
36+
37+
return limitIdentifierLength(trimmedNames.join('.'), totalMax); // Final trim in case we couldn't make it
38+
}
39+
40+
export function stackVariableNamespace(stack: StackDeployment) {
41+
return limitIdentifierLength(stack.stackArtifactId, 100);
42+
}
43+
44+
function sanitizeName(x: string): string {
45+
return x.replace(/[^A-Za-z0-9.@\-_]/g, '_');
46+
}
47+
48+
49+
/**
50+
* Makes sure the given identifier length does not exceed N characters
51+
*
52+
* Replaces characters in the middle (to leave the start and end identifiable) and replaces
53+
* them with a hash to prevent collissions.
54+
*/
55+
export function limitIdentifierLength(s: string, n: number): string {
56+
if (s.length <= n) { return s; }
57+
const h = hash(s).substr(0, 8);
58+
const mid = Math.floor((n - h.length) / 2);
59+
60+
return s.substr(0, mid) + h + s.substr(s.length - mid);
61+
}

packages/@aws-cdk/pipelines/test/codepipeline/codebuild-step.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Template, Match } from '@aws-cdk/assertions';
22
import { Stack } from '@aws-cdk/core';
33
import * as cdkp from '../../lib';
4-
import { PIPELINE_ENV, TestApp } from '../testhelpers';
4+
import { PIPELINE_ENV, TestApp, ModernTestGitHubNpmPipeline, AppWithOutput } from '../testhelpers';
55

66
let app: TestApp;
77
let pipelineStack: Stack;
@@ -41,4 +41,25 @@ test('additionalinputs creates the right commands', () => {
4141
})),
4242
},
4343
});
44+
});
45+
46+
test('envFromOutputs works even with very long stage and stack names', () => {
47+
const pipeline = new ModernTestGitHubNpmPipeline(pipelineStack, 'Cdk');
48+
49+
const myApp = new AppWithOutput(app, 'Alpha'.repeat(20), {
50+
stackId: 'Stack'.repeat(20),
51+
});
52+
53+
pipeline.addStage(myApp, {
54+
post: [
55+
new cdkp.ShellStep('Approve', {
56+
commands: ['/bin/true'],
57+
envFromCfnOutputs: {
58+
THE_OUTPUT: myApp.theOutput,
59+
},
60+
}),
61+
],
62+
});
63+
64+
// THEN - did not throw an error about identifier lengths
4465
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Graph } from '../../lib/helpers-internal';
2+
import { actionName } from '../../lib/private/identifiers';
3+
import { mkGraph } from '../blueprint/helpers-internal/util';
4+
5+
test('actionName trims subcomponents the same way', () => {
6+
const long1 = 'ExtremelyLong'.repeat(10);
7+
const long2 = 'AlsoLong'.repeat(10);
8+
9+
const g = mkGraph('MyGraph', G => {
10+
G.graph(long1, [], G1 => {
11+
G1.graph(long2, [], G2 => {
12+
G2.node('Prepare');
13+
G2.node('Deploy');
14+
});
15+
});
16+
});
17+
18+
const G2 = ((g.tryGetChild(long1) as Graph<any>)?.tryGetChild(long2) as Graph<any>);
19+
expect(G2).toBeDefined();
20+
21+
const prep = G2.tryGetChild('Prepare');
22+
const deploy = G2.tryGetChild('Deploy');
23+
24+
expect(prep).toBeDefined();
25+
expect(deploy).toBeDefined();
26+
27+
// ActionNames have the same prefix
28+
const prepParts = actionName(prep!, g).split('.');
29+
const deployParts = actionName(deploy!, g).split('.');
30+
31+
// Final parts are unchanged
32+
expect(prepParts.pop()).toEqual('Prepare');
33+
expect(deployParts.pop()).toEqual('Deploy');
34+
// Prefixes are the same
35+
expect(prepParts).toEqual(deployParts);
36+
});

packages/@aws-cdk/pipelines/test/testhelpers/test-app.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,17 @@ export class OneStackApp extends Stage {
6262
}
6363
}
6464

65+
export interface AppWithOutputProps extends StageProps {
66+
readonly stackId?: string;
67+
}
68+
6569
export class AppWithOutput extends Stage {
6670
public readonly theOutput: CfnOutput;
6771

68-
constructor(scope: Construct, id: string, props?: StageProps) {
72+
constructor(scope: Construct, id: string, props: AppWithOutputProps = {}) {
6973
super(scope, id, props);
7074

71-
const stack = new BucketStack(this, 'Stack');
75+
const stack = new BucketStack(this, props.stackId ?? 'Stack');
7276
this.theOutput = new CfnOutput(stack, 'MyOutput', { value: stack.bucket.bucketName });
7377
}
7478
}

0 commit comments

Comments
 (0)