@controlplane/cli
Version:
Control Plane Corporation CLI
442 lines • 21.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Helm = void 0;
const lodash_1 = require("lodash");
const functions_1 = require("./functions");
const resolver_1 = require("../commands/resolver");
const constants_1 = require("./constants");
const functions_2 = require("../util/functions");
const errors_1 = require("../util/errors");
const logger_1 = require("../util/logger");
const workload_1 = require("../util/workload");
const objects_1 = require("../util/objects");
const generic_1 = require("../commands/generic");
class Helm {
constructor(client, session, args, ensureDeletion) {
this.client = client;
this.session = session;
this.args = args;
this.ensuredGvcNames = [];
this.ensureDeletion = ensureDeletion;
// Parameters dependant
this.chart = this.extractChart();
this.stateName = (0, functions_1.getHelmStateName)(args.release || '');
this.stateParentLink = (0, resolver_1.kindResolver)('secret').parentLink(this.session.context);
this.stateSelfLink = (0, resolver_1.resolveToLink)('secret', this.stateName, this.session.context);
}
// Public Methods //
async createState(release) {
// Initialize a new state
const body = {
name: this.stateName,
kind: 'secret',
description: 'Created by cpln',
tags: {
'cpln/cli-generated': true,
...(0, generic_1.fromTagOptions)(this.args),
},
type: 'opaque',
data: {
payload: JSON.stringify(release),
encoding: 'plain',
},
};
// Create the state
const axiosResponse = await this.client.axios.put(this.stateParentLink, body);
// Use `response.headers.location` to validate the put method
return await this.fetchState(axiosResponse.headers.location);
}
async fetchState(link) {
const state = await this.client.get(`${link ? link : this.stateSelfLink}/-reveal`);
// Remove unnecessary properties to avoid a 409 on update
delete state.version;
delete state.created;
delete state.lastModified;
return state;
}
async updateState(state, release) {
var _a;
try {
// Update state tags
state.tags = {
...((_a = state.tags) !== null && _a !== void 0 ? _a : {}),
...(0, generic_1.fromTagOptions)(this.args),
...(0, generic_1.fromRemoveTagOptions)(this.args),
};
// Set state data
state.data.payload = JSON.stringify(release);
// Update state
await this.client.put(this.stateParentLink, state);
}
catch (e) {
this.session.out(`ERROR: Unable to update the state '${release.release}'.`);
this.session.abort({ error: e });
}
}
async deleteState(release) {
try {
await this.client.axios.delete(this.stateSelfLink);
}
catch (e) {
this.session.out(`ERROR: Unable to delete the state '${release.release}'.`);
this.session.abort({ error: e });
}
}
validateRelease(release) {
if (release.schemaVersion !== constants_1.CPLN_HELM_SCHEMA_LATEST_VERSION) {
this.session.abort({
message: `ERROR: Expected the version to be ${constants_1.CPLN_HELM_SCHEMA_LATEST_VERSION} but found ${release.schemaVersion}. Please, check for a new CLI update or maybe try to delete the release and its resources manually and then attempt to install the release again.`,
});
}
}
initializeRelease() {
return {
schemaVersion: constants_1.CPLN_HELM_SCHEMA_LATEST_VERSION,
release: this.args.release,
deployments: [],
};
}
async getRelease(state) {
var _a;
try {
let release;
const hasSchemaVersion = JSON.parse(state.data.payload);
// Migrate release if necessary
if (hasSchemaVersion.schemaVersion != constants_1.CPLN_HELM_SCHEMA_LATEST_VERSION) {
switch (hasSchemaVersion.schemaVersion) {
case '1':
const legacy = hasSchemaVersion;
const resources = [];
for (const resource of legacy.resources) {
try {
resources.push({
...resource,
template: await this.fetchResource(resource.kind, resource.link),
});
}
catch (e) {
// Ignore 404
if (((_a = e.response) === null || _a === void 0 ? void 0 : _a.status) !== 404) {
throw e;
}
}
}
release = {
schemaVersion: constants_1.CPLN_HELM_SCHEMA_LATEST_VERSION,
release: legacy.release,
deployments: [
{
gvc: '',
revision: 1,
updated: new Date().toISOString(),
status: 'deployed',
chart: this.chart.name,
appVersion: this.chart.appVersion,
description: legacy.description,
config: {},
values: {},
valuesFiles: [],
resources,
},
],
};
break;
default:
release = hasSchemaVersion;
break;
}
// Update state
await this.updateState(state, release);
}
else {
release = hasSchemaVersion;
}
return release;
}
catch (e) {
this.session.abort({ error: e });
}
}
async deleteResources(resourcesToDelete) {
var _a, _b;
let currentKindToEnsureDeletion;
let resourceLinksToEnsureDeletion = [];
// Sort by priority reverse
(0, objects_1.sortIHasKindByPriority)(resourcesToDelete, true);
for (const resource of resourcesToDelete) {
try {
// Skip resource deleteion if specified
if (((_a = resource.template.tags) === null || _a === void 0 ? void 0 : _a['helm.sh/resource-policy']) === 'keep') {
this.session.out(`Skipped deletion of ${resource.link} because it has the 'helm.sh/resource-policy' tag set to 'keep'`);
continue;
}
// Ensure the deletion of a group of resources of the same kind
if (currentKindToEnsureDeletion && currentKindToEnsureDeletion !== resource.kind) {
currentKindToEnsureDeletion = undefined;
await Promise.all(resourceLinksToEnsureDeletion.map((link) => this.ensureDeletion(link)));
resourceLinksToEnsureDeletion = [];
}
// Consider these kinds of resources to ensure their deletion later
if (resource.kind == 'volumeset' || resource.kind == 'workload') {
currentKindToEnsureDeletion = resource.kind;
resourceLinksToEnsureDeletion.push(resource.link);
}
// Perform the delete operation
await this.client.axios.delete(resource.link);
this.session.out(`Deleted ${resource.link}`);
}
catch (e) {
if (((_b = e.response) === null || _b === void 0 ? void 0 : _b.status) !== 404) {
this.session.out(`ERROR: Unable to uninstall '${resource.link}'. Please try again.`);
this.session.abort({ error: e });
}
}
}
}
async cleanupOnFail(createdResources) {
if (this.args.cleanupOnFail && createdResources.length > 0) {
this.session.out(`Rollback was not successful, performing deletion of created resources...`);
await this.deleteResources(createdResources);
}
}
async awaitWorkloadsReadiness(appliedWorkloadLinks) {
if (this.args.wait && appliedWorkloadLinks.length > 0) {
this.session.out(`\nWaiting for workloads to be ready...`);
await this.monitorWorkloadsReadiness(this.args.timeout, this.args.release, appliedWorkloadLinks);
this.session.out('\nAll workloads are ready.');
}
}
async ensureGvcExists(resource, ctx) {
var _a, _b, _c;
if (objects_1.gvcRelatedKinds.includes(resource.kind)) {
// Check if GVC was already ensured, and avoid checking again
if (this.ensuredGvcNames.includes(ctx.gvc)) {
return;
}
// Attempt to fetch the GVC, and throw errors accordingly
try {
const gvcLink = (0, resolver_1.resolveToLink)('gvc', ctx.gvc, ctx);
await this.client.get(gvcLink);
// If successful, we will get here, and we will mark this GVC as already ensured
this.ensuredGvcNames.push(ctx.gvc);
}
catch (e) {
// Handle when GVC does not exist
if (((_a = e.response) === null || _a === void 0 ? void 0 : _a.status) === 404) {
throw new Error(`ERROR: You attempted to apply ${resource.kind} '${resource.name}' while GVC '${ctx.gvc || 'UNKNOWN'}' does not exist.`);
}
else if ((_c = (_b = e.response) === null || _b === void 0 ? void 0 : _b.data) === null || _c === void 0 ? void 0 : _c.message) {
// Throw the error message along with the status code
throw new Error(`ERROR: Unable to fetch GVC '${ctx.gvc || 'UNKNOWN'}' for ${resource.kind} '${resource.name}'. Status: ${e.response.status}, Message: ${e.response.data.message}.`);
}
else {
// Something unexpected happened, throw accordingly
throw new Error(`ERROR: Unable to fetch GVC '${ctx.gvc || 'UNKNOWN'}' for ${resource.kind} '${resource.name}'. Message: ${e.message}.`);
}
}
}
}
async validateResourceReleaseTag(resourceSelfLink) {
var _a;
try {
const resourceToCheck = await this.client.get(resourceSelfLink);
if (constants_1.kindsToSkipHelmReleaseTagCheck.includes(resourceToCheck.kind)) {
return;
}
// Throw an error if the resource does not have the cpln/release tag
if (!resourceToCheck.tags || !resourceToCheck.tags.hasOwnProperty('cpln/release')) {
throw new Error(`ERROR: The resource '${resourceSelfLink}' cannot be updated because it is missing the 'cpln/release' tag. This tag is required to ensure the resource is managed by the correct release. Please add the tag 'cpln/release' with the value '${this.stateName}' to proceed.`);
}
// Throw an error if the resource does not belong to this release.
if (resourceToCheck.tags['cpln/release'] !== this.stateName) {
throw new Error(`ERROR: The resource '${resourceSelfLink}' cannot be updated because it is being managed by a different release '${resourceToCheck.tags['cpln/release'].replace('cpln-release-', '')}'. This measure is in place to prevent updates to resources that already belong to a different release.`);
}
}
catch (e) {
if (((_a = e.response) === null || _a === void 0 ? void 0 : _a.status) !== 404) {
throw e;
}
}
}
async shouldApplyResource(lastDeployedDeployment, resourceSelfLink, resource) {
// If there is a last deployed deployment, let's compare resources
if (lastDeployedDeployment) {
// Attempt to find this template resource in the latest deployed deployment
const deployedResource = lastDeployedDeployment.resources.find((resource) => resource.link === resourceSelfLink);
// If this resource exists in the last deployed deployment, then let's make a comparison
if (deployedResource) {
// But first, let's attempt to fetch the resource and see if the versions are identical between
// the one on the platform and the one in the last deployed deployment
try {
// Fetch the original resource from the data service
const originalResource = await this.client.get(resourceSelfLink);
// Comparison is only valid if both versions are identical.
// Otherwise, we apply the template because differences are likely intentional.
// Comparing is tricky since the data service adds fields (e.g., "description")
// that may not exist in the template or were unset by the user.
if (originalResource.version === deployedResource.version && (0, lodash_1.isEqual)(deployedResource.template, resource)) {
return false;
}
}
catch (e) {
// We don't really want to do anything here;
// If we are unable to compare, then let's just apply the template resource as is.
}
}
}
return true;
}
async prepareBeforeApply(resources, deploymentManager, deployment, state, release) {
var _a, _b, _c;
const secretHashMap = {};
// Process secrets in the template resources list and compute their hashes
for (const templateResource of resources) {
// Ignore non-secret resources
if (templateResource.kind !== 'secret') {
continue;
}
// Parse template resource to a Secret object
const secret = templateResource;
// Ignore secrets that are invalid, we will catch this later on apply
if (!secret.name || !secret.data) {
continue;
}
// Map secret name to its data hash
secretHashMap[secret.name] = (0, objects_1.computeHash)(secret.data);
}
// Process workloads and add/update their tag that is responsible for secrets
for (const templateResource of resources) {
// Ignore non-workload resources
if (templateResource.kind !== 'workload') {
continue;
}
// Parse template resource to a Workload object
const workload = templateResource;
// Extract secret names from the workload
const secretNames = (0, workload_1.extractSecretNames)(workload);
// Iterate over secret names and update workload tags accordingly
for (const secretName of secretNames) {
// Fetch secret from API if not already in the list
if (!(secretName in secretHashMap)) {
// Attempt to fetch the secret, and handle error accordingly
try {
// Resolve the self-link of the secret
const secretLink = (0, resolver_1.resolveToLink)('secret', secretName, this.session.context);
// Fetch the secret
const fetchedSecret = await this.fetchResource('secret', secretLink);
// Compute the hash of the secret and map it to the secret name
secretHashMap[secretName] = (0, objects_1.computeHash)(fetchedSecret.data);
}
catch (e) {
let errorMessage = '';
// Handle when the secret does not exist
if (((_a = e.response) === null || _a === void 0 ? void 0 : _a.status) === 404) {
// Let's map a constant value for non-existing secrets
secretHashMap[secretName] = constants_1.workloadSecretTagDefaultValue;
}
else if ((_c = (_b = e.response) === null || _b === void 0 ? void 0 : _b.data) === null || _c === void 0 ? void 0 : _c.message) {
// Throw the error message along with the status code
errorMessage = `ERROR: Unable to fetch Secret '${secretName}' that is referenced by Workload '${workload.name}'. Status: ${e.response.status}, Message: ${e.response.data.message}.`;
}
else {
// Something unexpected happened, throw accordingly
errorMessage = `ERROR: Unable to fetch Secret '${secretName}' that is referenced by Workload '${workload.name}'. Message: ${e.message}.`;
}
// Mark deployment as failed and throw error if an error message was found
if (errorMessage) {
deploymentManager.updateDeploymentStatus(deployment, 'failed', errorMessage);
await this.updateState(state, release);
this.session.out(`ERROR: Unable to install ${templateResource.kind} '${templateResource.name}'.`);
this.session.abort({ error: e });
}
}
}
// Initialize tags if not found
if (workload.tags === undefined) {
workload.tags = {};
}
// Update workload tags
workload.tags[`cpln/secret:${secretName}`] = secretHashMap[secretName];
}
}
}
// Private Methods //
extractChart() {
if (!this.args.chart) {
return { name: '', appVersion: '' };
}
try {
return (0, functions_1.extractHelmChart)(this.args.chart);
}
catch (e) {
this.session.out('ERROR: Unable to read Chart.yaml. Make sure you have a Chart.yaml file in the chart path you provided.');
this.session.abort({ error: e });
}
}
async fetchResource(kind, selfLink) {
if (kind === 'secret') {
selfLink = `${selfLink}/-reveal`;
}
const resource = await this.client.get(selfLink);
// Remove unnecessary properties to avoid a 409 on update
delete resource.version;
delete resource.created;
delete resource.lastModified;
return resource;
}
async monitorWorkloadsReadiness(timeoutSeconds, release, appliedWorkloadLinks) {
const promises = [];
// Accumulate promises in order to wait for them in parallel
for (const workloadLink of appliedWorkloadLinks) {
promises.push(this.monitorWorkloadReadiness(workloadLink));
}
let timeoutId;
const promisesToRace = [Promise.allSettled(promises)];
if (timeoutSeconds) {
const { timeoutPromise, timeoutId: _timeoutId } = (0, functions_2.timeoutAsync)(timeoutSeconds * 1000);
timeoutId = _timeoutId;
promisesToRace.push(timeoutPromise);
}
try {
// Wait for workloads to be ready in parallel
await Promise.race(promisesToRace);
}
catch (e) {
if (e instanceof errors_1.TimeoutError) {
this.session.abort({
message: `Timeout Error: Not all workloads were ready within ${timeoutSeconds} seconds.\nRelease ${release} has not been installed successfully!`,
});
}
// Handle any thrown error that occurred while monitoring a workload's readiness
this.session.abort({ error: e });
}
// Clear timeout if all workloads were ready
if (timeoutId) {
clearTimeout(timeoutId);
}
}
async monitorWorkloadReadiness(workloadLink) {
logger_1.logger.info(`Waiting for workload '${workloadLink}' to be ready...`);
await (0, functions_2.retryFn)(async () => {
try {
const workload = await this.client.get(workloadLink);
const deployments = await this.client.get(workloadLink + '/deployment');
const workloadHealth = (0, workload_1.getWorkloadHealth)(deployments.items, workload);
if (!workloadHealth.readyLatest) {
throw new Error(`Deployments are not ready yet.`);
}
}
catch (e) {
logger_1.logger.info(`Workload '${workloadLink}' is not ready yet, retrying. Error: ${e.message}`);
throw e;
}
}, {
onFailure() {
logger_1.logger.debug(`Timed out, the workload ${workloadLink} is still not ready.`);
},
repeatTimes: 9999,
});
}
}
exports.Helm = Helm;
//# sourceMappingURL=helm.js.map