@rushstack/rush-http-build-cache-plugin
Version:
Rush plugin for generic HTTP cloud build cache
448 lines (382 loc) • 15.3 kB
text/typescript
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import { Executable, Async } from '@rushstack/node-core-library';
import type { ITerminal } from '@rushstack/terminal';
import {
type ICloudBuildCacheProvider,
type ICredentialCacheEntry,
CredentialCache,
type RushSession,
EnvironmentConfiguration
} from '@rushstack/rush-sdk';
import { WebClient, type IWebClientResponse } from '@rushstack/rush-sdk/lib/utilities/WebClient';
import type { SpawnSyncReturns } from 'child_process';
enum CredentialsOptions {
Optional,
Required,
Omit
}
enum FailureType {
None,
Informational,
Warning,
Error,
Authentication
}
export interface IHttpBuildCacheTokenHandler {
exec: string;
args?: string[];
}
/**
* @public
*/
export type UploadMethod = 'PUT' | 'POST' | 'PATCH';
/**
* @public
*/
export interface IHttpBuildCacheProviderOptions {
url: string;
tokenHandler?: IHttpBuildCacheTokenHandler;
uploadMethod?: UploadMethod;
minHttpRetryDelayMs?: number;
headers?: Record<string, string>;
cacheKeyPrefix?: string;
isCacheWriteAllowed: boolean;
pluginName: string;
rushJsonFolder: string;
}
const MAX_HTTP_CACHE_ATTEMPTS: number = 3;
const DEFAULT_MIN_HTTP_RETRY_DELAY_MS: number = 2500;
export class HttpBuildCacheProvider implements ICloudBuildCacheProvider {
private readonly _pluginName: string;
private readonly _rushProjectRoot: string;
private readonly _environmentCredential: string | undefined;
private readonly _isCacheWriteAllowedByConfiguration: boolean;
private readonly _url: URL;
private readonly _uploadMethod: UploadMethod;
private readonly _headers: Record<string, string>;
private readonly _cacheKeyPrefix: string;
private readonly _tokenHandler: IHttpBuildCacheTokenHandler | undefined;
private readonly _minHttpRetryDelayMs: number;
private __credentialCacheId: string | undefined;
public get isCacheWriteAllowed(): boolean {
return EnvironmentConfiguration.buildCacheWriteAllowed ?? this._isCacheWriteAllowedByConfiguration;
}
public constructor(options: IHttpBuildCacheProviderOptions, rushSession: RushSession) {
this._pluginName = options.pluginName;
this._rushProjectRoot = options.rushJsonFolder;
this._environmentCredential = EnvironmentConfiguration.buildCacheCredential;
this._isCacheWriteAllowedByConfiguration = options.isCacheWriteAllowed;
this._url = new URL(options.url.endsWith('/') ? options.url : options.url + '/');
this._uploadMethod = options.uploadMethod ?? 'PUT';
this._headers = options.headers ?? {};
this._tokenHandler = options.tokenHandler;
this._cacheKeyPrefix = options.cacheKeyPrefix ?? '';
this._minHttpRetryDelayMs = options.minHttpRetryDelayMs ?? DEFAULT_MIN_HTTP_RETRY_DELAY_MS;
}
public async tryGetCacheEntryBufferByIdAsync(
terminal: ITerminal,
cacheId: string
): Promise<Buffer | undefined> {
try {
const result: boolean | Buffer = await this._makeHttpRequestAsync({
terminal: terminal,
relUrl: `${this._cacheKeyPrefix}${cacheId}`,
method: 'GET',
body: undefined,
warningText: 'Could not get cache entry',
readBody: true,
maxAttempts: MAX_HTTP_CACHE_ATTEMPTS
});
return Buffer.isBuffer(result) ? result : undefined;
} catch (e) {
terminal.writeWarningLine(`Error getting cache entry: ${e}`);
return undefined;
}
}
public async trySetCacheEntryBufferAsync(
terminal: ITerminal,
cacheId: string,
objectBuffer: Buffer
): Promise<boolean> {
if (!this.isCacheWriteAllowed) {
terminal.writeErrorLine('Writing to cache is not allowed in the current configuration.');
return false;
}
terminal.writeDebugLine('Uploading object with cacheId: ', cacheId);
try {
const result: boolean | Buffer = await this._makeHttpRequestAsync({
terminal: terminal,
relUrl: `${this._cacheKeyPrefix}${cacheId}`,
method: this._uploadMethod,
body: objectBuffer,
warningText: 'Could not write cache entry',
readBody: false,
maxAttempts: MAX_HTTP_CACHE_ATTEMPTS
});
return result !== false;
} catch (e) {
terminal.writeWarningLine(`Error uploading cache entry: ${e}`);
return false;
}
}
public async updateCachedCredentialAsync(terminal: ITerminal, credential: string): Promise<void> {
await CredentialCache.usingAsync(
{
supportEditing: true
},
async (credentialsCache: CredentialCache) => {
credentialsCache.setCacheEntry(this._credentialCacheId, {
credential: credential
});
await credentialsCache.saveIfModifiedAsync();
}
);
}
public async updateCachedCredentialInteractiveAsync(terminal: ITerminal): Promise<void> {
if (!this._tokenHandler) {
throw new Error(
`The interactive cloud credentials flow is not configured.\n` +
`Set the 'tokenHandler' setting in 'common/config/rush-plugins/${this._pluginName}.json' to a command that writes your credentials to standard output and exits with code 0 ` +
`or provide your credentials to rush using the --credential flag instead. Credentials must be the ` +
`'Authorization' header expected by ${this._url.href}`
);
}
const cmd: string = `${this._tokenHandler.exec} ${(this._tokenHandler.args || []).join(' ')}`;
terminal.writeVerboseLine(`Running '${cmd}' to get credentials`);
const result: SpawnSyncReturns<string> = Executable.spawnSync(
this._tokenHandler.exec,
this._tokenHandler.args || [],
{
currentWorkingDirectory: this._rushProjectRoot
}
);
terminal.writeErrorLine(result.stderr);
if (result.error) {
throw new Error(`Could not obtain credentials. The command '${cmd}' failed.`);
}
const credential: string = result.stdout.trim();
terminal.writeVerboseLine('Got credentials');
await this.updateCachedCredentialAsync(terminal, credential);
terminal.writeLine('Updated credentials cache');
}
public async deleteCachedCredentialsAsync(terminal: ITerminal): Promise<void> {
await CredentialCache.usingAsync(
{
supportEditing: true
},
async (credentialsCache: CredentialCache) => {
credentialsCache.deleteCacheEntry(this._credentialCacheId);
await credentialsCache.saveIfModifiedAsync();
}
);
}
private get _credentialCacheId(): string {
if (!this.__credentialCacheId) {
const cacheIdParts: string[] = [this._url.href];
if (this._isCacheWriteAllowedByConfiguration) {
cacheIdParts.push('cacheWriteAllowed');
}
this.__credentialCacheId = cacheIdParts.join('|');
}
return this.__credentialCacheId;
}
private async _makeHttpRequestAsync(options: {
terminal: ITerminal;
relUrl: string;
method: 'GET' | UploadMethod;
body: Buffer | undefined;
warningText: string;
readBody: boolean;
maxAttempts: number;
credentialOptions?: CredentialsOptions;
}): Promise<Buffer | boolean> {
const { terminal, relUrl, method, body, warningText, readBody, credentialOptions } = options;
const safeCredentialOptions: CredentialsOptions = credentialOptions ?? CredentialsOptions.Optional;
const credentials: string | undefined = await this._tryGetCredentialsAsync(safeCredentialOptions);
const url: string = new URL(relUrl, this._url).href;
const headers: Record<string, string> = {};
if (typeof credentials === 'string') {
headers.Authorization = credentials;
}
for (const [key, value] of Object.entries(this._headers)) {
if (typeof value === 'string') {
headers[key] = value;
}
}
const bodyLength: number | 'unknown' = (body as { length: number })?.length || 'unknown';
terminal.writeDebugLine(`[http-build-cache] request: ${method} ${url} ${bodyLength} bytes`);
const webClient: WebClient = new WebClient();
const response: IWebClientResponse = await webClient.fetchAsync(url, {
verb: method,
headers: headers,
body: body,
redirect: 'follow',
timeoutMs: 0 // Use the default timeout
});
if (!response.ok) {
const isNonCredentialResponse: boolean = response.status >= 500 && response.status < 600;
if (
!isNonCredentialResponse &&
typeof credentials !== 'string' &&
safeCredentialOptions === CredentialsOptions.Optional
) {
// If we don't already have credentials yet, and we got a response from the server
// that is a "normal" failure (4xx), then we assume that credentials are probably
// required. Re-attempt the request, requiring credentials this time.
//
// This counts as part of the "first attempt", so it is not included in the max attempts
return await this._makeHttpRequestAsync({
...options,
credentialOptions: CredentialsOptions.Required
});
}
if (options.maxAttempts > 1) {
// Pause a bit before retrying in case the server is busy
// Add some random jitter to the retry so we can spread out load on the remote service
// A proper solution might add exponential back off in case the retry count is high (10 or more)
const factor: number = 1.0 + Math.random(); // A random number between 1.0 and 2.0
const retryDelay: number = Math.floor(factor * this._minHttpRetryDelayMs);
await Async.sleepAsync(retryDelay);
return await this._makeHttpRequestAsync({ ...options, maxAttempts: options.maxAttempts - 1 });
}
this._reportFailure(terminal, method, response, false, warningText);
return false;
}
const result: Buffer | boolean = readBody ? await response.getBufferAsync() : true;
terminal.writeDebugLine(
`[http-build-cache] actual response: ${response.status} ${url} ${
result === true ? 'true' : result.length
} bytes`
);
return result;
}
private async _tryGetCredentialsAsync(options: CredentialsOptions.Required): Promise<string>;
private async _tryGetCredentialsAsync(options: CredentialsOptions.Optional): Promise<string | undefined>;
private async _tryGetCredentialsAsync(options: CredentialsOptions.Omit): Promise<undefined>;
private async _tryGetCredentialsAsync(options: CredentialsOptions): Promise<string | undefined>;
private async _tryGetCredentialsAsync(options: CredentialsOptions): Promise<string | undefined> {
if (options === CredentialsOptions.Omit) {
return;
}
let credentials: string | undefined = this._environmentCredential;
if (credentials === undefined) {
credentials = await this._tryGetCredentialsFromCacheAsync();
}
if (typeof credentials !== 'string' && options === CredentialsOptions.Required) {
throw new Error(
[
`Credentials for ${this._url.href} have not been provided.`,
`In CI, verify that RUSH_BUILD_CACHE_CREDENTIAL contains a valid Authorization header value.`,
``,
`For local developers, run:`,
``,
` rush update-cloud-credentials --interactive`,
``
].join('\n')
);
}
return credentials;
}
private async _tryGetCredentialsFromCacheAsync(): Promise<string | undefined> {
let cacheEntry: ICredentialCacheEntry | undefined;
await CredentialCache.usingAsync(
{
supportEditing: false
},
(credentialsCache: CredentialCache) => {
cacheEntry = credentialsCache.tryGetCacheEntry(this._credentialCacheId);
}
);
if (cacheEntry) {
const expirationTime: number | undefined = cacheEntry.expires?.getTime();
if (!expirationTime || expirationTime >= Date.now()) {
return cacheEntry.credential;
}
}
}
private _getFailureType(
requestMethod: string,
response: IWebClientResponse,
isRedirect: boolean
): FailureType {
if (response.ok) {
return FailureType.None;
}
switch (response.status) {
case 503: {
// We select 503 specifically because this represents "service unavailable" and
// "rate limit throttle" errors, which are transient issues.
//
// There are other 5xx errors, such as 501, that can occur if the request is
// malformed, so as a general rule we want to let through other 5xx errors
// so the user can troubleshoot.
// Don't fail production builds with warnings for transient issues
return FailureType.Informational;
}
case 401:
case 403:
case 407: {
if (requestMethod === 'GET' && (isRedirect || response.redirected)) {
// Cache misses for GET requests are not errors
// This is a workaround behavior where a server can issue a redirect and we fail to authenticate at the new location.
// We do not want to signal this as an authentication failure because the authorization header is not passed on to redirects.
// i.e The authentication header was accepted for the first request and therefore subsequent failures
// where it was not present should not be attributed to the header.
// This scenario usually comes up with services that redirect to pre-signed URLS that don't actually exist.
// Those services then usually treat the 404 as a 403 to prevent leaking information.
return FailureType.None;
}
return FailureType.Authentication;
}
case 404: {
if (requestMethod === 'GET') {
// Cache misses for GET requests are not errors
return FailureType.None;
}
}
}
// Let dev builds succeed, let Prod builds fail
return FailureType.Warning;
}
private _reportFailure(
terminal: ITerminal,
requestMethod: string,
response: IWebClientResponse,
isRedirect: boolean,
message: string
): void {
switch (this._getFailureType(requestMethod, response, isRedirect)) {
default: {
terminal.writeErrorLine(`${message}: HTTP ${response.status}: ${response.statusText}`);
break;
}
case FailureType.Warning: {
terminal.writeWarningLine(`${message}: HTTP ${response.status}: ${response.statusText}`);
break;
}
case FailureType.Informational: {
terminal.writeLine(`${message}: HTTP ${response.status}: ${response.statusText}`);
break;
}
case FailureType.None: {
terminal.writeDebugLine(`${message}: HTTP ${response.status}: ${response.statusText}`);
break;
}
case FailureType.Authentication: {
throw new Error(
[
`${this._url.href} responded with ${response.status}: ${response.statusText}.`,
`Credentials may be misconfigured or have expired.`,
`In CI, verify that RUSH_BUILD_CACHE_CREDENTIAL contains a valid Authorization header value.`,
``,
`For local developers, run:`,
``,
` rush update-cloud-credentials --interactive`,
``
].join('\n')
);
}
}
}
}