-
Notifications
You must be signed in to change notification settings - Fork 63
Expand file tree
/
Copy pathstarter-farm-stack.test.ts
More file actions
249 lines (219 loc) · 9.26 KB
/
Copy pathstarter-farm-stack.test.ts
File metadata and controls
249 lines (219 loc) · 9.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// Tests for how this sample wires the reusable constructs together: which
// resources the farm has, and how its two queues differ. The constructs
// themselves are tested in test/deadline-constructs.test.ts.
import * as cdk from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { IConstruct } from 'constructs';
import {
condaChannelsDefault,
farmRoles,
inlineStatements,
logicalIdOf,
renderSynthesizedString,
} from './helpers';
import { ServiceManagedFleet } from '../lib/deadline';
import {
ALL_FLEET_PRESETS,
StarterFarmStack,
StarterFarmStackProps,
} from '../lib/starter-farm-stack';
const ENV = { account: '123456789012', region: 'us-west-2' };
function build(props: StarterFarmStackProps = {}): StarterFarmStack {
return new StarterFarmStack(new cdk.App(), 'TestFarm', { env: ENV, ...props });
}
function synth(props: StarterFarmStackProps = {}): Template {
return Template.fromStack(build(props));
}
describe('default farm', () => {
const template = synth();
test('creates one farm', () => {
template.resourceCountIs('AWS::Deadline::Farm', 1);
});
test('creates the production and package build queues', () => {
template.resourceCountIs('AWS::Deadline::Queue', 2);
template.hasResourceProperties('AWS::Deadline::Queue', {
DisplayName: 'Production Job Queue',
JobAttachmentSettings: { RootPrefix: 'DeadlineCloud' },
});
template.hasResourceProperties('AWS::Deadline::Queue', {
DisplayName: 'Package Build Queue',
JobAttachmentSettings: { RootPrefix: 'DeadlineCloudPkgBld' },
});
});
test('stores both queues and the Conda channel on one bucket', () => {
template.resourceCountIs('AWS::S3::Bucket', 1);
});
test('deploys only the CPU Linux fleet, associated with both queues', () => {
template.resourceCountIs('AWS::Deadline::Fleet', 1);
template.hasResourceProperties('AWS::Deadline::Fleet', {
DisplayName: 'CPU Linux Fleet',
});
template.resourceCountIs('AWS::Deadline::QueueFleetAssociation', 2);
});
test('creates a role per queue and a role for the fleet', () => {
expect(Object.keys(farmRoles(template))).toHaveLength(3);
});
test('outputs the IDs needed to submit a job', () => {
const outputs = Object.keys(template.findOutputs('*'));
expect(outputs).toEqual(
expect.arrayContaining([
'FarmId',
'ProductionQueueId',
'PackageBuildQueueId',
'JobAttachmentsBucketName',
'CondaChannelUrl',
'CpuLinuxFleetId',
]),
);
});
});
describe('the two queues', () => {
const template = synth();
test('only the package build queue can write the Conda channel', () => {
// A production job must not be able to modify the packages other jobs
// depend on, so the split between these two grants is the security boundary
// this farm's shape exists to create.
const stack = build();
const stackTemplate = Template.fromStack(stack);
const condaSid = (role: IConstruct) =>
inlineStatements(stackTemplate, role)
.map((statement) => statement.Sid)
.filter((sid) => sid?.startsWith('CondaChannel'));
expect(condaSid(stack.productionQueue.role)).toEqual(['CondaChannelReadOnly']);
expect(condaSid(stack.packageBuildQueue.role)).toEqual(['CondaChannelReadWrite']);
});
test('each owns a separate job attachments prefix', () => {
const prefixes = Object.values(template.findResources('AWS::Deadline::Queue')).map(
(queue) => queue.Properties.JobAttachmentSettings.RootPrefix,
);
expect(new Set(prefixes).size).toBe(prefixes.length);
});
test('only the production queue gets a Conda queue environment', () => {
// The package build job bundles bring their own Conda environment and
// define a CondaChannels parameter of their own, so a queue environment on
// that queue would define the same parameter name twice over. This matches
// the CloudFormation and Terraform starter farms.
const stack = build();
const stackTemplate = Template.fromStack(stack);
const environments = Object.values(
stackTemplate.findResources('AWS::Deadline::QueueEnvironment'),
);
expect(environments).toHaveLength(1);
const environment = environments[0].Properties;
expect(environment.QueueId).toEqual({
'Fn::GetAtt': [logicalIdOf(stack.productionQueue), 'QueueId'],
});
expect(environment.Priority).toBe(1);
expect(environment.TemplateType).toBe('YAML');
expect(condaChannelsDefault(renderSynthesizedString(environment.Template))).toContain(
's3://BUCKET/Conda/Default',
);
});
test('are assumable only by this farm in this account', () => {
const statements = Object.values(farmRoles(template)).flatMap(
(role) => role.Properties.AssumeRolePolicyDocument.Statement,
);
expect(statements.length).toBeGreaterThan(0);
for (const statement of statements) {
expect(statement.Condition.StringEquals['aws:SourceAccount']).toBe(ENV.account);
expect(statement.Condition.ArnEquals['aws:SourceArn']).toBeDefined();
}
});
});
describe('Conda channels', () => {
test('the production queue installs from the private channel first', () => {
const rendered = renderSynthesizedString(condaEnvironmentTemplate(build()));
expect(condaChannelsDefault(rendered)).toBe('s3://BUCKET/Conda/Default deadline-cloud');
});
test('extra channels passed to the stack are appended', () => {
const rendered = renderSynthesizedString(
condaEnvironmentTemplate(build({ condaChannels: ['deadline-cloud', 'conda-forge'] })),
);
expect(condaChannelsDefault(rendered)).toBe(
's3://BUCKET/Conda/Default deadline-cloud conda-forge',
);
});
});
describe('fleet selection', () => {
test('every preset can be deployed at once', () => {
const template = synth({ fleets: ALL_FLEET_PRESETS });
template.resourceCountIs('AWS::Deadline::Fleet', 3);
// Each fleet is associated with both queues.
template.resourceCountIs('AWS::Deadline::QueueFleetAssociation', 6);
// Two queue roles plus a worker role per fleet, so no fleet's permissions
// reach another's workers.
expect(Object.keys(farmRoles(template))).toHaveLength(5);
});
test('a preset each covers Linux, Windows, and GPU work', () => {
const template = synth({ fleets: ALL_FLEET_PRESETS });
const fleets = Object.values(template.findResources('AWS::Deadline::Fleet')).map(
(fleet) => fleet.Properties,
);
const byName = new Map(fleets.map((fleet) => [fleet.DisplayName, fleet]));
expect(byName.get('CPU Linux Fleet').Configuration.ServiceManagedEc2.InstanceCapabilities
.OsFamily).toBe('LINUX');
expect(byName.get('CPU Windows Fleet').Configuration.ServiceManagedEc2.InstanceCapabilities
.OsFamily).toBe('WINDOWS');
expect(byName.get('CUDA Linux Fleet').Configuration.ServiceManagedEc2.InstanceCapabilities
.AcceleratorCapabilities).toBeDefined();
});
test('addFleet extends the farm with hardware the presets do not cover', () => {
const stack = build();
stack.addFleet(
new ServiceManagedFleet(stack, 'ArmLinuxFleet', {
farm: stack.farm,
displayName: 'ARM Linux Fleet',
osFamily: 'LINUX',
cpuArchitecture: 'arm64',
maxWorkerCount: 20,
vCpuCount: { min: 4, max: 16 },
memoryMiB: { min: 8192 },
}),
);
const template = Template.fromStack(stack);
expect(stack.fleets).toHaveLength(2);
template.resourceCountIs('AWS::Deadline::Fleet', 2);
// The added fleet is associated with both queues and gets an output, the
// same as a preset one.
template.resourceCountIs('AWS::Deadline::QueueFleetAssociation', 4);
template.hasOutput('ArmLinuxFleetId', Match.anyValue());
});
test('a fleet ID that cannot name an output is rejected with a clear message', () => {
// Two fleets whose IDs differ only in punctuation would collide on one
// output name, which CDK reports without naming the constructs at fault.
const stack = build();
expect(() =>
stack.addFleet(
new ServiceManagedFleet(stack, 'arm-linux', {
farm: stack.farm,
displayName: 'ARM Linux Fleet',
osFamily: 'LINUX',
maxWorkerCount: 1,
vCpuCount: { min: 4 },
memoryMiB: { min: 8192 },
}),
),
).toThrow(/only letters and digits/);
});
});
describe('job attachments bucket', () => {
test('is deleted with the stack, and emptied so the deletion succeeds', () => {
const template = synth();
for (const bucket of Object.values(template.findResources('AWS::S3::Bucket'))) {
expect(bucket.DeletionPolicy).toBe('Delete');
}
template.resourceCountIs('Custom::S3AutoDeleteObjects', 1);
});
});
/** The synthesized body of the production queue's Conda queue environment. */
function condaEnvironmentTemplate(stack: StarterFarmStack): unknown {
const wanted = JSON.stringify({
'Fn::GetAtt': [logicalIdOf(stack.productionQueue), 'QueueId'],
});
const environments = Object.values(
Template.fromStack(stack).findResources('AWS::Deadline::QueueEnvironment'),
).filter((environment) => JSON.stringify(environment.Properties.QueueId) === wanted);
expect(environments).toHaveLength(1);
return environments[0].Properties.Template;
}