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; } // }, }, };