import compact from 'lodash/compact'; import isEmpty from 'lodash/isEmpty'; import isFunction from 'lodash/isFunction'; import isString from 'lodash/isString'; import jsyaml from 'js-yaml'; import omitBy from 'lodash/omitBy'; import pickBy from 'lodash/pickBy'; import forIn from 'lodash/forIn'; import uniq from 'lodash/uniq'; import Vue from 'vue'; import { addObject, addObjects, findBy, removeAt } from '@/utils/array'; import CustomValidators from '@/utils/custom-validators'; import { downloadFile, generateZip } from '@/utils/download'; import { eachLimit } from '@/utils/promise'; import { get } from '@/utils/object'; import { DEV } from '@/store/prefs'; import { sortableNumericSuffix } from '@/utils/sort'; import { coerceStringTypeToScalarType, escapeHtml, matchesSomeRegex, ucFirst } from '@/utils/string'; import { displayKeyFor, validateChars, validateDnsLikeTypes, validateLength, validateBoolean } from '@/utils/validators'; import { ANNOTATIONS_TO_IGNORE_REGEX, DESCRIPTION, LABELS_TO_IGNORE_REGEX, NORMAN_NAME } from '@/config/labels-annotations'; import { AS, _YAML, MODE, _CLONE, _EDIT, _VIEW, _UNFLAG, _CONFIG } from '@/config/query-params'; import { SELF } from '@/plugins/steve/resource-proxy'; import { cleanForNew, normalizeType } from './normalize'; const STRING_LIKE_TYPES = [ 'string', 'date', 'blob', 'enum', 'multiline', 'masked', 'password', 'dnsLabel', 'hostname', ]; const DNS_LIKE_TYPES = ['dnsLabel', 'dnsLabelRestricted', 'hostname']; const REMAP_STATE = { disabled: 'inactive', notapplied: 'Not Applied', notready: 'Not Ready', waitapplied: 'Wait Applied', outofsync: 'Out of Sync', 'in-progress': 'In Progress', gitupdating: 'Git Updating', errapplied: 'Err Applied', waitcheckin: 'Wait Check-In', off: 'Disabled', waitingforinfrastructure: 'Waiting for Infra', }; const DEFAULT_COLOR = 'warning'; const DEFAULT_ICON = 'x'; const DEFAULT_WAIT_INTERVAL = 1000; const DEFAULT_WAIT_TMIMEOUT = 30000; export const STATES = { 'in-progress': { color: 'info', icon: 'tag' }, 'in-use': { color: 'success', icon: 'dot-open' }, 'pending-rollback': { color: 'info', icon: 'dot-half' }, 'pending-upgrade': { color: 'info', icon: 'dot-half' }, 'N/A': { color: 'warning', icon: 'x' }, 'vm-error': { color: 'error', icon: 'dot' }, aborted: { color: 'warning', icon: 'error' }, activating: { color: 'info', icon: 'tag' }, active: { color: 'success', icon: 'dot-open' }, available: { color: 'success', icon: 'dot-open' }, backedup: { color: 'success', icon: 'backup' }, bound: { color: 'success', icon: 'dot' }, building: { color: 'success', icon: 'dot-open' }, completed: { color: 'success', icon: 'dot' }, cordoned: { color: 'info', icon: 'tag' }, count: { color: 'success', icon: 'dot-open' }, created: { color: 'info', icon: 'tag' }, creating: { color: 'info', icon: 'tag' }, deactivating: { color: 'info', icon: 'adjust' }, degraded: { color: 'warning', icon: 'error' }, denied: { color: 'error', icon: 'adjust' }, deployed: { color: 'success', icon: 'dot-open' }, disabled: { color: 'warning', icon: 'error' }, disconnected: { color: 'warning', icon: 'error' }, drained: { color: 'info', icon: 'tag' }, draining: { color: 'warning', icon: 'tag' }, downloading: { color: 'info', icon: 'tag' }, enteringMaintain: { color: 'info', icon: 'tag' }, errapplied: { color: 'error', icon: 'error' }, error: { color: 'error', icon: 'error' }, erroring: { color: 'error', icon: 'error' }, errors: { color: 'error', icon: 'error' }, expired: { color: 'warning', icon: 'error' }, exporting: { color: 'info', icon: 'tag' }, fail: { color: 'error', icon: 'error' }, failed: { color: 'error', icon: 'error' }, healthy: { color: 'success', icon: 'dot-open' }, inactive: { color: 'error', icon: 'dot' }, initializing: { color: 'warning', icon: 'error' }, inprogress: { color: 'info', icon: 'spinner' }, imported: { color: 'success', icon: 'dot-open' }, locked: { color: 'warning', icon: 'adjust' }, maintain: { color: 'info', icon: 'tag' }, migrating: { color: 'info', icon: 'info' }, missing: { color: 'warning', icon: 'adjust' }, modified: { color: 'warning', icon: 'edit' }, notApplicable: { color: 'warning', icon: 'tag' }, notapplied: { color: 'warning', icon: 'tag' }, notready: { color: 'warning', icon: 'tag' }, off: { color: 'darker', icon: 'error' }, orphaned: { color: 'warning', icon: 'tag' }, other: { color: 'info', icon: 'info' }, outofsync: { color: 'warning', icon: 'tag' }, pass: { color: 'success', icon: 'dot-dotfill' }, passed: { color: 'success', icon: 'dot-dotfill' }, paused: { color: 'info', icon: 'info' }, pending: { color: 'info', icon: 'tag' }, provisioning: { color: 'info', icon: 'dot' }, provisioned: { color: 'success', icon: 'dot' }, purged: { color: 'error', icon: 'purged' }, purging: { color: 'info', icon: 'purged' }, progress: { color: 'warning', icon: 'x' }, ready: { color: 'success', icon: 'dot-open' }, reconnecting: { color: 'error', icon: 'error' }, registering: { color: 'info', icon: 'tag' }, reinitializing: { color: 'warning', icon: 'error' }, released: { color: 'warning', icon: 'error' }, removed: { color: 'error', icon: 'trash' }, removing: { color: 'info', icon: 'trash' }, requested: { color: 'info', icon: 'tag' }, restarting: { color: 'info', icon: 'adjust' }, restoring: { color: 'info', icon: 'medicalcross' }, running: { color: 'success', icon: 'dot-open' }, skip: { color: 'info', icon: 'dot-open' }, skipped: { color: 'info', icon: 'dot-open' }, starting: { color: 'info', icon: 'adjust' }, stopped: { color: 'error', icon: 'dot' }, stopping: { color: 'info', icon: 'adjust' }, succeeded: { color: 'success', icon: 'dot-dotfill' }, success: { color: 'success', icon: 'dot-open' }, superseded: { color: 'info', icon: 'dot-open' }, suspended: { color: 'info', icon: 'pause' }, unavailable: { color: 'error', icon: 'error' }, unhealthy: { color: 'error', icon: 'error' }, uninstalled: { color: 'info', icon: 'trash' }, uninstalling: { color: 'info', icon: 'trash' }, unknown: { color: 'warning', icon: 'x' }, untriggered: { color: 'success', icon: 'tag' }, uploading: { color: 'info', icon: 'tag' }, updating: { color: 'warning', icon: 'tag' }, waitapplied: { color: 'info', icon: 'tag' }, waitcheckin: { color: 'warning', icon: 'tag' }, waiting: { color: 'info', icon: 'tag' }, warning: { color: 'warning', icon: 'error' }, validated: { color: 'success', icon: 'dot-open' }, }; export function getStatesByType(type = 'info') { const out = { info: [], error: [], success: [], warning: [], unknown: [], }; forIn(STATES, (state, stateKey) => { if (state.color) { if (out[state.color]) { out[state.color].push(stateKey); } else { out.unknown.push(stateKey); } } }); return out; } const SORT_ORDER = { error: 1, warning: 2, info: 3, success: 4, other: 5, }; export function colorForState(state, isError, isTransitioning) { if ( isError ) { return 'text-error'; } if ( isTransitioning ) { return 'text-info'; } const key = (state || 'active').toLowerCase(); let color; if ( STATES[key] && STATES[key].color ) { color = maybeFn.call(this, STATES[key].color); } if ( !color ) { color = DEFAULT_COLOR; } return `text-${ color }`; } export function stateDisplay(state) { // @TODO use translations const key = (state || 'active').toLowerCase(); if ( REMAP_STATE[key] ) { return REMAP_STATE[key]; } return key.split(/-/).map(ucFirst).join('-'); } export function stateSort(color, display) { color = color.replace(/^(text|bg)-/, ''); return `${ SORT_ORDER[color] || SORT_ORDER['other'] } ${ display }`; } function maybeFn(val) { if ( isFunction(val) ) { return val(this); } return val; } export default { customValidationRules() { return [ /** * Essentially a fake schema object with additonal params to extend validation * * @param {nullable} Value is nullabel * @param {path} Path on the resource to the value to validate * @param {required} Value required * @param {requiredIf} Value required if value at path not empty * @param {translationKey} Human readable display key for param in path e.g. metadata.name === Name * @param {type} Type of field to validate * @param {validators} array of strings where item is name of exported validator function in custom-validtors, args can be passed by prepending args seperated by colon. e.g maxLength:63 */ /* { nullable: false, path: 'spec.ports', required: true, type: 'array', validators: ['servicePort'], } */ ]; }, _key() { const m = this.metadata; if ( m ) { if ( m.uid ) { return m.uid; } if ( m.namespace ) { return `${ this.type }/${ m.namespace }/${ m.name }`; } } if ( this.id ) { return `${ this.type }/${ this.id }`; } return `${ this.type }/${ Math.random() }`; }, schema() { return this.$getters['schemaFor'](this.type); }, toString() { return () => { return `[${ this.type }: ${ this.id }]`; }; }, toJSON() { return () => { return this[SELF]; }; }, description() { return this.metadata?.annotations?.[DESCRIPTION] || this.spec?.description || this._description; }, labels() { const all = this.metadata?.labels || {}; return omitBy(all, (value, key) => { return matchesSomeRegex(key, this.labelsToIgnoreRegexes); }); }, annotations() { const all = this.metadata?.annotations || {}; return omitBy(all, (value, key) => { return matchesSomeRegex(key, this.annotationsToIgnoreRegexes); }); }, typeDisplay() { const schema = this.schema; if ( schema ) { return this.$rootGetters['type-map/labelFor'](schema); } return '?'; }, nameDisplay() { return this.displayName || this.spec?.displayName || this.metadata?.annotations?.[NORMAN_NAME] || this.name || this.metadata?.name || this.id; }, nameSort() { return sortableNumericSuffix(this.nameDisplay).toLowerCase(); }, namespacedName() { const namespace = this.metadata?.namespace; const name = this.nameDisplay; if ( namespace ) { return `${ namespace }:${ name }`; } return name; }, namespacedNameSort() { return sortableNumericSuffix(this.namespacedName).toLowerCase(); }, name() { return this._name || this.metadata?.name; }, namespace() { return this.metadata?.namespace; }, groupByLabel() { const name = this.metadata?.namespace; let out; if ( name ) { out = this.t('resourceTable.groupLabel.namespace', { name: escapeHtml(name) }); } else { out = this.t('resourceTable.groupLabel.notInANamespace'); } return out; }, labelsToIgnoreRegexes() { return LABELS_TO_IGNORE_REGEX; }, setLabels() { return (val) => { if ( !this.metadata ) { this.metadata = {}; } const all = this.metadata.labels || {}; const wasIgnored = pickBy(all, (value, key) => { return matchesSomeRegex(key, this.labelsToIgnoreRegexes); }); Vue.set(this.metadata, 'labels', { ...wasIgnored, ...val }); }; }, setLabel() { return (key, val) => { if ( val ) { if ( !this.metadata ) { this.metadata = {}; } if ( !this.metadata.labels ) { this.metadata.labels = {}; } Vue.set(this.metadata.labels, key, val); } else if ( this.metadata?.labels ) { Vue.set(this.metadata.labels, key, undefined); delete this.metadata.labels[key]; } }; }, annotationsToIgnoreRegexes() { return ANNOTATIONS_TO_IGNORE_REGEX; }, setAnnotations() { return (val) => { if ( !this.metadata ) { this.metadata = {}; } const all = this.metadata.annotations || {}; const wasIgnored = pickBy(all, (value, key) => { return matchesSomeRegex(key, this.annotationsToIgnoreRegexes); }); Vue.set(this.metadata, 'annotations', { ...wasIgnored, ...val }); }; }, setAnnotation() { return (key, val) => { if ( val ) { if ( !this.metadata ) { this.metadata = {}; } if ( !this.metadata.annotations ) { this.metadata.annotations = {}; } Vue.set(this.metadata.annotations, key, val); } else if ( this.metadata?.annotations ) { Vue.set(this.metadata.annotations, key, undefined); delete this.metadata.annotations[key]; } }; }, // You can override the state by providing your own state (and possibly reading metadata.state) state() { return this.stateObj?.name || this._state || 'unknown'; }, // You can override the displayed by providing your own stateDisplay (and possibly using the function exported above) stateDisplay() { return stateDisplay(this.state); }, stateColor() { return colorForState.call( this, this.state, this.stateObj?.error, this.stateObj?.transitioning ); }, stateBackground() { return this.stateColor.replace('text-', 'bg-'); }, stateIcon() { let trans = false; let error = false; if ( this.metadata && this.metadata.state ) { trans = this.metadata.state.transitioning; error = this.metadata.state.error; } if ( trans ) { return 'icon icon-spinner icon-spin'; } if ( error ) { return 'icon icon-error'; } const key = (this.state || '').toLowerCase(); let icon; if ( STATES[key] && STATES[key].icon ) { icon = maybeFn.call(this, STATES[key].icon); } if ( !icon ) { icon = DEFAULT_ICON; } return `icon icon-${ icon }`; }, stateSort() { return stateSort(this.stateColor, this.stateDisplay); }, stateDescription() { const trans = this.stateObj?.transitioning || false; const error = this.stateObj?.error || false; const message = this.stateObj?.message; return trans || error ? ucFirst(message) : ''; }, stateObj() { return this.metadata?.state; }, // ------------------------------------------------------------------ waitForTestFn() { return (fn, msg, timeoutMs, intervalMs) => { console.log('Starting wait for', msg); // eslint-disable-line no-console if ( !timeoutMs ) { timeoutMs = DEFAULT_WAIT_TMIMEOUT; } if ( !intervalMs ) { intervalMs = DEFAULT_WAIT_INTERVAL; } return new Promise((resolve, reject) => { // Do a first check immediately if ( fn.apply(this) ) { console.log('Wait for', msg, 'done immediately'); // eslint-disable-line no-console resolve(this); } const timeout = setTimeout(() => { console.log('Wait for', msg, 'timed out'); // eslint-disable-line no-console clearInterval(interval); clearTimeout(timeout); reject(new Error(`Failed while: ${ msg }`)); }, timeoutMs); const interval = setInterval(() => { if ( fn.apply(this) ) { console.log('Wait for', msg, 'done'); // eslint-disable-line no-console clearInterval(interval); clearTimeout(timeout); resolve(this); } else { console.log('Wait for', msg, 'not done yet'); // eslint-disable-line no-console } }, intervalMs); }); }; }, waitForState() { return (state, timeout, interval) => { return this.waitForTestFn(() => { return (this.state || '').toLowerCase() === state.toLowerCase(); }, `state=${ state }`, timeout, interval); }; }, waitForTransition() { return () => { return this.waitForTestFn(() => { return !this.transitioning; }, 'transition completion'); }; }, waitForAction() { return (name) => { return this.waitForTestFn(() => { return this.hasAction(name); }, `action=${ name }`); }; }, waitForLink() { return (name) => { return this.waitForTestFn(() => { return this.hasLink(name); }, `link=${ name }`); }; }, hasCondition() { return (condition) => { return this.isCondition(condition, null); }; }, isSystemResource() { const systemNamespaces = this.$rootGetters['systemNamespaces']; if ( systemNamespaces.includes(this.metadata?.namespace) ) { return true; } return false; }, isCondition() { return (condition, withStatus = 'True') => { if ( !this.status || !this.status.conditions ) { return false; } const entry = findBy((this.status.conditions || []), 'type', condition); if ( !entry ) { return false; } if ( !withStatus ) { return true; } return (entry.status || '').toLowerCase() === `${ withStatus }`.toLowerCase(); }; }, waitForCondition() { return (name, withStatus = 'True', timeoutMs = DEFAULT_WAIT_TMIMEOUT, intervalMs = DEFAULT_WAIT_INTERVAL) => { return this.waitForTestFn(() => { return this.isCondition(name, withStatus); }, `condition ${ name }=${ withStatus }`, timeoutMs, intervalMs); }; }, // ------------------------------------------------------------------ availableActions() { const all = this._availableActions; // Remove disabled items and consecutive dividers let last = null; const out = all.filter((item) => { if ( item.enabled === false ) { return false; } const cur = item.divider; const ok = !cur || (cur && !last); last = cur; return ok; }); // Remove dividers at the beginning while ( out.length && out[0].divider ) { out.shift(); } // Remove dividers at the end while ( out.length && out[out.length - 1].divider ) { out.pop(); } // Remove consecutive dividers in the middle for ( let i = 1 ; i < out.length ; i++ ) { if ( out[i].divider && out[i - 1].divider ) { removeAt(out, i, 1); i--; } } return out; }, // You can add custom actions by overriding your own availableActions (and probably reading _standardActions) _availableActions() { return this._standardActions; }, _standardActions() { const all = [ { divider: true }, { action: this.canUpdate ? 'goToEdit' : 'goToViewConfig', label: this.t(this.canUpdate ? 'action.edit' : 'action.view'), icon: 'icon icon-edit', enabled: this.canCustomEdit, }, { action: this.canUpdate ? 'goToEditYaml' : 'goToViewYaml', label: this.t(this.canUpdate ? 'action.editYaml' : 'action.viewYaml'), icon: 'icon icon-file', enabled: this.canYaml, }, { action: 'download', label: this.t('action.download'), icon: 'icon icon-download', bulkable: true, bulkAction: 'downloadBulk', enabled: this.canYaml }, { action: (this.canCustomEdit ? 'goToClone' : 'cloneYaml'), label: this.t('action.clone'), icon: 'icon icon-copy', enabled: this.canClone && this.canCreate && (this.canCustomEdit || this.canYaml), }, { divider: true }, { action: 'promptRemove', altAction: 'remove', label: this.t('action.remove'), icon: 'icon icon-trash', bulkable: true, enabled: this.canDelete, bulkAction: 'promptRemove', weight: -10, // Delete always goes last }, { action: 'viewInApi', label: this.t('action.viewInApi'), icon: 'icon icon-external-link', enabled: this.canViewInApi, } ]; return all; }, // ------------------------------------------------------------------ canDelete() { return this._canDelete; }, _canDelete() { return this.hasLink('remove') && this.$rootGetters['type-map/optionsFor'](this.type).isRemovable; }, canClone() { return true; }, canUpdate() { return this.hasLink('update') && this.$rootGetters['type-map/optionsFor'](this.type).isEditable; }, canCustomEdit() { return this.$rootGetters['type-map/hasCustomEdit'](this.type, this.id); }, canCreate() { if ( this.schema && !this.schema?.collectionMethods.find(x => x.toLowerCase() === 'post') ) { return false; } return this.$rootGetters['type-map/optionsFor'](this.type).isCreatable; }, canViewInApi() { return this.hasLink('self') && this.$rootGetters['prefs/get'](DEV); }, canYaml() { return this.hasLink('view'); }, // ------------------------------------------------------------------ hasLink() { return (linkName) => { return !!this.linkFor(linkName); }; }, linkFor() { return (linkName) => { return (this.links || {})[linkName]; }; }, followLink() { return (linkName, opt = {}) => { if ( !opt.url ) { opt.url = (this.links || {})[linkName]; } if ( opt.urlSuffix ) { opt.url += opt.urlSuffix; } if ( !opt.url ) { throw new Error(`Unknown link ${ linkName } on ${ this.type } ${ this.id }`); } return this.$dispatch('request', opt); }; }, // ------------------------------------------------------------------ hasAction() { return (actionName) => { return !!this.actionLinkFor(actionName); }; }, actionLinkFor() { return (actionName) => { return (this.actions || this.actionLinks || {})[actionName]; }; }, doAction() { return (actionName, body, opt = {}) => { return this.$dispatch('resourceAction', { resource: this, actionName, body, opt, }); }; }, // ------------------------------------------------------------------ patch() { return (data, opt = {}) => { if ( !opt.url ) { opt.url = this.linkFor('self'); } opt.method = 'patch'; opt.headers = opt.headers || {}; opt.headers['content-type'] = 'application/json-patch+json'; opt.data = data; return this.$dispatch('request', opt); }; }, save() { return this._save; }, _save() { return async(opt = {}) => { delete this.__rehydrate; delete this.__clone; const forNew = !this.id; const errors = await this.validationErrors(this, opt.ignoreFields); if (!isEmpty(errors)) { return Promise.reject(errors); } if ( this.metadata?.resourceVersion ) { this.metadata.resourceVersion = `${ this.metadata.resourceVersion }`; } if ( !opt.url ) { if ( forNew ) { const schema = this.$getters['schemaFor'](this.type); let url = schema.linkFor('collection'); if ( schema.attributes && schema.attributes.namespaced && this.metadata && this.metadata.namespace ) { url += `/${ this.metadata.namespace }`; } opt.url = url; } else { opt.url = this.linkFor('update') || this.linkFor('self'); } } if ( !opt.method ) { opt.method = ( forNew ? 'post' : 'put' ); } if ( !opt.headers ) { opt.headers = {}; } if ( !opt.headers['content-type'] ) { opt.headers['content-type'] = 'application/json'; } if ( !opt.headers['accept'] ) { opt.headers['accept'] = 'application/json'; } // @TODO remove this once the API maps steve _type <-> k8s type in both directions opt.data = { ...this }; if (opt?.data._type) { opt.data.type = opt.data._type; } if (opt?.data._name) { opt.data.name = opt.data._name; } if (opt?.data._labels) { opt.data.labels = opt.data._labels; } if (opt?.data._annotations) { opt.data.annotations = opt.data._annotations; } try { const res = await this.$dispatch('request', opt); // console.log('### Resource Save', this.type, this.id); // Steve sometimes returns Table responses instead of the resource you just saved.. ignore if ( res && res.kind !== 'Table') { await this.$dispatch('load', { data: res, existing: (forNew ? this : undefined ) }); } } catch (e) { if ( this.type && this.id && e?._status === 409) { // If there's a conflict, try to load the new version await this.$dispatch('find', { type: this.type, id: this.id, opt: { force: true } }); } return Promise.reject(e); } return this; }; }, remove() { return async(opt = {}) => { if ( !opt.url ) { opt.url = (this.links || {})['self']; } opt.method = 'delete'; const res = await this.$dispatch('request', opt); if ( res?._status === 204 ) { // If there's no body, assume the resource was immediately deleted // and drop it from the store as if a remove event happened. await this.$dispatch('ws.resource.remove', { data: this }); } }; }, // ------------------------------------------------------------------ currentRoute() { return () => { if ( process.server ) { return this.$rootState.$route; } else { return window.$nuxt.$route; } }; }, currentRouter() { return () => { if ( process.server ) { return this.$rootState.$router; } else { return window.$nuxt.$router; } }; }, listLocation() { return { name: `c-cluster-product-resource`, params: { product: this.$rootGetters['productId'], cluster: this.$rootGetters['clusterId'], resource: this.type, } }; }, _detailLocation() { const schema = this.$getters['schemaFor'](this.type); const id = this.id?.replace(/.*\//, ''); return { name: `c-cluster-product-resource${ schema?.attributes?.namespaced ? '-namespace' : '' }-id`, params: { product: this.$rootGetters['productId'], cluster: this.$rootGetters['clusterId'], resource: this.type, namespace: this.metadata?.namespace, id, } }; }, detailLocation() { return this._detailLocation; }, goToDetail() { this.currentRouter().push(this.detailLocation); }, goToClone() { return (moreQuery = {}) => { const location = this.detailLocation; location.query = { ...location.query, [MODE]: _CLONE, [AS]: _UNFLAG, ...moreQuery }; this.currentRouter().push(location); }; }, goToEdit() { return (moreQuery = {}) => { const location = this.detailLocation; location.query = { ...location.query, [MODE]: _EDIT, [AS]: _UNFLAG, ...moreQuery }; this.currentRouter().push(location); }; }, goToViewConfig() { return (moreQuery = {}) => { const location = this.detailLocation; location.query = { ...location.query, [MODE]: _VIEW, [AS]: _CONFIG, ...moreQuery }; this.currentRouter().push(location); }; }, goToEditYaml() { return () => { const location = this.detailLocation; location.query = { ...location.query, [MODE]: _EDIT, [AS]: _YAML }; this.currentRouter().push(location); }; }, goToViewYaml() { return () => { const location = this.detailLocation; location.query = { ...location.query, [MODE]: _VIEW, [AS]: _YAML }; this.currentRouter().push(location); }; }, cloneYaml() { return (moreQuery = {}) => { const location = this.detailLocation; location.query = { ...location.query, [MODE]: _CLONE, [AS]: _YAML, ...moreQuery }; this.currentRouter().push(location); }; }, download() { return async() => { const value = await this.followLink('view', { headers: { accept: 'application/yaml' } }); downloadFile(`${ this.nameDisplay }.yaml`, value.data, 'application/yaml'); }; }, downloadBulk() { return async(items) => { const files = {}; const names = []; for ( const item of items ) { let name = `${ item.nameDisplay }.yaml`; let i = 2; while ( names.includes(name) ) { name = `${ item.nameDisplay }_${ i++ }.yaml`; } names.push(name); } await eachLimit(items, 10, (item, idx) => { return item.followLink('view', { headers: { accept: 'application/yaml' } } ).then((data) => { files[`resources/${ names[idx] }`] = data.data || data; }); }); const zip = await generateZip(files); downloadFile('resources.zip', zip, 'application/zip'); }; }, viewInApi() { return () => { window.open(this.links.self, '_blank'); }; }, promptRemove() { return (resources = this) => { this.$dispatch('promptRemove', resources); }; }, confirmRemove() { return false; }, applyDefaults() { return () => { }; }, urlFromAttrs() { const schema = this.$getters['schemaFor'](this.type); const { metadata:{ namespace = 'default' } } = this; let url = schema.links.collection; const attributes = schema?.attributes; if (!attributes) { throw new Error('Attributes must be present on the schema'); } const { group, resource } = attributes; url = `${ url.slice(0, url.indexOf('/v1')) }/apis/${ group }/namespaces/${ namespace }/${ resource }`; return url; }, // convert yaml to object, clean for new if creating/cloning // map _type to type cleanYaml() { return (yaml, mode = 'edit') => { try { const obj = jsyaml.load(yaml); if (mode !== 'edit') { cleanForNew(obj); } if (obj._type) { obj.type = obj._type; delete obj._type; } const out = jsyaml.dump(obj, { skipInvalid: true }); return out; } catch (e) { return null; } }; }, cleanForNew() { return () => { cleanForNew(this); }; }, yamlForSave() { return (yaml) => { try { const obj = jsyaml.load(yaml); if (obj) { if (this._type) { obj._type = obj.type; } return jsyaml.dump(obj); } } catch (e) { return null; } }; }, saveYaml() { return async(yaml) => { /* Multipart support, but need to know the right cluster and work for management store and "apply" seems to only work for create, not update. const ary = jsyaml.loadAll(yaml); // will throw on invalid yaml, and return one or more documents (usually one) if ( ary.length > 1 ) { await this.$rootGetters['currentCluster'].doAction('apply', { yaml, defaultNamespace: this.metadata.namespace, }); } const parsed = ary[0]; */ const parsed = jsyaml.load(yaml); // will throw on invalid yaml, and return one or more documents (usually one) if ( this.schema?.attributes?.namespaced && !parsed.metadata.namespace ) { const err = this.$rootGetters['i18n/t']('resourceYaml.errors.namespaceRequired'); throw err; } let res; const isCreate = !this.id; const headers = { 'content-type': 'application/yaml', accept: 'application/json', }; if ( isCreate ) { res = await this.schema.followLink('collection', { method: 'POST', headers, data: yaml }); } else { res = await this.followLink('update', { method: 'PUT', headers, data: yaml }); } await this.$dispatch(`load`, { data: res, existing: (isCreate ? this : undefined) }); if (this.isSpoofed) { await this.$dispatch('cluster/findAll', { type: this.type, opt: { force: true } }, { root: true }); } }; }, validationErrors() { return (data, ignoreFields) => { const errors = []; const { type: originalType, schema } = data; const type = normalizeType(originalType); if ( !originalType ) { // eslint-disable-next-line console.warn(this.t('validation.noType'), data); return errors; } if ( !schema ) { // eslint-disable-next-line console.warn(this.t('validation.noSchema'), originalType, data); return errors; } const fields = schema.resourceFields || {}; const keys = Object.keys(fields); let field, key, val, displayKey; for ( let i = 0 ; i < keys.length ; i++ ) { const fieldErrors = []; key = keys[i]; field = fields[key]; val = get(data, key); displayKey = displayKeyFor(type, key, this.$rootGetters); const fieldType = field?.type ? normalizeType(field.type) : null; const valIsString = isString(val); if ( ignoreFields && ignoreFields.includes(key) ) { continue; } if ( val === undefined ) { val = null; } if (valIsString) { if (fieldType) { Vue.set(data, key, coerceStringTypeToScalarType(val, fieldType)); } // Empty strings on nullable string fields -> null if ( field.nullable && val.length === 0 && STRING_LIKE_TYPES.includes(fieldType)) { val = null; Vue.set(data, key, val); } } if (fieldType === 'boolean') { validateBoolean(val, field, displayKey, this.$rootGetters, fieldErrors); } else { validateLength(val, field, displayKey, this.$rootGetters, fieldErrors); validateChars(val, field, displayKey, this.$rootGetters, fieldErrors); } if (fieldErrors.length > 0) { fieldErrors.push(this.t('validation.required', { key: displayKey })); errors.push(...fieldErrors); continue; } // IDs claim to be these but are lies... if ( key !== 'id' && !isEmpty(val) && DNS_LIKE_TYPES.includes(fieldType) ) { // DNS types should be lowercase const tolower = (val || '').toLowerCase(); if ( tolower !== val ) { val = tolower; Vue.set(data, key, val); } fieldErrors.push(...validateDnsLikeTypes(val, fieldType, displayKey, this.$rootGetters, fieldErrors)); } errors.push(...fieldErrors); } let { customValidationRules } = this; if (!isEmpty(customValidationRules)) { if (isFunction(customValidationRules)) { customValidationRules = customValidationRules(); } customValidationRules.forEach((rule) => { const { path, requiredIf: requiredIfPath, validators = [], type: fieldType, } = rule; let pathValue = get(data, path); const parsedRules = compact((validators || [])); let displayKey = path; if (rule.translationKey && this.$rootGetters['i18n/exists'](rule.translationKey)) { displayKey = this.t(rule.translationKey); } if (isString(pathValue)) { pathValue = pathValue.trim(); } if (requiredIfPath) { const reqIfVal = get(data, requiredIfPath); if (!isEmpty(reqIfVal) && (isEmpty(pathValue) && pathValue !== 0)) { errors.push(this.t('validation.required', { key: displayKey })); } } validateLength(pathValue, rule, displayKey, this.$rootGetters, errors); validateChars(pathValue, rule, displayKey, this.$rootGetters, errors); if ( !isEmpty(pathValue) && DNS_LIKE_TYPES.includes(fieldType) ) { // DNS types should be lowercase const tolower = (pathValue || '').toLowerCase(); if ( tolower !== pathValue ) { pathValue = tolower; Vue.set(data, path, pathValue); } errors.push(...validateDnsLikeTypes(pathValue, fieldType, displayKey, this.$rootGetters, errors)); } parsedRules.forEach((validator) => { const validatorAndArgs = validator.split(':'); const validatorName = validatorAndArgs.slice(0, 1); const validatorArgs = validatorAndArgs.slice(1) || null; const validatorExists = Object.prototype.hasOwnProperty.call(CustomValidators, validatorName); if (!isEmpty(validatorName) && validatorExists) { CustomValidators[validatorName](pathValue, this.$rootGetters, errors, validatorArgs, displayKey, data); } else if (!isEmpty(validatorName) && !validatorExists) { // eslint-disable-next-line console.warn(this.t('validation.custom.missing', { validatorName })); } }); }); } return uniq(errors); }; }, ownersByType() { const { metadata:{ ownerReferences = [] } } = this; const ownersByType = {}; ownerReferences.forEach((owner) => { if (!ownersByType[owner.kind]) { ownersByType[owner.kind] = [owner]; } else { ownersByType[owner.kind].push(owner); } }); return ownersByType; }, owners() { const owners = []; for ( const kind in this.ownersByType) { const schema = this.$rootGetters['cluster/schema'](kind); if (schema) { const type = schema.id; const allOfResourceType = this.$rootGetters['cluster/all']( type ); this.ownersByType[kind].forEach((resource, idx) => { const resourceInstance = allOfResourceType.find(resource => resource?.metdata?.uid === resource.uid); if (resourceInstance) { owners.push(resourceInstance); } }); } } return owners; }, details() { return this._details; }, _details() { const details = []; if (this.owners?.length > 0) { details.push({ label: this.t('resourceDetail.detailTop.ownerReferences', { count: this.owners.length }), formatter: 'ListLinkDetail', content: this.owners.map(owner => ({ key: owner.id, row: owner, col: {}, value: owner.metadata.name })) }); } if (get(this, 'metadata.deletionTimestamp')) { details.push({ label: this.t('resourceDetail.detailTop.deleted'), formatter: 'LiveDate', formatterOpts: { addSuffix: true }, content: get(this, 'metadata.deletionTimestamp') }); } return details; }, t() { return this.$rootGetters['i18n/t']; }, // Returns array of MODELS that own this resource (async, network call) findOwners() { return () => { return this._getRelationship('owner', 'from'); }; }, // Returns array of {type, namespace, id} objects that own this resource (sync) getOwners() { return () => { return this._getRelationship('owner', 'from'); }; }, findOwned() { return () => { return this._findRelationship('owner', 'to'); }; }, _relationshipsFor() { return (rel, direction) => { const out = { selectors: [], ids: [] }; if ( !this.metadata?.relationships?.length ) { return out; } for ( const r of this.metadata.relationships ) { if ( rel !== 'any' && r.rel !== rel ) { continue; } if ( !r[`${ direction }Type`] ) { continue; } if ( r.selector ) { addObjects(out.selectors, { type: r.toType, namespace: r.toNamespace, selector: r.selector }); } else { const type = r[`${ direction }Type`]; let namespace = r[`${ direction }Namespace`]; let name = r[`${ direction }Id`]; if ( !namespace && name.includes('/') ) { const idx = name.indexOf('/'); namespace = name.substr(0, idx); name = name.substr(idx + 1); } const id = (namespace ? `${ namespace }/` : '') + name; addObject(out.ids, { type, namespace, name, id, }); } } return out; }; }, _getRelationship() { return (rel, direction) => { const res = this._relationshipsFor(rel, direction); if ( res.selectors?.length ) { // eslint-disable-next-line no-console console.warn('Sync request for a relationship that is a selector'); } return res.ids || []; }; }, _findRelationship() { return async(rel, direction) => { const { selectors, ids } = this._relationshipsFor(rel, direction); const out = []; for ( const sel of selectors ) { const matching = await this.$dispatch('findMatching', sel); addObjects(out, matching.data); } for ( const obj of ids ) { const { type, id } = obj; let matching = this.$getters['byId'](type, id); if ( !matching ) { try { matching = await this.$dispatch('find', { type, id }); } catch { } } if (matching) { addObject(out, matching); } } return out; }; }, shortId() { const splitId = this.id.split('/'); return splitId.length > 1 ? splitId[1] : splitId[0]; } };