-
Notifications
You must be signed in to change notification settings - Fork 692
Expand file tree
/
Copy pathOperationBuildCache.ts
More file actions
444 lines (387 loc) · 16.1 KB
/
Copy pathOperationBuildCache.ts
File metadata and controls
444 lines (387 loc) · 16.1 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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { FileSystem, type FolderItem, InternalError, Async } from '@rushstack/node-core-library';
import type { ITerminal } from '@rushstack/terminal';
import type { RushConfigurationProject } from '../../api/RushConfigurationProject';
import type { BuildCacheConfiguration } from '../../api/BuildCacheConfiguration';
import type { ICloudBuildCacheProvider } from './ICloudBuildCacheProvider';
import type { FileSystemBuildCacheProvider } from './FileSystemBuildCacheProvider';
import { TarExecutable } from '../../utilities/TarExecutable';
import { EnvironmentVariableNames } from '../../api/EnvironmentConfiguration';
import type { IOperationExecutionResult } from '../operations/IOperationExecutionResult';
/**
* @internal
*/
export interface IOperationBuildCacheOptions {
/**
* The repo-wide configuration for the build cache.
*/
buildCacheConfiguration: BuildCacheConfiguration;
/**
* The terminal to use for logging.
*/
terminal: ITerminal;
/**
* If true, omit AppleDouble (`._*`) files from cache archives when running on macOS
* and a companion file exists in the same directory.
*/
excludeAppleDoubleFiles: boolean;
}
/**
* @internal
*/
export type IProjectBuildCacheOptions = IOperationBuildCacheOptions & {
/**
* Value from rush-project.json
*/
projectOutputFolderNames: ReadonlyArray<string>;
/**
* The project to be cached.
*/
project: RushConfigurationProject;
/**
* The hash of all relevant inputs and configuration that uniquely identifies this execution.
*/
operationStateHash: string;
/**
* The name of the phase that is being cached.
*/
phaseName: string;
};
interface IPathsToCache {
filteredOutputFolderNames: string[];
outputFilePaths: string[];
}
/**
* @internal
*/
export class OperationBuildCache {
private static _tarUtilityPromise: Promise<TarExecutable | undefined> | undefined;
private readonly _project: RushConfigurationProject;
private readonly _localBuildCacheProvider: FileSystemBuildCacheProvider;
private readonly _cloudBuildCacheProvider: ICloudBuildCacheProvider | undefined;
private readonly _buildCacheEnabled: boolean;
private readonly _cacheWriteEnabled: boolean;
private readonly _projectOutputFolderNames: ReadonlyArray<string>;
private readonly _cacheId: string | undefined;
private readonly _excludeAppleDoubleFiles: boolean;
private constructor(cacheId: string | undefined, options: IProjectBuildCacheOptions) {
const {
buildCacheConfiguration: {
localCacheProvider,
cloudCacheProvider,
buildCacheEnabled,
cacheWriteEnabled
},
project,
projectOutputFolderNames,
excludeAppleDoubleFiles
} = options;
this._project = project;
this._localBuildCacheProvider = localCacheProvider;
this._cloudBuildCacheProvider = cloudCacheProvider;
this._buildCacheEnabled = buildCacheEnabled;
this._cacheWriteEnabled = cacheWriteEnabled;
this._projectOutputFolderNames = projectOutputFolderNames || [];
this._cacheId = cacheId;
this._excludeAppleDoubleFiles = excludeAppleDoubleFiles && process.platform === 'darwin';
}
private static _tryGetTarUtility(terminal: ITerminal): Promise<TarExecutable | undefined> {
if (!OperationBuildCache._tarUtilityPromise) {
OperationBuildCache._tarUtilityPromise = TarExecutable.tryInitializeAsync(terminal);
}
return OperationBuildCache._tarUtilityPromise;
}
public get cacheId(): string | undefined {
return this._cacheId;
}
public static getOperationBuildCache(options: IProjectBuildCacheOptions): OperationBuildCache {
const cacheId: string | undefined = OperationBuildCache._getCacheId(options);
return new OperationBuildCache(cacheId, options);
}
public static forOperation(
executionResult: IOperationExecutionResult,
options: IOperationBuildCacheOptions
): OperationBuildCache {
const { buildCacheConfiguration, terminal, excludeAppleDoubleFiles } = options;
const outputFolders: string[] = [...(executionResult.operation.settings?.outputFolderNames ?? [])];
if (executionResult.metadataFolderPath) {
outputFolders.push(executionResult.metadataFolderPath);
}
const buildCacheOptions: IProjectBuildCacheOptions = {
buildCacheConfiguration,
terminal,
project: executionResult.operation.associatedProject,
phaseName: executionResult.operation.associatedPhase.name,
projectOutputFolderNames: outputFolders,
operationStateHash: executionResult.getStateHash(),
excludeAppleDoubleFiles
};
const cacheId: string | undefined = OperationBuildCache._getCacheId(buildCacheOptions);
return new OperationBuildCache(cacheId, buildCacheOptions);
}
public async tryRestoreFromCacheAsync(terminal: ITerminal, specifiedCacheId?: string): Promise<boolean> {
const cacheId: string | undefined = specifiedCacheId || this._cacheId;
if (!cacheId) {
terminal.writeWarningLine('Unable to get cache ID. Ensure Git is installed.');
return false;
}
if (!this._buildCacheEnabled) {
// Skip reading local and cloud build caches, without any noise
return false;
}
let localCacheEntryPath: string | undefined =
await this._localBuildCacheProvider.tryGetCacheEntryPathByIdAsync(terminal, cacheId);
let cacheEntryBuffer: Buffer | undefined;
let updateLocalCacheSuccess: boolean | undefined;
if (!localCacheEntryPath && this._cloudBuildCacheProvider) {
terminal.writeVerboseLine(
'This project was not found in the local build cache. Querying the cloud build cache.'
);
cacheEntryBuffer = await this._cloudBuildCacheProvider.tryGetCacheEntryBufferByIdAsync(
terminal,
cacheId
);
if (cacheEntryBuffer) {
try {
localCacheEntryPath = await this._localBuildCacheProvider.trySetCacheEntryBufferAsync(
terminal,
cacheId,
cacheEntryBuffer
);
updateLocalCacheSuccess = true;
} catch (e) {
updateLocalCacheSuccess = false;
}
}
}
if (!localCacheEntryPath && !cacheEntryBuffer) {
terminal.writeVerboseLine('This project was not found in the build cache.');
return false;
}
terminal.writeLine('Build cache hit.');
terminal.writeVerboseLine(`Cache key: ${cacheId}`);
const projectFolderPath: string = this._project.projectFolder;
// Purge output folders
terminal.writeVerboseLine(`Clearing cached folders: ${this._projectOutputFolderNames.join(', ')}`);
await Promise.all(
this._projectOutputFolderNames.map((outputFolderName: string) =>
FileSystem.deleteFolderAsync(`${projectFolderPath}/${outputFolderName}`)
)
);
const tarUtility: TarExecutable | undefined = await OperationBuildCache._tryGetTarUtility(terminal);
let restoreSuccess: boolean = false;
if (tarUtility && localCacheEntryPath) {
const logFilePath: string = this._getTarLogFilePath(cacheId, 'untar');
const tarExitCode: number = await tarUtility.tryUntarAsync({
archivePath: localCacheEntryPath,
outputFolderPath: projectFolderPath,
logFilePath
});
if (tarExitCode === 0) {
restoreSuccess = true;
terminal.writeLine('Successfully restored output from the build cache.');
} else {
terminal.writeWarningLine(
'Unable to restore output from the build cache. ' +
`See "${logFilePath}" for logs from the tar process.`
);
}
}
if (updateLocalCacheSuccess === false) {
terminal.writeWarningLine('Unable to update the local build cache with data from the cloud cache.');
}
return restoreSuccess;
}
public async trySetCacheEntryAsync(terminal: ITerminal, specifiedCacheId?: string): Promise<boolean> {
if (!this._cacheWriteEnabled) {
// Skip writing local and cloud build caches, without any noise
return true;
}
const cacheId: string | undefined = specifiedCacheId || this._cacheId;
if (!cacheId) {
terminal.writeWarningLine('Unable to get cache ID. Ensure Git is installed.');
return false;
}
const filesToCache: IPathsToCache | undefined = await this._tryCollectPathsToCacheAsync(terminal);
if (!filesToCache) {
return false;
}
terminal.writeVerboseLine(
`Caching build output folders: ${filesToCache.filteredOutputFolderNames.join(', ')}`
);
let localCacheEntryPath: string | undefined;
const tarUtility: TarExecutable | undefined = await OperationBuildCache._tryGetTarUtility(terminal);
if (tarUtility) {
const finalLocalCacheEntryPath: string = this._localBuildCacheProvider.getCacheEntryPath(cacheId);
// Derive the temp file from the destination path to ensure they are on the same volume
// In the case of a shared network drive containing the build cache, we also need to make
// sure the the temp path won't be shared by two parallel rush builds.
const randomSuffix: string = crypto.randomBytes(8).toString('hex');
const tempLocalCacheEntryPath: string = `${finalLocalCacheEntryPath}-${randomSuffix}.temp`;
const logFilePath: string = this._getTarLogFilePath(cacheId, 'tar');
const tarExitCode: number = await tarUtility.tryCreateArchiveFromProjectPathsAsync({
archivePath: tempLocalCacheEntryPath,
paths: filesToCache.outputFilePaths,
project: this._project,
logFilePath
});
if (tarExitCode === 0) {
// Move after the archive is finished so that if the process is interrupted we aren't left with an invalid file
try {
await Async.runWithRetriesAsync({
action: () =>
FileSystem.moveAsync({
sourcePath: tempLocalCacheEntryPath,
destinationPath: finalLocalCacheEntryPath,
overwrite: true
}),
maxRetries: 2,
retryDelayMs: 500
});
} catch (moveError) {
try {
await FileSystem.deleteFileAsync(tempLocalCacheEntryPath);
} catch (deleteError) {
// Ignored
}
throw moveError;
}
localCacheEntryPath = finalLocalCacheEntryPath;
} else {
terminal.writeWarningLine(
`"tar" exited with code ${tarExitCode} while attempting to create the cache entry. ` +
`See "${logFilePath}" for logs from the tar process.`
);
return false;
}
} else {
terminal.writeWarningLine(
`Unable to locate "tar". Please ensure that "tar" is on your PATH environment variable, or set the ` +
`${EnvironmentVariableNames.RUSH_TAR_BINARY_PATH} environment variable to the full path to the "tar" binary.`
);
return false;
}
let cacheEntryBuffer: Buffer | undefined;
let setCloudCacheEntryPromise: Promise<boolean> | undefined;
// Note that "writeAllowed" settings (whether in config or environment) always apply to
// the configured CLOUD cache. If the cache is enabled, rush is always allowed to read from and
// write to the local build cache.
if (this._cloudBuildCacheProvider?.isCacheWriteAllowed) {
if (localCacheEntryPath) {
cacheEntryBuffer = await FileSystem.readFileToBufferAsync(localCacheEntryPath);
} else {
throw new InternalError('Expected the local cache entry path to be set.');
}
setCloudCacheEntryPromise = this._cloudBuildCacheProvider?.trySetCacheEntryBufferAsync(
terminal,
cacheId,
cacheEntryBuffer
);
}
const updateCloudCacheSuccess: boolean | undefined = (await setCloudCacheEntryPromise) ?? true;
const success: boolean = updateCloudCacheSuccess && !!localCacheEntryPath;
if (success) {
terminal.writeLine('Successfully set cache entry.');
terminal.writeVerboseLine(`Cache key: ${cacheId}`);
} else if (!localCacheEntryPath && updateCloudCacheSuccess) {
terminal.writeWarningLine('Unable to set local cache entry.');
} else if (localCacheEntryPath && !updateCloudCacheSuccess) {
terminal.writeWarningLine('Unable to set cloud cache entry.');
} else {
terminal.writeWarningLine('Unable to set both cloud and local cache entries.');
}
return success;
}
/**
* Walks the declared output folders of the project and collects a list of files.
* @returns The list of output files as project-relative paths, or `undefined` if a
* symbolic link was encountered.
*/
private async _tryCollectPathsToCacheAsync(terminal: ITerminal): Promise<IPathsToCache | undefined> {
const projectFolderPath: string = this._project.projectFolder;
const outputFilePaths: string[] = [];
const queue: [string, string][] = [];
const filteredOutputFolderNames: string[] = [];
let hasSymbolicLinks: boolean = false;
const excludeAppleDoubleFiles: boolean = this._excludeAppleDoubleFiles;
// Adds child directories to the queue, files to the path list, and bails on symlinks
function processChildren(relativePath: string, diskPath: string, children: FolderItem[]): void {
// When excluding AppleDouble files, build a set of sibling names so we can check
// whether a companion file exists for each ._X file.
let childNameSet: Set<string> | undefined;
if (excludeAppleDoubleFiles) {
childNameSet = new Set<string>(children.map(({ name }) => name));
}
for (const child of children) {
const childName: string = child.name;
const childRelativePath: string = `${relativePath}/${childName}`;
if (child.isSymbolicLink()) {
terminal.writeError(
`Unable to include "${childRelativePath}" in build cache. It is a symbolic link.`
);
hasSymbolicLinks = true;
} else if (child.isDirectory()) {
queue.push([childRelativePath, `${diskPath}/${child.name}`]);
} else {
// Check for macOS AppleDouble files (._X pattern) that have a companion file
if (childNameSet && childName.length > 2 && childName.startsWith('._')) {
const companionName: string = childName.substring(2);
if (childNameSet.has(companionName)) {
terminal.writeVerboseLine(`Omitting AppleDouble file "${childRelativePath}" from build cache.`);
continue;
}
}
outputFilePaths.push(childRelativePath);
}
}
}
// Handle declared output folders.
for (const outputFolder of this._projectOutputFolderNames) {
const diskPath: string = `${projectFolderPath}/${outputFolder}`;
try {
const children: FolderItem[] = await FileSystem.readFolderItemsAsync(diskPath);
processChildren(outputFolder, diskPath, children);
// The folder exists, record it
filteredOutputFolderNames.push(outputFolder);
} catch (error) {
if (!FileSystem.isNotExistError(error as Error)) {
throw error;
}
// If the folder does not exist, ignore it.
}
}
for (const [relativePath, diskPath] of queue) {
const children: FolderItem[] = await FileSystem.readFolderItemsAsync(diskPath);
processChildren(relativePath, diskPath, children);
}
if (hasSymbolicLinks) {
// Symbolic links do not round-trip safely.
return undefined;
}
// Ensure stable output path order.
outputFilePaths.sort();
return {
outputFilePaths,
filteredOutputFolderNames
};
}
private _getTarLogFilePath(cacheId: string, mode: 'tar' | 'untar'): string {
return path.join(this._project.projectRushTempFolder, `${cacheId}.${mode}.log`);
}
private static _getCacheId(options: IProjectBuildCacheOptions): string | undefined {
const {
buildCacheConfiguration,
project: { packageName },
operationStateHash,
phaseName
} = options;
return buildCacheConfiguration.getCacheEntryId({
projectName: packageName,
projectStateHash: operationStateHash,
phaseName
});
}
}