@rushstack/rush-http-build-cache-plugin
Version:
Rush plugin for generic HTTP cloud build cache
298 lines • 14.8 kB
JavaScript
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
Object.defineProperty(exports, "__esModule", { value: true });
exports.HttpBuildCacheProvider = void 0;
const node_core_library_1 = require("@rushstack/node-core-library");
const rush_sdk_1 = require("@rushstack/rush-sdk");
const WebClient_1 = require("@rushstack/rush-sdk/lib/utilities/WebClient");
var CredentialsOptions;
(function (CredentialsOptions) {
CredentialsOptions[CredentialsOptions["Optional"] = 0] = "Optional";
CredentialsOptions[CredentialsOptions["Required"] = 1] = "Required";
CredentialsOptions[CredentialsOptions["Omit"] = 2] = "Omit";
})(CredentialsOptions || (CredentialsOptions = {}));
var FailureType;
(function (FailureType) {
FailureType[FailureType["None"] = 0] = "None";
FailureType[FailureType["Informational"] = 1] = "Informational";
FailureType[FailureType["Warning"] = 2] = "Warning";
FailureType[FailureType["Error"] = 3] = "Error";
FailureType[FailureType["Authentication"] = 4] = "Authentication";
})(FailureType || (FailureType = {}));
const MAX_HTTP_CACHE_ATTEMPTS = 3;
const DEFAULT_MIN_HTTP_RETRY_DELAY_MS = 2500;
class HttpBuildCacheProvider {
get isCacheWriteAllowed() {
var _a;
return (_a = rush_sdk_1.EnvironmentConfiguration.buildCacheWriteAllowed) !== null && _a !== void 0 ? _a : this._isCacheWriteAllowedByConfiguration;
}
constructor(options, rushSession) {
var _a, _b, _c, _d;
this._pluginName = options.pluginName;
this._rushProjectRoot = options.rushJsonFolder;
this._environmentCredential = rush_sdk_1.EnvironmentConfiguration.buildCacheCredential;
this._isCacheWriteAllowedByConfiguration = options.isCacheWriteAllowed;
this._url = new URL(options.url.endsWith('/') ? options.url : options.url + '/');
this._uploadMethod = (_a = options.uploadMethod) !== null && _a !== void 0 ? _a : 'PUT';
this._headers = (_b = options.headers) !== null && _b !== void 0 ? _b : {};
this._tokenHandler = options.tokenHandler;
this._cacheKeyPrefix = (_c = options.cacheKeyPrefix) !== null && _c !== void 0 ? _c : '';
this._minHttpRetryDelayMs = (_d = options.minHttpRetryDelayMs) !== null && _d !== void 0 ? _d : DEFAULT_MIN_HTTP_RETRY_DELAY_MS;
}
async tryGetCacheEntryBufferByIdAsync(terminal, cacheId) {
try {
const result = 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;
}
}
async trySetCacheEntryBufferAsync(terminal, cacheId, objectBuffer) {
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 = 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;
}
}
async updateCachedCredentialAsync(terminal, credential) {
await rush_sdk_1.CredentialCache.usingAsync({
supportEditing: true
}, async (credentialsCache) => {
credentialsCache.setCacheEntry(this._credentialCacheId, {
credential: credential
});
await credentialsCache.saveIfModifiedAsync();
});
}
async updateCachedCredentialInteractiveAsync(terminal) {
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 = `${this._tokenHandler.exec} ${(this._tokenHandler.args || []).join(' ')}`;
terminal.writeVerboseLine(`Running '${cmd}' to get credentials`);
const result = node_core_library_1.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 = result.stdout.trim();
terminal.writeVerboseLine('Got credentials');
await this.updateCachedCredentialAsync(terminal, credential);
terminal.writeLine('Updated credentials cache');
}
async deleteCachedCredentialsAsync(terminal) {
await rush_sdk_1.CredentialCache.usingAsync({
supportEditing: true
}, async (credentialsCache) => {
credentialsCache.deleteCacheEntry(this._credentialCacheId);
await credentialsCache.saveIfModifiedAsync();
});
}
get _credentialCacheId() {
if (!this.__credentialCacheId) {
const cacheIdParts = [this._url.href];
if (this._isCacheWriteAllowedByConfiguration) {
cacheIdParts.push('cacheWriteAllowed');
}
this.__credentialCacheId = cacheIdParts.join('|');
}
return this.__credentialCacheId;
}
async _makeHttpRequestAsync(options) {
const { terminal, relUrl, method, body, warningText, readBody, credentialOptions } = options;
const safeCredentialOptions = credentialOptions !== null && credentialOptions !== void 0 ? credentialOptions : CredentialsOptions.Optional;
const credentials = await this._tryGetCredentialsAsync(safeCredentialOptions);
const url = new URL(relUrl, this._url).href;
const headers = {};
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 = (body === null || body === void 0 ? void 0 : body.length) || 'unknown';
terminal.writeDebugLine(`[http-build-cache] request: ${method} ${url} ${bodyLength} bytes`);
const webClient = new WebClient_1.WebClient();
const response = await webClient.fetchAsync(url, {
verb: method,
headers: headers,
body: body,
redirect: 'follow',
timeoutMs: 0 // Use the default timeout
});
if (!response.ok) {
const isNonCredentialResponse = 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 = 1.0 + Math.random(); // A random number between 1.0 and 2.0
const retryDelay = Math.floor(factor * this._minHttpRetryDelayMs);
await node_core_library_1.Async.sleepAsync(retryDelay);
return await this._makeHttpRequestAsync({ ...options, maxAttempts: options.maxAttempts - 1 });
}
this._reportFailure(terminal, method, response, false, warningText);
return false;
}
const result = readBody ? await response.getBufferAsync() : true;
terminal.writeDebugLine(`[http-build-cache] actual response: ${response.status} ${url} ${result === true ? 'true' : result.length} bytes`);
return result;
}
async _tryGetCredentialsAsync(options) {
if (options === CredentialsOptions.Omit) {
return;
}
let credentials = 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;
}
async _tryGetCredentialsFromCacheAsync() {
var _a;
let cacheEntry;
await rush_sdk_1.CredentialCache.usingAsync({
supportEditing: false
}, (credentialsCache) => {
cacheEntry = credentialsCache.tryGetCacheEntry(this._credentialCacheId);
});
if (cacheEntry) {
const expirationTime = (_a = cacheEntry.expires) === null || _a === void 0 ? void 0 : _a.getTime();
if (!expirationTime || expirationTime >= Date.now()) {
return cacheEntry.credential;
}
}
}
_getFailureType(requestMethod, response, isRedirect) {
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;
}
_reportFailure(terminal, requestMethod, response, isRedirect, message) {
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'));
}
}
}
}
exports.HttpBuildCacheProvider = HttpBuildCacheProvider;
//# sourceMappingURL=HttpBuildCacheProvider.js.map
;