Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extensions: Add hooks to support virtual clusters #11064

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions shell/assets/translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5042,6 +5042,7 @@ resourceTable:
role: Group by Role
cluster: Group by Cluster
device: Group by Device
hostCluster: Group by Host Cluster
groupLabel:
cluster: "<span>Cluster:</span> {name}"
notInACluster: Not in a Cluster
Expand Down
18 changes: 18 additions & 0 deletions shell/core/types-provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ export interface ClusterProvisionerContext {
*/
export interface IClusterProvisioner {

/**
* Indicates if this provisioner/helper should be used for the given cluster.
*
* This allows the provisioner to determine if it should be used for a cluster based on attributes/metadata of its choosing
*
* @param cluster The cluster (`provisioning.cattle.io.cluster`)
* @returns Whether to use this provisioner for the given cluster.
*/
// static useForModel?(cluster: any): boolean;

/**
* Unique ID of the Cluster Provisioner
* If this overlaps with the name of an existing provisioner (seen in the type query param while creating a cluster) this provisioner will overwrite the built-in ui
Expand Down Expand Up @@ -262,4 +272,12 @@ export interface IClusterProvisioner {
* @returns Array of errors. If there are no errors the array will be empty
*/
provision?(cluster: any, pools: any[]): Promise<any[]>;

/**
* Optionally Process the available actions for a cluster and return a (possibly modified) set of actions
*
* @param cluster The cluster (`provisioning.cattle.io.cluster`)
* @returns List of actions for the cluster
*/
availableActions?(cluster: any, actions: any[]): any[] | undefined;
}
15 changes: 3 additions & 12 deletions shell/detail/provisioning.cattle.io.cluster.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,10 @@ export default {
async fetch() {
await this.value.waitForProvisioner();

const extClass = this.$plugin.getDynamic('provisioner', this.value.machineProvider);

if (extClass) {
this.extProvider = new extClass({
dispatch: this.$store.dispatch,
getters: this.$store.getters,
axios: this.$store.$axios,
$plugin: this.$store.app.$plugin,
$t: this.t
});
if (this.value.customProvisionerHelper) {
this.extDetailTabs = {
...this.extDetailTabs,
...this.extProvider.detailTabs
...this.value.customProvisionerHelper.detailTabs
};
this.extCustomParams = { provider: this.value.machineProvider };
}
Expand Down Expand Up @@ -379,7 +370,7 @@ export default {
},

showNodes() {
return !this.showMachines && this.haveNodes && !!this.nodes.length;
return !this.showMachines && this.haveNodes && !!this.nodes.length && this.extDetailTabs.machines;
},

showSnapshots() {
Expand Down
2 changes: 2 additions & 0 deletions shell/edit/provisioning.cattle.io.cluster/rke2.vue
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,8 @@ export default {
* Extension provider where being provisioned by an extension
*/
extensionProvider() {
// Note we don't use the model customProvisionerHelper here, as for create it won't be set
// Instead we create from the provider value
const extClass = this.$plugin.getDynamic('provisioner', this.provider);

if (extClass) {
Expand Down
7 changes: 7 additions & 0 deletions shell/list/provisioning.cattle.io.cluster.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ export default {
},

computed: {
groupable() {
// Groupable if at least one cluster has a parent cluster
return !!this.filteredRows.find((c) => !!c.groupByParent);
},

filteredRows() {
// If Harvester feature is enabled, hide Harvester Clusters
if (this.harvesterEnabled) {
Expand Down Expand Up @@ -228,6 +233,8 @@ export default {
:data-testid="'cluster-list'"
:force-update-live-and-delayed="forceUpdateLiveAndDelayed"
:sub-rows="true"
:groupable="groupable"
group-tooltip="resourceTable.groupBy.hostCluster"
>
<!-- Why are state column and subrow overwritten here? -->
<!-- for rke1 clusters, where they try to use the mgmt cluster stateObj instead of prov cluster stateObj, -->
Expand Down
65 changes: 63 additions & 2 deletions shell/models/provisioning.cattle.io.cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,46 @@ import SteveModel from '@shell/plugins/steve/steve-class';
import { findBy } from '@shell/utils/array';
import { get, set } from '@shell/utils/object';
import { sortBy } from '@shell/utils/sort';
import { ucFirst } from '@shell/utils/string';
import { escapeHtml, ucFirst } from '@shell/utils/string';
import { compare } from '@shell/utils/version';
import { AS, MODE, _VIEW, _YAML } from '@shell/config/query-params';
import { HARVESTER_NAME as HARVESTER } from '@shell/config/features';
import { CAPI as CAPI_ANNOTATIONS, NODE_ARCHITECTURE } from '@shell/config/labels-annotations';
import { ModelExtensions } from '@shell/utils/model-extensions';

/**
* Class representing Cluster resource.
* @extends SteveModel
*/
export default class ProvCluster extends SteveModel {
/**
* Instance of model extensions utility that we can use for accessing model helpers provided by extensions
*/
get modelExtensions() {
if (!this.__modelExtensions) {
this.__modelExtensions = new ModelExtensions(this, 'provisioner', (model) => model.machineProvider);
}

return this.__modelExtensions;
}

/**
* customProvisionerHelper returns a custom helper if applicable that can be used for this cluster
*/
get customProvisionerHelper() {
return this.modelExtensions.modelHelper;
}

// Ensure we remove the properties for the model extension from the model on save
// Otherwise we get a problem when editing a cluster
cleanForSave(data, forNew) {
super.cleanForSave(data, forNew);
delete data.__modelExtensions;
delete data.__modelHelper;

return data;
}

get details() {
const out = [
{
Expand Down Expand Up @@ -178,7 +207,15 @@ export default class ProvCluster extends SteveModel {
});
}

return actions.concat(out);
const all = actions.concat(out);

// If we have a helper that wants to modify the available actions, let it do it
if (this.customProvisionerHelper?.availableActions) {
// Provider can either modify the provided list or return one of its own
return this.customProvisionerHelper?.availableActions(this, all) || all;
}

return all;
}

get normanCluster() {
Expand Down Expand Up @@ -496,6 +533,10 @@ export default class ProvCluster extends SteveModel {
}

get machineProviderDisplay() {
if (this.customProvisionerHelper?.machineProviderDisplay) {
return this.customProvisionerHelper?.machineProviderDisplay(this);
}

if ( this.isImported ) {
return null;
}
Expand Down Expand Up @@ -954,6 +995,26 @@ export default class ProvCluster extends SteveModel {
if ( res?._status === 204 ) {
await this.$dispatch('ws.resource.remove', { data: this });
}

// If this cluster has a custom provisioner, allow it to do custom deletion
if (this.customProvisionerHelper?.postDelete) {
return this.customProvisionerHelper?.postDelete(this);
}
}

get groupByParent() {
// Customer helper can report if the cluster has a parent cluster
return this.customProvisionerHelper?.parentCluster(this);
}

get groupByLabel() {
const name = this.groupByParent;

if (name) {
return this.$rootGetters['i18n/t']('resourceTable.groupLabel.cluster', { name: escapeHtml(name) });
} else {
return this.$rootGetters['i18n/t']('resourceTable.groupLabel.notInACluster');
}
}

get hasError() {
Expand Down
8 changes: 8 additions & 0 deletions shell/plugins/dashboard-store/resource-class.js
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,14 @@ export default class Resource {
return this.$ctx.rootState;
}

get '$plugin'() {
return this.$ctx.rootState?.$plugin;
}

get '$axios'() {
return this.$ctx.rootState?.$axios;
}

get customValidationRules() {
return [
/**
Expand Down
1 change: 1 addition & 0 deletions shell/scripts/typegen.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ ${BASE_DIR}/node_modules/.bin/tsc ${SHELL_DIR}/config/labels-annotations.js --de
# # store
${BASE_DIR}/node_modules/.bin/tsc ${SHELL_DIR}/store/features.js --declaration --allowJs --emitDeclarationOnly --outDir ${SHELL_DIR}/tmp/store > /dev/null
${BASE_DIR}/node_modules/.bin/tsc ${SHELL_DIR}/store/prefs.js --declaration --allowJs --emitDeclarationOnly --outDir ${SHELL_DIR}/tmp/store > /dev/null
${BASE_DIR}/node_modules/.bin/tsc shell/store/plugins.js --declaration --allowJs --emitDeclarationOnly --outDir ${SHELL_DIR}/tmp/store > /dev/null

# # plugins
${BASE_DIR}/node_modules/.bin/tsc ${SHELL_DIR}/plugins/dashboard-store/normalize.js --declaration --allowJs --emitDeclarationOnly --outDir ${SHELL_DIR}/tmp/plugins/dashboard-store/ > /dev/null
Expand Down
7 changes: 6 additions & 1 deletion shell/store/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export const state = () => {
$router: markRaw({}),
$route: markRaw({}),
$plugin: markRaw({}),
/**
$axios: markRaw({}),
* Cache state of side nav clusters. This avoids flickering when the user changes pages and the side nav component re-renders
*/
sideNavCache: undefined,
Expand Down Expand Up @@ -763,6 +763,10 @@ export const mutations = {

setSideNavCache(state, sideNavCache) {
state.sideNavCache = sideNavCache;
},

setAxios(state, axios) {
state.$axios = markRaw(axios || {});
}
};

Expand Down Expand Up @@ -1210,6 +1214,7 @@ export const actions = {
commit('setRouter', nuxt.app.router);
commit('setRoute', nuxt.route);
commit('setPlugin', nuxt.app.$plugin);
commit('setAxios', nuxt.app.$axios);

dispatch('management/rehydrateSubscribe');
dispatch('cluster/rehydrateSubscribe');
Expand Down
126 changes: 126 additions & 0 deletions shell/utils/model-extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Helper for models to use to simplify using a helper class provided by an extension.
*
* An example use is with provisioning.cattle.io.cluster, where a custom helper can be added via an extension
* to support new provisioners.
*/

// ==============================================================================================

/**
* Interface for an object that can determine if it should be used for a given model
*/
interface UseForModel {
name: string,
useForModel: (model: any) => boolean,
}

// Expected properties on the model
interface IModel {
$rootState: any;
$dispatch: any;
$getters: any;
$axios: any;
$plugin: any;
t: any;
__modelHelper?: any;
annotations: {[key: string]: string};
}

// Function that for a model will return the name of the model extension to use for it
type UseForFunction = (model: any) => string;

// Cache of instantiated model helpers for a given model and helper name
const modelExtensionCache: {[modelName: string]: {[name: string]: any[]}} = {};

// Cache of instantiated functions to determine if a helper should be used for a given model
const modelExtensionUseForCache: {[modelName: string]: UseForModel[]} = {};

// ==============================================================================================

export class ModelExtensions {
constructor(private model: IModel, private modelExtensionName: string, private defaultUseFor?: UseForFunction) {
// Initialize the cache if needed for this model extension name
if (!modelExtensionCache[this.modelExtensionName]) {
modelExtensionCache[this.modelExtensionName] = {};
}
}

/**
* Get a model helper for the model
*/
get modelHelper(): any {
// Use cached helper if set
if (this.model.__modelHelper) {
return this.model.__modelHelper;
}

// First ask all of the helpers that have a 'useForModel' function if they should be used
let helper = this.useForHelpers.find((h) => h.useForModel(this.model))?.name;

// If no helper and we have a default function, look for a helper that matches the value returned by the function
if (!helper && this.defaultUseFor) {
helper = this.defaultUseFor(this.model);
}

// Cache for next time
this.model.__modelHelper = helper ? this.instantiateModelHelper(helper) : undefined;

return this.model.__modelHelper;
}

/**
* Go through all of the extension helpers with the required name and find the ones that
* have a custom 'useForModel' static function and return those as an array
*
* Cache this list for a given model extension so we can use it in future calls
*/
get useForHelpers(): UseForModel[] {
if (!modelExtensionUseForCache[this.modelExtensionName]) {
modelExtensionUseForCache[this.modelExtensionName] = [];

const helpers = this.model.$rootState.$plugin.listDynamic(this.modelExtensionName);

helpers.forEach((name: string) => {
const customProvisionerCls = this.model.$rootState.$plugin.getDynamic(this.modelExtensionName, name);
const useForModel = customProvisionerCls.useForModel;

if (useForModel) {
modelExtensionUseForCache[this.modelExtensionName].push({
name,
useForModel
});
}
});
}

return modelExtensionUseForCache[this.modelExtensionName];
}

/**
* Instantiate the given model helper
*
* @param name Name of the helper
* @returns Instance of the helper
*/
instantiateModelHelper(name: string): any {
// Check if we have an instance of the helper already cached
if (!modelExtensionCache[this.modelExtensionName][name]) {
const customProvisionerCls = this.model.$rootState.$plugin.getDynamic(this.modelExtensionName, name);

if (customProvisionerCls) {
const context = {
dispatch: this.model.$dispatch,
getters: this.model.$getters,
$axios: this.model.$axios,
$plugin: this.model.$plugin,
$t: this.model.t
};

modelExtensionCache[this.modelExtensionName][name] = new customProvisionerCls(context);
}
}

return modelExtensionCache[this.modelExtensionName][name];
}
}
Loading