@razee/razeedeploy-core
Version:
Core components used to extend razee deploy
305 lines (275 loc) • 13 kB
JavaScript
/*
* Copyright 2019, 2024 IBM Corp. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const objectPath = require('object-path');
const yaml = require('js-yaml');
const fs = require('fs-extra');
const hash = require('object-hash');
const clone = require('clone');
const pLimit = require('p-limit');
// Limit concurrent check and save of kube resources in `_saveChild()`.
// This limit will apply across separate BaseDownloadController instances,
// e.g. to prevent either large numbers of RemoteResources OR large
// numbers of kube resources in RemoteResources from flooding the
// Kubernetes API.
const globalLimit = pLimit(10);
const CompositeController = require('./CompositeController');
const FetchEnvs = require('./FetchEnvs');
module.exports = class BaseDownloadController extends CompositeController {
constructor(params) {
params.finalizerString = params.finalizerString || 'children.downloads.deploy.razee.io';
super(params);
}
async added() {
let requests = objectPath.get(this.data, ['object', 'spec', 'requests'], []);
// when failure to download optional resource occurs, should continue to download other requests, but shouldnt reconcile children
let optionalResourceFailure = 0;
let lastModifiedArray = objectPath.get(this.data, ['object', 'status', 'last-modified'], []);
let newLastModifiedArray = [];
/*
A request may have been split into mulitple requests (e.g. a request that returns multiple files split into separate requests per file).
If this is done and the original request is non-optional, all the requests in the group should be processed as a group rather than aborting on the first failure.
These two should behave the same:
- A single original request for a single file containing multiple resources
- A single original request for multiple files each containing a single resource
To get the same behavior, track failures and messages for the group and only fail once the entire has been attempted.
The group is defined by having a `splitRequestId` attribute added to each new request added by splitting the original. Using the hash of the original request is recommended.
*/
let mandatoryResourceFailure = 0;
let mandatoryResourceFailureMsgs = [];
for (var i = 0; i < requests.length; i++) {
let request = requests[i];
let requestHash = hash(request);
let file;
let fileCachePath = `./download-cache/${this.namespace}/${this.name}/${requestHash}`;
let optional = request.optional || false;
let reqOpt = clone(request.options);
let url = objectPath.get(request, 'options.uri') || objectPath.get(request, 'options.url');
if (objectPath.has(request, 'options.headers.If-Modified-Since') || objectPath.has(request, 'options.headers.If-None-Match')) {
this.log.warn('Should not include If-Modified-Since/If-None-Match in definition headers, removing from request..');
objectPath.del(reqOpt, 'headers.If-Modified-Since');
objectPath.del(reqOpt, 'headers.If-None-Match');
}
let imsObj = lastModifiedArray.find((el) => objectPath.get(el, 'hash') == requestHash && objectPath.has(el, 'last-modified'));
let fileCached = await fs.pathExists(fileCachePath);
this.log.debug(`Request Hash ${requestHash} ${imsObj ? 'found' : 'not found'} in .status.last-modified array and file ${fileCached ? 'is' : 'is not'} cached`);
if (imsObj && fileCached) {
this.log.debug(`Adding headers.If-Modified-Since to request from Request Hash ${requestHash}`);
objectPath.set(reqOpt, 'headers.If-Modified-Since', objectPath.get(imsObj, 'last-modified'));
}
try {
reqOpt = await this._fetchHeaderSecrets(reqOpt);
} catch (e) {
// error fetching header secrets
if (optional && e.code == 404) {
++optionalResourceFailure;
this.log.warn(e.message);
this.updateRazeeLogs('warn', { controller: 'BaseDownload', warn: e.message, url: url });
this.log.debug(`skipping download for ${url}`);
continue;
} else {
return Promise.reject(e.message);
}
}
try {
this.log.info(`downloading file ${i+1} of ${requests.length}: ${url}`);
let res = await this.download(reqOpt);
if (res.toJSON instanceof Function) {
res = res.toJSON();
}
if (res.statusCode >= 200 && res.statusCode < 300) {
this.log.debug(`Download ${res.statusCode} ${url}`);
file = yaml.loadAll(res.body);
if (Array.isArray(file) && file.length == 1) { file = file[0]; }
// TODO if last-modified doesnt exist try etag
// use with request at .headers.If-None-Match
let resLM = objectPath.get(res, 'headers.last-modified');
if (resLM) {
await fs.outputJson(fileCachePath, file);
newLastModifiedArray[i] = { hash: requestHash, url: url, 'last-modified': resLM };
}
} else if (res.statusCode == 304 && fileCached) {
this.log.debug(`Download ${res.statusCode} Not Modified ${url}`);
file = await fs.readJson(fileCachePath);
newLastModifiedArray[i] = { hash: requestHash, url: url, 'last-modified': objectPath.get(imsObj, 'last-modified') };
} else {
this.log.debug(`Download failed: ${res.statusCode} | ${url}`);
throw { statusCode: res.statusCode, uri: url };
}
} catch (e) {
if (optional) {
++optionalResourceFailure;
this.log.error(`Error downloading optional file (will continue): ${e.message || e}`);
this.updateRazeeLogs('warn', { controller: 'BaseDownload', warn: `Error downloading file. StatusCode: ${e.statusCode}`, url: url });
file = [];
newLastModifiedArray[i] = { hash: requestHash, url: url };
} else {
this.log.error(`Error downloading mandatory file (will return rejection): ${e.message || e}`);
return Promise.reject(`uri: ${reqOpt.uri || reqOpt.url}, statusCode: ${e.statusCode}, message: ${e.message}`);
}
}
try {
this.log.info(`applying downloaded file ${i+1} of ${requests.length}: ${url}`);
await this._decomposeFile(file);
} catch (e) {
let msg = `Error applying file to kubernetes. StatusCode: ${e.statusCode} url: ${url} message: ${objectPath.get(e, 'body.message', e)}`;
if (optional) {
++optionalResourceFailure;
this.log.warn(msg);
this.updateRazeeLogs('warn', { controller: 'BaseDownload', warn: `Error applying file to kubernetes, see logs for details. StatusCode: ${e.statusCode}`, url: url });
} else {
++mandatoryResourceFailure;
mandatoryResourceFailureMsgs.push(msg);
}
}
// Get the next request to check whether it's time to process mandatory request failures.
const nextRequest = (i+1 == requests.length) ? null : requests[i+1];
// If this is the end of an original request or a split original request...
if( !request.splitRequestId || !nextRequest || !nextRequest.splitRequestId || nextRequest.splitRequestId != request.splitRequestId ) {
// If mandatory request failures occurred...
if( mandatoryResourceFailure > 0 ) {
// Non-optional requests failed. Fail.
this.log.error(`${mandatoryResourceFailure} mandatory resource(s) failed to process: ${mandatoryResourceFailureMsgs.join(', ')}`);
return Promise.reject(`${mandatoryResourceFailure} errors occurred: ${mandatoryResourceFailureMsgs.join(', ')}`);
}
// Reset the mandatory request failure trackers.
mandatoryResourceFailure = 0;
mandatoryResourceFailureMsgs = [];
}
}
this.log.info(`requests processed: ${requests.length}`);
// update the last-modified array
let res = await this.patchSelf({
status: {
'last-modified': newLastModifiedArray
}
}, { status: true });
objectPath.set(this.data, 'object', res); // save latest patch response
if (optionalResourceFailure > 0) {
let msg = `${optionalResourceFailure} optional resource(s) failed to process.. skipping reconcileChildren`;
this.log.warn(msg);
this.updateRazeeLogs('warn', { controller: 'BaseDownload', warn: msg });
} else {
await this.reconcileChildren();
}
}
// Helpers ==================================
async _decomposeFile(file) {
let kind = objectPath.get(file, ['kind'], '');
let items = objectPath.get(file, ['items']);
if (Array.isArray(file)) {
let error;
let res = await Promise.all(file.map(async f => {
try {
return await this._decomposeFile(f);
} catch (e) {
error = error || e;
}
}));
return error ? Promise.reject(error) : res;
} else if (kind.toLowerCase() == 'list' && Array.isArray(items)) {
let error;
let res = await Promise.all(items.map(async f => {
try {
return await this._decomposeFile(f);
} catch (e) {
error = error || e;
}
}));
return error ? Promise.reject(error) : res;
} else if (file) {
return await this._saveChild(file);
}
}
async _fetchHeaderSecrets(requestOptions) {
requestOptions = clone(requestOptions);
let headers = objectPath.get(requestOptions, 'headers');
if (headers) {
for (let [hKey, hObject] of Object.entries(headers)) {
let secretRef = objectPath.get(hObject, 'valueFrom.secretKeyRef');
if (secretRef) {
let secretName = objectPath.get(secretRef, 'name');
let secretNamespace = objectPath.get(secretRef, 'namespace', this.namespace);
let secretKey = objectPath.get(secretRef, 'key');
try {
objectPath.set(headers, [hKey], await this._getSecretData(secretName, secretKey, secretNamespace));
} catch (e) {
const error = new Error(`Unable to fetch header secret data. { name: ${secretName}, namespace: ${secretNamespace}, key: ${secretKey} }: ${objectPath.get(e, 'error.message')}`);
error.code = objectPath.get(e, 'error.code');
throw error;
}
}
}
}
const reqopt = new FetchEnvs(this);
const headersFrom = objectPath.get(requestOptions, 'headersFrom');
if (headersFrom) {
let headersFromTemp;
try {
headersFromTemp = await reqopt.processEnvFrom(headersFrom);
} catch(e) {
const err = new Error(`Unable to fetch header secrets with headersFrom. ${e.message}`);
err.code = e.code;
throw err;
}
let mergedHeaders = { ...headers };
for (const header of headersFromTemp) {
const data = header?.data;
mergedHeaders = { ...mergedHeaders, ...data };
}
requestOptions = { ...requestOptions, headers: mergedHeaders };
}
return requestOptions;
}
async _getSecretData(name, key, ns) {
ns = ns || this.namespace;
let res = await this.kubeResourceMeta.request({ uri: `/api/v1/namespaces/${ns}/secrets/${name}`, json: true });
let secret = Buffer.from(objectPath.get(res, ['data', key], ''), 'base64').toString();
if (secret === '') {
throw {
name: 'StatusCodeError',
statusCode: 404,
message: `404 - key "${key}" not found in secret "${name}", in namespace "${ns}"`,
error: {
kind: 'Status',
apiVersion: 'v1',
metadata: {},
status: 'Failure',
message: `key "${key}" not found in secret "${name}", in namespace "${ns}"`,
reason: 'NotFound',
details: { 'name': name, 'namespace': ns, 'kind': 'secrets', 'key': key },
code: 404
}
};
}
return secret;
}
async _saveChild(child) {
// Limit concurrency to prevent flooding the Kubernetes API
let res = await globalLimit(async () => {
return await this.applyChild(child);
});
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
return Promise.reject(res);
}
return res;
}
async download() {
// Input: request options
// Output: http request full resonse with a 'body' element
// Do not send back anything you dont want applied to kube
throw Error('Override BaseDownloadController.download in the subclass.');
}
};