UNPKG

@rushstack/rush-http-build-cache-plugin

Version:

Rush plugin for generic HTTP cloud build cache

298 lines 14.8 kB
"use strict"; // 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