dashboard/shell/models/workload.js

735 lines
19 KiB
JavaScript

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