dashboard/shell/utils/uiplugins.ts

373 lines
10 KiB
TypeScript

import { matchesSomeRegex } from '@shell/utils/string';
import { CATALOG as CATALOG_ANNOTATIONS } from '@shell/config/labels-annotations';
import { CATALOG } from '@shell/config/types';
import { UI_PLUGIN_BASE_URL, isSupportedChartVersion, UI_PLUGIN_LABELS } from '@shell/config/uiplugins';
import { Plugin, Version } from '@shell/types/uiplugins';
const MAX_RETRIES = 10;
const RETRY_WAIT = 2500;
type Action = 'install' | 'upgrade';
export type HelmRepository = any;
export type HelmChart = any;
/**
*
* @param store Vue store
* @param chartName The chartName
* @param rancherVersion Rancher version
* @param kubeVersion K8s version
* @param opt Store options
* @returns The latest compatible version of the extension; return null If there are no compatible versions.
*/
export async function getLatestExtensionVersion(
store: any,
chartName: string,
rancherVersion: string,
kubeVersion: string,
opt = { reset: true, force: true },
) {
await store.dispatch('catalog/load', opt);
const chart = store.getters['catalog/chart']({ chartName });
const versions = chart?.versions || [];
const compatibleVersions = versions.filter((version: any) => isSupportedChartVersion({
version, rancherVersion, kubeVersion
}));
return compatibleVersions[0]?.version;
}
/**
* Wait for a given UI Extension to be available
*
* @param store Vue store
* @param name Name of the extension
* @param maxRetries Number of times to check for availability
* @param retryWait Gap (in ms) between availability checks
* @returns the extension object when available, null if timed out waiting for it to be available
*/
export async function waitForUIExtension(store: any, name: string, maxRetries = MAX_RETRIES, retryWait = RETRY_WAIT): Promise<any> {
let tries = 0;
while (true) {
try {
const res = await store.dispatch('management/request', {
url: `${ UI_PLUGIN_BASE_URL }`,
method: 'GET',
headers: { accept: 'application/json' },
redirectUnauthorized: false,
});
const entries = res.entries || res.Entries || {};
const extension = entries[name];
if (extension) {
return extension;
}
} catch (e) {
}
tries++;
if (tries > maxRetries) {
return null;
}
await new Promise((resolve) => setTimeout(resolve, retryWait));
}
}
/**
* Wait for a given UI Extension package to be available
*
* @param store Vue store
* @param extension Extension object
* @param maxRetries Number of times to check for availability
* @param retryWait Gap (in MS) between availability checks
* @returns true when available, false if timed out waiting for it to be available
*/
export async function waitForUIPackage(store: any, extension: any, maxRetries = MAX_RETRIES, retryWait = RETRY_WAIT): Promise<boolean> {
let tries = 0;
const { name, version } = extension;
while (true) {
try {
await store.dispatch('management/request', {
url: `${ UI_PLUGIN_BASE_URL }/${ name }/${ version }/plugin/${ name }-${ version }.umd.min.js`,
method: 'GET',
headers: { accept: 'application/json' },
redirectUnauthorized: false,
});
return true;
} catch (error) {
}
tries++;
if (tries > maxRetries) {
return false;
}
await new Promise((resolve) => setTimeout(resolve, retryWait));
}
}
/**
* Install Helm Chart
*
* Note: This should really be provided via the shell rather than copied here
*/
export async function installHelmChart(repo: any, chart: any, values: any = {}, namespace = 'default', action: Action = 'install') {
/*
Refer to the developer docs at docs/developer/helm-chart-apps.md
for details on what values are injected and where they come from.
*/
// TODO: This is needed in order to support system registry for air-gapped environments
// this.addGlobalValuesTo(values);
const chartInstall = {
chartName: chart.name,
version: chart.version,
releaseName: chart.name,
description: chart.name,
annotations: {
[CATALOG_ANNOTATIONS.SOURCE_REPO_TYPE]: chart.repoType,
[CATALOG_ANNOTATIONS.SOURCE_REPO_NAME]: chart.repoName
},
values,
};
/*
Configure Helm CLI options for doing the install or
upgrade operation.
*/
const installRequest = {
charts: [chartInstall],
noHooks: false,
timeout: '1000s',
wait: true,
namespace,
projectId: '',
disableOpenAPIValidation: false,
skipCRDs: false,
};
// Install the Chart
const res = await repo.doAction(action, installRequest);
return res;
}
/**
*
* @param store Vue store
* @param url Repository Url
* @returns HelmRepository
*/
export async function getHelmRepositoryExact(store: any, url: string): Promise<HelmRepository> {
return await getHelmRepository(store, (repository: any) => {
const target = repository.spec?.gitRepo || repository.spec?.url;
return target === url;
});
}
/**
*
* @param store Vue store
* @param urlRegexes Regex to match a community repository
* @param catalogImages Catalog images to match against the repository's labels
* @returns HelmRepository
*/
export async function getHelmRepositoryMatch(store: any, urlRegexes: string[], catalogImages: string[]): Promise<HelmRepository> {
return await getHelmRepository(store, (repository: any) => {
// if installed from rancher/ui-plugin-catalog or rancher/ui-extension-harvester-ui-extension
const catalog = repository?.metadata?.labels?.[UI_PLUGIN_LABELS.CATALOG_IMAGE] || '';
if (catalogImages.includes(catalog)) {
return true;
}
const target = repository.spec?.gitBranch ? repository.spec?.gitRepo : repository.spec?.url;
return matchesSomeRegex(target, urlRegexes);
});
}
/**
*
* @param store Vue store
* @param matchFn Match function for repository's urls
* @returns HelmRepository
*/
async function getHelmRepository(store: any, matchFn: (repository: any) => boolean): Promise<HelmRepository> {
if (store.getters['management/schemaFor'](CATALOG.CLUSTER_REPO)) {
const repos = await store.dispatch('management/findAll', { type: CATALOG.CLUSTER_REPO, opt: { force: true, watch: false } });
return repos.find(matchFn);
} else {
throw new Error('No permissions');
}
}
/**
*
* @param store Vue store
* @param url Repository Url
*/
export async function refreshHelmRepository(store: any, url: string): Promise<void> {
const repository = await getHelmRepositoryExact(store, url);
const now = (new Date()).toISOString().replace(/\.\d+Z$/, 'Z');
repository.spec.forceUpdate = now;
await repository.save();
await repository.waitForState('active', 10000, 1000);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
/**
*
* @param store Vue store
* @param name Repository name
* @param url Repository Url
* @param branch Repository Branch
* @returns HelmRepository
*/
export async function createHelmRepository(store: any, name: string, url: string, branch?: string): Promise<HelmRepository> {
const data = {
type: CATALOG.CLUSTER_REPO,
metadata: { name },
spec: {} as any
};
if (branch) {
data.spec.gitBranch = branch;
data.spec.gitRepo = url;
} else {
data.spec.url = url;
}
// Create a model for the new repository and save it
const repo = await store.dispatch('management/create', data);
const helmRepo = await repo.save();
// Poll the repository until it says it has been downloaded
let fetched = false;
let tries = 0;
while (!fetched) {
const repo = await store.dispatch('management/find', {
type: CATALOG.CLUSTER_REPO,
id: helmRepo.id, // Get the ID from the Helm Repository
opt: { force: true, watch: false }
});
tries++;
const downloaded = repo.status.conditions.find((s: any) => s.type === 'Downloaded');
if (downloaded) {
if (downloaded.status === 'True') {
fetched = true;
}
}
if (!fetched) {
tries++;
if (tries > MAX_RETRIES) {
throw new Error('Failed to add Helm Chart Repository');
}
await new Promise((resolve) => setTimeout(resolve, RETRY_WAIT));
}
fetched = true;
}
// Return the Helm Repository
return helmRepo;
}
/**
* Get the given Helm Chart from the specified Helm Repository
*
* @param store Vue store
* @param repository Repository Url
* @param chartName Helm Chart name
* @returns Helm Chart
*/
export async function getHelmChart(store: any, repository: any, chartName: string): Promise<HelmChart | null> {
let tries = 0;
while (true) {
try {
const versionInfo = await store.dispatch('management/request', {
method: 'GET',
url: `${ repository?.links?.info }&chartName=${ chartName }`,
});
return versionInfo.chart;
} catch (error) {
}
tries++;
if (tries > MAX_RETRIES) {
return null;
}
await new Promise((resolve) => setTimeout(resolve, RETRY_WAIT));
}
}
export async function onExtensionsReady(store: any) {
const alreadyReady = store.getters['uiplugins/ready'];
if (alreadyReady) {
return;
}
const extensions = store.getters['uiplugins/plugins'] || [];
for (let i = 0; i < extensions.length; i++) {
const ext = extensions[i];
try {
await ext.onLogIn(store);
} catch (e) {
console.error(`Exception caught in onReady for extension ${ ext.name }`, e); // eslint-disable-line no-console
}
}
await store.dispatch('uiplugins/setReady', true);
}
/**
* Finds a Helm Chart version which matches plugin displayVersion. First it checks against Chart.appVersion and
* falls back to Chart.version if appVersion is not present.
*
* @param plugin A data object constructed from UIPlugin and Helm Chart versions
* @returns string Helm Chart version
*/
export function getPluginChartVersion(plugin?: Plugin) {
const pluginVersion = plugin?.displayVersion;
return plugin?.versions?.find((v) => pluginVersion === (v.appVersion ?? v.version))?.version ?? pluginVersion;
}
export function getPluginChartVersionLabel(version: Version) {
if (version.appVersion === version.version) return `${ version.version }`;
return `${ version.appVersion } (${ version.version })`;
}