import { findBy, insertAt } from '@shell/utils/array'; import { CATTLE_PUBLIC_ENDPOINTS } from '@shell/config/labels-annotations'; import { WORKLOAD_TYPES, SERVICE, POD } from '@shell/config/types'; import { set } from '@shell/utils/object'; import day from 'dayjs'; import { convertSelectorObj, parse } from '@shell/utils/selector'; import { SEPARATOR } from '@shell/config/workload'; import WorkloadService from '@shell/models/workload.service'; import { matching } from '@shell/utils/selector-typed'; export const defaultContainer = { imagePullPolicy: 'Always', name: 'container-0', securityContext: { runAsNonRoot: false, readOnlyRootFilesystem: false, privileged: false, allowPrivilegeEscalation: false, }, volumeMounts: [] }; export default class Workload extends WorkloadService { // remove clone as yaml/edit as yaml until API supported get _availableActions() { let out = super._availableActions; const type = this._type ? this._type : this.type; const editYaml = findBy(out, 'action', 'goToEditYaml'); const index = editYaml ? out.indexOf(editYaml) : 0; insertAt(out, index, { action: 'addSidecar', label: this.t('action.addSidecar'), icon: 'icon icon-plus', enabled: !!this.links.update, }); if (type !== WORKLOAD_TYPES.JOB && type !== WORKLOAD_TYPES.CRON_JOB && type !== WORKLOAD_TYPES.REPLICA_SET ) { insertAt(out, 0, { action: 'toggleRollbackModal', label: this.t('action.rollback'), icon: 'icon icon-downgrade-alt', enabled: !!this.links.update, }); insertAt(out, 0, { action: 'redeploy', label: this.t('action.redeploy'), icon: 'icon icon-refresh', enabled: !!this.links.update, bulkable: true, bulkAction: 'redeploy' }); insertAt(out, 0, { action: 'pause', label: this.t('asyncButton.pause.action'), icon: 'icon icon-pause', enabled: !!this.links.update && !this.spec?.paused }); insertAt(out, 0, { action: 'resume', label: this.t('asyncButton.resume.action'), icon: 'icon icon-play', enabled: !!this.links.update && this.spec?.paused === true }); } insertAt(out, 0, { divider: true }) ; insertAt(out, 0, { action: 'openShell', enabled: !!this.links.view, icon: 'icon icon-chevron-right', label: this.t('action.openShell'), total: 1, }); const toFilter = ['cloneYaml']; out = out.filter((action) => { if (!toFilter.includes(action.action)) { return action; } }); return out; } applyDefaults() { const { spec = {} } = this; if (this.type === WORKLOAD_TYPES.CRON_JOB) { if (!spec.jobTemplate) { spec.jobTemplate = { spec: { template: { spec: { restartPolicy: 'Never', containers: [{ imagePullPolicy: 'Always', name: 'container-0' }], initContainers: [] } } } }; } } else { if (!spec.replicas && spec.replicas !== 0) { spec.replicas = 1; } if (!spec.template) { spec.template = { spec: { restartPolicy: this.type === WORKLOAD_TYPES.JOB ? 'Never' : 'Always', containers: [{ ...structuredClone(defaultContainer) }], initContainers: [] } }; } if (!spec.selector) { spec.selector = {}; } } this.spec = spec; } toggleRollbackModal( workload = this ) { this.$dispatch('promptModal', { componentProps: { workload }, component: 'RollbackWorkloadDialog' }); } async rollBackWorkload( cluster, workload, type, rollbackRequestData ) { const rollbackRequestBody = JSON.stringify(rollbackRequestData); if ( Array.isArray( workload ) ) { throw new TypeError(this.t('promptRollback.multipleWorkloadError')); } const namespace = workload.metadata.namespace; const workloadName = workload.metadata.name; /** * Ensure we go out to the correct cluster * * Build the request body in the same format that kubectl * uses to call the Kubernetes API to roll back a workload. * To see an example request body, run: * kubectl rollout undo deployment/[deployment name] --to-revision=[revision number] -v=8 */ await this.patch(rollbackRequestBody, { url: `/k8s/clusters/${ cluster.id }/apis/apps/v1/namespaces/${ namespace }/${ type }/${ workloadName }` }); } pause() { set(this.spec, 'paused', true); this.save(); } resume() { set(this.spec, 'paused', false); this.save(); } async scaleDown() { const newScale = this.spec.replicas - 1; if (newScale >= 0) { set(this.spec, 'replicas', newScale); await this.save(); } } async scaleUp() { set(this.spec, 'replicas', this.spec.replicas + 1); await this.save(); } get state() { if ( this.spec?.paused === true ) { return 'paused'; } return super.state; } async openShell() { const pods = await this.matchingPods(); for ( const pod of pods ) { if ( pod.isRunning ) { pod.openShell(); return; } } this.$dispatch('growl/error', { title: 'Unavailable', message: 'There are no running pods to execute a shell in.' }, { root: true }); } addSidecar() { return this.goToEdit({ sidecar: true }); } get restartCount() { return this.pods.reduce((total, pod) => { const { status:{ containerStatuses = [] } } = pod; if (containerStatuses.length) { total += containerStatuses.reduce((tot, container) => { tot += container.restartCount || 0; return tot; }, 0); } return total; }, 0); } get hasSidecars() { const podTemplateSpec = this.type === WORKLOAD_TYPES.CRON_JOB ? this?.spec?.jobTemplate?.spec?.template?.spec : this.spec?.template?.spec; const { containers = [], initContainers = [] } = podTemplateSpec; return containers.length > 1 || initContainers.length; } get customValidationRules() { const type = this._type ? this._type : this.type; const podSpecPath = type === WORKLOAD_TYPES.CRON_JOB ? 'spec.jobTemplate.spec.template.spec' : 'spec.template.spec'; const out = [ { nullable: false, path: 'metadata.name', required: true, translationKey: 'generic.name', type: 'subDomain', }, { nullable: false, path: 'spec', required: true, type: 'object', validators: ['containerImages'], }, { nullable: true, path: `${ podSpecPath }.affinity`, type: 'object', validators: ['podAffinity'], } ]; switch (type) { case WORKLOAD_TYPES.DEPLOYMENT: case WORKLOAD_TYPES.REPLICA_SET: out.push( { nullable: false, path: 'spec.replicas', required: true, type: 'number', translationKey: 'workload.replicas' }); break; case WORKLOAD_TYPES.STATEFUL_SET: out.push({ nullable: false, path: 'spec.replicas', required: true, type: 'number', translationKey: 'workload.replicas' }); out.push({ nullable: false, path: 'spec.serviceName', required: true, type: 'string', translationKey: 'workload.serviceName' }); break; case WORKLOAD_TYPES.CRON_JOB: out.push( { nullable: false, path: 'spec.schedule', required: true, type: 'string', validators: ['cronSchedule'], translationKey: 'workload.cronSchedule' }); } return out; } get endpoint() { return this?.metadata?.annotations?.[CATTLE_PUBLIC_ENDPOINTS]; } get desired() { return this.spec?.replicas || 0; } get available() { return this.status?.readyReplicas || 0; } get ready() { const readyReplicas = Math.max(0, (this.status?.replicas || 0) - (this.status?.unavailableReplicas || 0)); if (this.type === WORKLOAD_TYPES.DAEMON_SET) { return readyReplicas; } return `${ readyReplicas }/${ this.desired }`; } get unavailable() { return this.status?.unavailableReplicas || 0; } get upToDate() { return this.status?.updatedReplicas; } get details() { const out = []; const type = this._type ? this._type : this.type; const detailItem = { restarts: { label: this.t('resourceDetail.masthead.restartCount'), content: this.restartCount }, endpoint: { label: 'Endpoints', content: this.endpoint, formatter: 'WorkloadDetailEndpoints' }, ready: { label: 'Ready', content: this.ready }, upToDate: { label: 'Up-to-date', content: this.upToDate }, available: { label: 'Available', content: this.available } }; if (type === WORKLOAD_TYPES.JOB) { const { completionTime, startTime } = this.status; const FACTORS = [60, 60, 24]; const LABELS = ['sec', 'min', 'hour', 'day']; if ( startTime ) { out.push({ label: 'Started', content: startTime, formatter: 'LiveDate', formatterOpts: { addSuffix: true }, }); } if (completionTime && startTime) { const end = day(completionTime); const start = day(startTime); let diff = end.diff(start) / 1000; let label; let i = 0; while ( diff >= FACTORS[i] && i < FACTORS.length ) { diff /= FACTORS[i]; i++; } if ( diff < 5 ) { label = Math.floor(diff * 10) / 10; } else { label = Math.floor(diff); } label += ` ${ this.t(`unit.${ LABELS[i] }`, { count: label }) } `; label = label.trim(); out.push({ label: 'Duration', content: label }); } } else if ( type === WORKLOAD_TYPES.CRON_JOB ) { out.push({ label: 'Last Scheduled Time', content: this?.status?.lastScheduleTime, formatter: 'LiveDate' }); } out.push({ label: 'Image', content: this.imageNames, formatter: 'PodImages' }, { label: detailItem.restarts.label, content: detailItem.restarts.content, }); switch (type) { case WORKLOAD_TYPES.DEPLOYMENT: out.push(detailItem.ready, detailItem.upToDate, detailItem.available, SEPARATOR, detailItem.endpoint); break; case WORKLOAD_TYPES.DAEMON_SET: out.push(detailItem.ready, SEPARATOR, detailItem.endpoint); break; case WORKLOAD_TYPES.REPLICA_SET: out.push(detailItem.ready, SEPARATOR, detailItem.endpoint); break; case WORKLOAD_TYPES.STATEFUL_SET: out.push(detailItem.ready, SEPARATOR, detailItem.endpoint); break; case WORKLOAD_TYPES.REPLICATION_CONTROLLER: out.push(detailItem.ready, SEPARATOR, detailItem.endpoint); break; case WORKLOAD_TYPES.JOB: out.push(detailItem.endpoint); break; case WORKLOAD_TYPES.CRON_JOB: out.push(detailItem.endpoint); break; case POD: out.push(detailItem.ready); break; default: break; } return out; } redeploy(resources = this) { const workloads = Array.isArray(resources) ? resources : [resources]; this.$dispatch('promptModal', { modalWidth: '500px', componentProps: { workloads }, component: 'RedeployWorkloadDialog' }); } // match existing container ports with services created for this workload async getPortsWithServiceType() { const ports = []; this.containers.forEach((container) => ports.push(...(container.ports || []))); (this.initContainers || []).forEach((container) => ports.push(...(container.ports || []))); // Only get services owned if we can access the service resource const canAccessServices = this.$getters['schemaFor'](SERVICE); const services = canAccessServices ? await this.getServicesOwned() : []; const clusterIPServicePorts = []; const loadBalancerServicePorts = []; const nodePortServicePorts = []; if (services.length) { services.forEach((svc) => { switch (svc.spec.type) { case 'ClusterIP': clusterIPServicePorts.push(...(svc?.spec?.ports || [])); break; case 'LoadBalancer': loadBalancerServicePorts.push(...(svc?.spec?.ports || [])); break; case 'NodePort': nodePortServicePorts.push(...(svc?.spec?.ports || [])); break; default: break; } }); } ports.forEach((port) => { const name = port.name ? port.name : `${ port.containerPort }${ port.protocol.toLowerCase() }${ port.hostPort || port._listeningPort || '' }`; port.name = name; if (port._serviceType && port._serviceType !== '') { return; } if (loadBalancerServicePorts.length) { const portSpec = findBy(loadBalancerServicePorts, 'name', name); if (portSpec) { port._listeningPort = portSpec.port; port._serviceType = 'LoadBalancer'; return; } } if (nodePortServicePorts.length) { const portSpec = findBy(nodePortServicePorts, 'name', name); if (portSpec) { port._listeningPort = portSpec.nodePort; port._serviceType = 'NodePort'; return; } } if (clusterIPServicePorts.length) { if (findBy(clusterIPServicePorts, 'name', name)) { port._serviceType = 'ClusterIP'; } } }); return ports; } get ownedByWorkload() { const types = Object.values(WORKLOAD_TYPES); if (this.metadata?.ownerReferences) { for (const owner of this.metadata.ownerReferences) { const have = (`${ owner.apiVersion.replace(/\/.*/, '') }.${ owner.kind }`).toLowerCase(); if ( types.includes(have) ) { return true; } } } return false; } get isFromNorman() { return (this.metadata.labels || {})['cattle.io/creator'] === 'norman'; } get warnDeletionMessage() { if (this.isFromNorman) { return this.t('workload.normanWarning'); } else { return null; } } async fetchPods() { if (this.podMatchExpression) { return this.$dispatch('findLabelSelector', { type: POD, matching: { namespace: this.metadata.namespace, labelSelector: { matchExpressions: this.podMatchExpression }, }, }); } return undefined; } async unWatchPods() { return await this.$dispatch('unwatch', { type: POD, all: true }); } /** * This getter expects a superset of workload pods to have been fetched already * * It assumes fetchPods has been called and should be used instead of the response of fetchPods * (findAll --> findLabelSelector world results won't trigger change detection) */ get pods() { if (this.podMatchExpression) { return this.$getters['matchingLabelSelector'](POD, { matchExpressions: this.podMatchExpression }, this.metadata.namespace); } else { return []; } } /** * Return a string version of a matchLabel expression */ get podSelector() { const relationships = this.metadata?.relationships || []; const selector = relationships.filter((relationship) => relationship.toType === POD)[0]?.selector; return selector; } /** * Match Expression version of the podSelector */ get podMatchExpression() { return this.podSelector ? parse(this.podSelector) : null; } calcPodGauges(pods) { const out = { }; if (!pods) { return out; } pods.map((pod) => { const { stateColor, stateDisplay } = pod; if (out[stateDisplay]) { out[stateDisplay].count++; } else { out[stateDisplay] = { color: stateColor.replace('text-', ''), count: 1 }; } }); return out; } get podGauges() { return this.calcPodGauges(this.pods); } // Job Specific get jobRelationships() { if (this.type !== WORKLOAD_TYPES.CRON_JOB) { return undefined; } return this.metadata?.relationships?.filter((relationship) => relationship.toType === WORKLOAD_TYPES.JOB) || []; } /** * Ensure the store has all matching jobs */ async matchingJobs() { if (this.type !== WORKLOAD_TYPES.CRON_JOB) { return undefined; } // This will be 1 request per relationship, though there's not likely to be many per cron job return Promise.all(this.jobRelationships.map((obj) => { return this.$dispatch('find', { type: WORKLOAD_TYPES.JOB, id: obj.toId }); })); } /** * Expects all required pods are fetched upfront */ get jobs() { if (this.type !== WORKLOAD_TYPES.CRON_JOB) { return undefined; } return this.jobRelationships.map((obj) => { return this.$getters['byId'](WORKLOAD_TYPES.JOB, obj.toId ); }).filter((x) => !!x); } get jobGauges() { const out = { succeeded: { color: 'success', count: 0 }, running: { color: 'info', count: 0 }, failed: { color: 'error', count: 0 } }; if (this.type === WORKLOAD_TYPES.CRON_JOB) { this.jobs.forEach((job) => { const { status = {} } = job; out.running.count += status.active || 0; out.succeeded.count += status.succeeded || 0; out.failed.count += status.failed || 0; }); } else if (this.type === WORKLOAD_TYPES.JOB) { const { status = {} } = this; out.running.count = status.active || 0; out.succeeded.count = status.succeeded || 0; out.failed.count = status.failed || 0; } else { return null; } return out; } get currentRevisionNumber() { if (this.ownedByWorkload || this.kind === 'Job' || this.kind === 'CronJob') { return undefined; } if (this.kind === 'Deployment') { return this.metadata.annotations['deployment.kubernetes.io/revision']; } // 'DaemonSet', 'StatefulSet' return this.metadata.generation; } async matchingPods() { const matchInfo = await matching({ labelSelector: { matchExpressions: convertSelectorObj(this.spec.selector) }, type: POD, $store: this.$store || { getters: this.$rootGetters, dispatch: (action, args) => this.$dispatch(action.split('/')[1], args) }, inStore: this.$rootGetters['currentProduct'].inStore, namespace: this.metadata.namespace, transient: true, }); return matchInfo.matches; } cleanForSave(data) { const val = super.cleanForSave(data); // remove fields from containers val.spec?.template?.spec?.containers?.forEach((container) => { this.cleanContainerForSave(container); }); // remove fields from initContainers val.spec?.template?.spec?.initContainers?.forEach((container) => { this.cleanContainerForSave(container); }); return val; } }