dashboard/shell/edit/workload/mixins/workload.js

953 lines
27 KiB
JavaScript

import omitBy from 'lodash/omitBy';
import { cleanUp } from '@shell/utils/object';
import {
CONFIG_MAP,
SECRET,
WORKLOAD_TYPES,
NODE,
SERVICE,
PVC,
SERVICE_ACCOUNT,
CAPI,
} from '@shell/config/types';
import Tab from '@shell/components/Tabbed/Tab';
import CreateEditView from '@shell/mixins/create-edit-view';
import { allHash } from '@shell/utils/promise';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { LabeledInput } from '@components/Form/LabeledInput';
import ServiceNameSelect from '@shell/components/form/ServiceNameSelect';
import HealthCheck from '@shell/components/form/HealthCheck';
import Security from '@shell/components/form/Security';
import Upgrading from '@shell/edit/workload/Upgrading';
import Loading from '@shell/components/Loading';
import Networking from '@shell/components/form/Networking';
import VolumeClaimTemplate from '@shell/edit/workload/VolumeClaimTemplate';
import Job from '@shell/edit/workload/Job';
import { _EDIT, _CREATE, _VIEW } from '@shell/config/query-params';
import WorkloadPorts from '@shell/components/form/WorkloadPorts';
import ContainerResourceLimit from '@shell/components/ContainerResourceLimit';
import KeyValue from '@shell/components/form/KeyValue';
import Tabbed from '@shell/components/Tabbed';
import { mapGetters } from 'vuex';
import NodeScheduling from '@shell/components/form/NodeScheduling';
import PodAffinity from '@shell/components/form/PodAffinity';
import Tolerations from '@shell/components/form/Tolerations';
import CruResource from '@shell/components/CruResource';
import Command from '@shell/components/form/Command';
import LifecycleHooks from '@shell/components/form/LifecycleHooks';
import Storage from '@shell/edit/workload/storage';
import Labels from '@shell/components/form/Labels';
import { RadioGroup } from '@components/Form/Radio';
import { UI_MANAGED } from '@shell/config/labels-annotations';
import { removeObject } from '@shell/utils/array';
import { BEFORE_SAVE_HOOKS } from '@shell/mixins/child-hook';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import formRulesGenerator from '@shell/utils/validators/formRules';
import { TYPES as SECRET_TYPES } from '@shell/models/secret';
const TAB_WEIGHT_MAP = {
general: 99,
healthCheck: 98,
labels: 97,
networking: 96,
nodeScheduling: 95,
podScheduling: 94,
resources: 93,
upgrading: 92,
securityContext: 91,
storage: 90,
volumeClaimTemplates: 89,
};
const GPU_KEY = 'nvidia.com/gpu';
export default {
name: 'CruWorkload',
components: {
ContainerResourceLimit,
Command,
CruResource,
HealthCheck,
Job,
KeyValue,
LabeledInput,
LabeledSelect,
Labels,
LifecycleHooks,
Loading,
NameNsDescription,
Networking,
NodeScheduling,
PodAffinity,
RadioGroup,
Security,
ServiceNameSelect,
Storage,
Tab,
Tabbed,
Tolerations,
Upgrading,
VolumeClaimTemplate,
WorkloadPorts,
},
mixins: [CreateEditView],
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
default: 'create',
},
createOption: {
default: (text) => {
if (text) {
return { metadata: { name: text } };
}
},
type: Function
},
},
async fetch() {
const requests = { rancherClusters: this.$store.dispatch('management/findAll', { type: CAPI.RANCHER_CLUSTER }) };
const needed = {
configMaps: CONFIG_MAP,
nodes: NODE,
services: SERVICE,
pvcs: PVC,
sas: SERVICE_ACCOUNT,
secrets: SECRET,
};
// Only fetch types if the user can see them
Object.keys(needed).forEach((key) => {
const type = needed[key];
if (this.$store.getters['cluster/schemaFor'](type)) {
requests[key] = this.$store.dispatch('cluster/findAll', { type });
}
});
const hash = await allHash(requests);
this.servicesOwned = hash.services ? await this.value.getServicesOwned() : [];
this.allSecrets = hash.secrets || [];
this.allConfigMaps = hash.configMaps || [];
this.allNodeObjects = hash.nodes || [];
this.allNodes = this.allNodeObjects.map(node => node.id);
this.allServices = hash.services || [];
this.pvcs = hash.pvcs || [];
this.sas = hash.sas || [];
},
data() {
let type = this.$route.params.resource;
const createSidecar = !!this.$route.query.sidecar;
const isInitContainer = !!this.$route.query.init;
if (type === 'workload') {
type = null;
}
if (!this.value.spec) {
this.value.spec = {};
if (this.value.type === WORKLOAD_TYPES.POD) {
const podContainers = [{
imagePullPolicy: 'Always',
name: `container-0`,
}];
const podSpec = { template: { spec: { containers: podContainers, initContainers: [] } } };
this.$set(this.value, 'spec', podSpec);
}
}
if ((this.mode === _EDIT || this.mode === _VIEW ) && this.value.type === 'pod' ) {
const podSpec = { ...this.value.spec };
this.$set(this.value.spec, 'template', { spec: podSpec });
}
const spec = this.value.spec;
let podTemplateSpec = type === WORKLOAD_TYPES.CRON_JOB ? spec.jobTemplate.spec.template.spec : spec?.template?.spec;
let containers = podTemplateSpec.containers || [];
let container;
if (this.mode === _VIEW && this.value.type === 'pod' ) {
podTemplateSpec = spec;
}
if (
this.mode === _CREATE ||
this.mode === _VIEW ||
(!createSidecar && !this.value.hasSidecars) // hasSideCars = containers.length > 1 || initContainers.length;
) {
container = containers[0];
} else {
// This means that there are no containers.
if (!podTemplateSpec.initContainers) {
podTemplateSpec.initContainers = [];
}
const allContainers = [
...podTemplateSpec.initContainers,
...podTemplateSpec.containers,
];
if (this.$route.query.init) {
podTemplateSpec.initContainers.push({
imagePullPolicy: 'Always',
name: `container-${ allContainers.length }`,
});
containers = podTemplateSpec.initContainers;
}
if (createSidecar || this.value.type === 'pod') {
container = {
imagePullPolicy: 'Always',
name: `container-${ allContainers.length }`,
};
containers.push(container);
} else {
container = containers[0];
}
}
this.selectContainer(container);
return {
allConfigMaps: [],
allNodes: null,
allNodeObjects: [],
allSecrets: [],
allServices: [],
name: this.value?.metadata?.name || null,
pvcs: [],
sas: [],
showTabs: false,
pullPolicyOptions: ['Always', 'IfNotPresent', 'Never'],
spec,
type,
servicesOwned: [],
servicesToRemove: [],
portsForServices: [],
isInitContainer,
container,
containerChange: 0,
tabChange: 0,
podFsGroup: podTemplateSpec.securityContext?.fsGroup,
savePvcHookName: 'savePvcHook',
tabWeightMap: TAB_WEIGHT_MAP,
fvFormRuleSets: [{
path: 'image', rootObject: this.container, rules: ['required'], translationKey: 'workload.container.image'
}],
fvReportedValidationPaths: ['spec'],
};
},
computed: {
tabErrors() {
return { general: this.fvGetPathErrors(['image'])?.length > 0 };
},
defaultTab() {
if (!!this.$route.query.sidecar || this.$route.query.init || this.mode === _CREATE) {
return 'container-0';
}
return this.allContainers.length ? this.allContainers[0].name : '';
},
isEdit() {
return this.mode === _EDIT;
},
isJob() {
return this.type === WORKLOAD_TYPES.JOB || this.isCronJob;
},
isCronJob() {
return this.type === WORKLOAD_TYPES.CRON_JOB;
},
isReplicable() {
return (
this.type === WORKLOAD_TYPES.DEPLOYMENT ||
this.type === WORKLOAD_TYPES.REPLICA_SET ||
this.type === WORKLOAD_TYPES.REPLICATION_CONTROLLER ||
this.type === WORKLOAD_TYPES.STATEFUL_SET
);
},
isDeployment() {
return this.type === WORKLOAD_TYPES.DEPLOYMENT;
},
isPod() {
return this.value.type === WORKLOAD_TYPES.POD;
},
isStatefulSet() {
return this.type === WORKLOAD_TYPES.STATEFUL_SET;
},
// if this is a cronjob, grab pod spec from within job template spec
podTemplateSpec: {
get() {
return this.isCronJob ? this.spec.jobTemplate.spec.template.spec : this.spec?.template?.spec;
},
set(neu) {
if (this.isCronJob) {
this.$set(this.spec.jobTemplate.spec.template, 'spec', neu);
} else {
this.$set(this.spec.template, 'spec', neu);
}
},
},
podLabels: {
get() {
if (this.isCronJob) {
if (!this.spec.jobTemplate.metadata) {
this.$set(this.spec.jobTemplate, 'metadata', { labels: {} });
}
return this.spec.jobTemplate.metadata.labels;
} else {
if (!this.spec.template.metadata) {
this.$set(this.spec.template, 'metadata', { labels: {} });
}
return this.spec.template.metadata.labels;
}
},
set(neu) {
if (this.isCronJob) {
this.$set(this.spec.jobTemplate.metadata, 'labels', neu);
} else {
this.$set(this.spec.template.metadata, 'labels', neu);
}
},
},
podAnnotations: {
get() {
if (this.isCronJob) {
if (!this.spec.jobTemplate.metadata) {
this.$set(this.spec.jobTemplate, 'metadata', { annotations: {} });
}
return this.spec.jobTemplate.metadata.annotations;
} else {
if (!this.spec.template.metadata) {
this.$set(this.spec.template, 'metadata', { annotations: {} });
}
return this.spec.template.metadata.annotations;
}
},
set(neu) {
if (this.isCronJob) {
this.$set(this.spec.jobTemplate.metadata, 'annotations', neu);
} else {
this.$set(this.spec.template.metadata, 'annotations', neu);
}
},
},
allContainers() {
const containers = this.podTemplateSpec?.containers || [];
const initContainers = this.podTemplateSpec?.initContainers || [];
return [
...containers,
...initContainers.map((each) => {
each._init = true;
return each;
}),
].map((container) => {
const containerImageRule = formRulesGenerator(this.$store.getters['i18n/t'], { name: container.name }).containerImage;
container.error = containerImageRule(container);
return container;
});
},
flatResources: {
get() {
const { limits = {}, requests = {} } = this.container.resources || {};
const {
cpu: limitsCpu,
memory: limitsMemory,
[GPU_KEY]: limitsGpu,
} = limits;
const { cpu: requestsCpu, memory: requestsMemory } = requests;
return {
limitsCpu,
limitsMemory,
requestsCpu,
requestsMemory,
limitsGpu,
};
},
set(neu) {
const {
limitsCpu,
limitsMemory,
requestsCpu,
requestsMemory,
limitsGpu,
} = neu;
const out = {
requests: {
cpu: requestsCpu,
memory: requestsMemory,
},
limits: {
cpu: limitsCpu,
memory: limitsMemory,
[GPU_KEY]: limitsGpu,
},
};
this.$set(this.container, 'resources', cleanUp(out));
},
},
healthCheck: {
get() {
const { readinessProbe, livenessProbe, startupProbe } = this.container;
return {
readinessProbe,
livenessProbe,
startupProbe,
};
},
set(neu) {
Object.assign(this.container, neu);
},
},
imagePullSecrets: {
get() {
if (!this.podTemplateSpec.imagePullSecrets) {
this.$set(this.podTemplateSpec, 'imagePullSecrets', []);
}
const { imagePullSecrets } = this.podTemplateSpec;
return imagePullSecrets.map(each => each.name);
},
set(neu) {
this.podTemplateSpec.imagePullSecrets = neu.map((secret) => {
return { name: secret };
});
},
},
schema() {
return this.$store.getters['cluster/schemaFor'](this.type);
},
namespacedSecrets() {
const namespace = this.value?.metadata?.namespace;
if (namespace) {
return this.allSecrets.filter(
secret => secret.metadata.namespace === namespace
);
} else {
return this.allSecrets;
}
},
imagePullNamespacedSecrets() {
const namespace = this.value?.metadata?.namespace;
return this.allSecrets.filter(secret => secret.metadata.namespace === namespace && (secret._type === SECRET_TYPES.DOCKER || secret._type === SECRET_TYPES.DOCKER_JSON));
},
namespacedConfigMaps() {
const namespace = this.value?.metadata?.namespace;
if (namespace) {
return this.allConfigMaps.filter(
configMap => configMap.metadata.namespace === namespace
);
} else {
return this.allConfigMaps;
}
},
namespacedServiceNames() {
const { namespace } = this.value?.metadata;
if (namespace) {
return this.sas.filter(
serviceName => serviceName.metadata.namespace === namespace
);
} else {
return this.sas;
}
},
headlessServices() {
return this.allServices.filter(
service => service.spec.clusterIP === 'None' &&
service.metadata.namespace === this.value.metadata.namespace
);
},
workloadTypes() {
return omitBy(WORKLOAD_TYPES, (type) => {
return (
type === WORKLOAD_TYPES.REPLICA_SET ||
type === WORKLOAD_TYPES.REPLICATION_CONTROLLER
);
});
},
// array of id, label, description, initials for type selection step
workloadSubTypes() {
const out = [];
for (const prop in this.workloadTypes) {
const type = this.workloadTypes[prop];
const subtype = {
id: type,
description: `workload.typeDescriptions.'${ type }'`,
label: this.nameDisplayFor(type),
bannerAbbrv: this.initialDisplayFor(type),
};
out.push(subtype);
}
return out;
},
containerOptions() {
const out = [...this.allContainers];
if (!this.isView) {
out.push({ name: 'Add Container', __add: true });
}
return out;
},
...mapGetters({ t: 'i18n/t' }),
},
watch: {
type(neu, old) {
const template =
old === WORKLOAD_TYPES.CRON_JOB ? this.spec?.jobTemplate?.spec?.template : this.spec?.template;
if (!template.spec) {
template.spec = {};
}
let restartPolicy;
if (this.isJob || this.isCronJob) {
restartPolicy = 'Never';
} else {
restartPolicy = 'Always';
}
this.$set(template.spec, 'restartPolicy', restartPolicy);
if (!this.isReplicable) {
delete this.spec.replicas;
}
if (old === WORKLOAD_TYPES.CRON_JOB) {
this.$set(this.spec, 'template', { ...template });
delete this.spec.jobTemplate;
delete this.spec.schedule;
} else if (neu === WORKLOAD_TYPES.CRON_JOB) {
this.$set(this.spec, 'jobTemplate', { spec: { template } });
this.$set(this.spec, 'schedule', '0 * * * *');
delete this.spec.template;
}
this.$set(this.value, 'type', neu);
delete this.value.apiVersion;
},
container(neu) {
const containers = this.isInitContainer ? this.podTemplateSpec.initContainers : this.podTemplateSpec.containers;
const existing = containers.find(container => container.__active) || {};
Object.assign(existing, neu);
},
},
created() {
this.registerBeforeHook(this.saveWorkload, 'willSaveWorkload');
this.registerBeforeHook(this.getPorts, 'getPorts');
this.registerAfterHook(this.saveService, 'saveService');
},
methods: {
addContainerBtn() {
this.selectContainer({ name: 'Add Container', __add: true });
},
nameDisplayFor(type) {
const schema = this.$store.getters['cluster/schemaFor'](type);
return this.$store.getters['type-map/labelFor'](schema) || '';
},
// TODO better images for workload types?
// show initials of workload type in blue circles for now
initialDisplayFor(type) {
const typeDisplay = this.nameDisplayFor(type);
return typeDisplay
.split('')
.filter(letter => letter.match(/[A-Z]/))
.join('');
},
cancel() {
this.done();
},
async getPorts() {
const ports = (await this.value.getPortsWithServiceType()) || [];
this.portsForServices = ports;
},
async saveService() {
// If we can't access services then just return - the UI should only allow ports without service creation
if (!this.$store.getters['cluster/schemaFor'](SERVICE)) {
return;
}
const { toSave = [], toRemove = [] } =
(await this.value.servicesFromContainerPorts(
this.mode,
this.portsForServices
)) || {};
this.servicesOwned = toSave;
this.servicesToRemove = toRemove;
if (!toSave.length && !toRemove.length) {
return;
}
return Promise.all([
...toSave.map(svc => svc.save()),
...toRemove.map((svc) => {
const ui = svc?.metadata?.annotations[UI_MANAGED];
if (ui) {
svc.remove();
}
}),
]);
},
saveWorkload() {
if (
this.type !== WORKLOAD_TYPES.JOB &&
this.type !== WORKLOAD_TYPES.CRON_JOB &&
this.mode === _CREATE
) {
this.spec.selector = { matchLabels: this.value.workloadSelector };
Object.assign(this.value.metadata.labels, this.value.workloadSelector);
}
let template;
if (this.type === WORKLOAD_TYPES.CRON_JOB) {
template = this.spec.jobTemplate;
} else {
template = this.spec.template;
}
if (
this.type !== WORKLOAD_TYPES.JOB &&
this.type !== WORKLOAD_TYPES.CRON_JOB &&
this.mode === _CREATE
) {
if (!template.metadata) {
template.metadata = { labels: this.value.workloadSelector };
} else {
Object.assign(template.metadata.labels, this.value.workloadSelector);
}
}
if (template.spec.containers && template.spec.containers[0]) {
const containerResources = template.spec.containers[0].resources;
const nvidiaGpuLimit =
template.spec.containers[0].resources?.limits?.[GPU_KEY];
// Though not required, requests are also set to mirror the ember ui
if (nvidiaGpuLimit > 0) {
containerResources.requests = containerResources.requests || {};
containerResources.requests[GPU_KEY] = nvidiaGpuLimit;
}
if (!this.nvidiaIsValid(nvidiaGpuLimit)) {
try {
delete containerResources.requests[GPU_KEY];
delete containerResources.limits[GPU_KEY];
if (Object.keys(containerResources.limits).length === 0) {
delete containerResources.limits;
}
if (Object.keys(containerResources.requests).length === 0) {
delete containerResources.requests;
}
if (Object.keys(containerResources).length === 0) {
delete template.spec.containers[0].resources;
}
} catch {}
}
}
const nodeAffinity = template?.spec?.affinity?.nodeAffinity || {};
const podAffinity = template?.spec?.affinity?.podAffinity || {};
const podAntiAffinity = template?.spec?.affinity?.podAntiAffinity || {};
this.fixNodeAffinity(nodeAffinity);
this.fixPodAffinity(podAffinity);
this.fixPodAffinity(podAntiAffinity);
this.fixPodSecurityContext(this.podTemplateSpec);
// delete this.value.kind;
if (this.container && !this.container.name) {
this.$set(this.container, 'name', this.value.metadata.name);
}
const ports = this.value.containers.reduce((total, each) => {
const containerPorts = each.ports || [];
total.push(
...containerPorts.filter(
port => port._serviceType && port._serviceType !== ''
)
);
return total;
}, []);
// ports contain info used to create services after saving
this.portsForServices = ports;
Object.assign(this.value, { spec: this.spec });
},
// node and pod affinity are formatted incorrectly from API; fix before saving
fixNodeAffinity(nodeAffinity) {
const preferredDuringSchedulingIgnoredDuringExecution =
nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution || [];
const requiredDuringSchedulingIgnoredDuringExecution =
nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution || {};
preferredDuringSchedulingIgnoredDuringExecution.forEach((term) => {
const matchExpressions = term?.preference?.matchExpressions || [];
matchExpressions.forEach((expression) => {
if (expression.values) {
expression.values =
typeof expression.values === 'string' ? [expression.values] : [...expression.values];
}
});
});
(
requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms || []
).forEach((term) => {
const matchExpressions = term.matchExpressions || [];
matchExpressions.forEach((expression) => {
if (expression.values) {
expression.values =
typeof expression.values === 'string' ? [expression.values] : [...expression.values];
}
});
});
},
fixPodAffinity(podAffinity) {
const preferredDuringSchedulingIgnoredDuringExecution =
podAffinity.preferredDuringSchedulingIgnoredDuringExecution || [];
const requiredDuringSchedulingIgnoredDuringExecution =
podAffinity.requiredDuringSchedulingIgnoredDuringExecution || [];
preferredDuringSchedulingIgnoredDuringExecution.forEach((term) => {
const matchExpressions =
term?.podAffinityTerm?.labelSelector?.matchExpressions || [];
matchExpressions.forEach((expression) => {
if (expression.values) {
expression.values =
typeof expression.values === 'string' ? [expression.values] : [...expression.values];
}
});
});
requiredDuringSchedulingIgnoredDuringExecution.forEach((term) => {
const matchExpressions = term?.labelSelector?.matchExpressions || [];
matchExpressions.forEach((expression) => {
if (expression.values) {
expression.values =
typeof expression.values === 'string' ? [expression.values] : [...expression.values];
}
});
});
return podAffinity;
},
fixPodSecurityContext(podTempSpec) {
if (this.podFsGroup) {
podTempSpec.securityContext = podTempSpec.securityContext || {};
podTempSpec.securityContext.fsGroup = this.podFsGroup;
} else {
if (podTempSpec.securityContext?.fsGroup) {
delete podTempSpec.securityContext.fsGroup;
}
if (Object.keys(podTempSpec.securityContext || {}).length === 0) {
delete podTempSpec.securityContext;
}
}
},
selectType(type) {
if (!this.type && type) {
this.$router.replace({ params: { resource: type } });
} else {
this.type = type;
}
},
selectContainer(container) {
if (container.__add) {
this.addContainer();
return;
}
(this.allContainers || []).forEach((container) => {
if (container.__active) {
delete container.__active;
}
});
container.__active = true;
this.container = container;
this.isInitContainer = !!container._init;
this.containerChange++;
},
addContainer() {
let nameNumber = this.allContainers.length;
const allNames = this.allContainers.reduce((names, each) => {
names.push(each.name);
return names;
}, []);
while (allNames.includes(`container-${ nameNumber }`)) {
nameNumber++;
}
const container = {
imagePullPolicy: 'Always',
name: `container-${ nameNumber }`,
active: true
};
this.podTemplateSpec.containers.push(container);
this.selectContainer(container);
},
removeContainer(container) {
if (container._init) {
removeObject(this.podTemplateSpec.initContainers, container);
} else {
removeObject(this.podTemplateSpec.containers, container);
}
this.selectContainer(this.allContainers[0]);
},
updateInitContainer(neu) {
if (!this.container) {
return;
}
const containers = this.podTemplateSpec.containers;
if (neu) {
if (!this.podTemplateSpec.initContainers) {
this.podTemplateSpec.initContainers = [];
}
this.podTemplateSpec.initContainers.push(this.container);
removeObject(containers, this.container);
} else {
delete this.container._init;
const initContainers = this.podTemplateSpec.initContainers;
removeObject(initContainers, this.container);
containers.push(this.container);
}
this.isInitContainer = neu;
},
clearPvcFormState(hookName) {
// On the `closePvcForm` event, remove the
// before save hook to prevent the PVC from
// being created. Use the PVC's unique ID to distinguish
// between hooks for different PVCs.
if (this[BEFORE_SAVE_HOOKS]) {
this.unregisterBeforeSaveHook(hookName);
}
},
updateServiceAccount(neu) {
if (neu) {
this.podTemplateSpec.serviceAccount = neu;
this.podTemplateSpec.serviceAccountName = neu;
} else {
// Note - both have to be removed in order for removal to work
delete this.podTemplateSpec.serviceAccount;
delete this.podTemplateSpec.serviceAccountName;
}
},
nvidiaIsValid(nvidiaGpuLimit) {
if ( !Number.isInteger(parseInt(nvidiaGpuLimit)) ) {
return false;
}
if (nvidiaGpuLimit === undefined) {
return false;
}
if (nvidiaGpuLimit < 1) {
return false;
} else {
return true;
}
//
},
},
};