dashboard/edit/workload/index.vue

922 lines
28 KiB
Vue

<script>
import omitBy from 'lodash/omitBy';
import { cleanUp } from '@/utils/object';
import {
CONFIG_MAP, SECRET, WORKLOAD_TYPES, NODE, SERVICE, PVC
} from '@/config/types';
import Tab from '@/components/Tabbed/Tab';
import CreateEditView from '@/mixins/create-edit-view';
import { allHash } from '@/utils/promise';
import NameNsDescription from '@/components/form/NameNsDescription';
import LabeledSelect from '@/components/form/LabeledSelect';
import LabeledInput from '@/components/form/LabeledInput';
import HealthCheck from '@/components/form/HealthCheck';
import Security from '@/components/form/Security';
import Upgrading from '@/edit/workload/Upgrading';
import Loading from '@/components/Loading';
import Networking from '@/components/form/Networking';
import VolumeClaimTemplate from '@/edit/workload/VolumeClaimTemplate';
import Job from '@/edit/workload/Job';
import { _EDIT, _CREATE, _VIEW } from '@/config/query-params';
import WorkloadPorts from '@/components/form/WorkloadPorts';
import ContainerResourceLimit from '@/components/ContainerResourceLimit';
import KeyValue from '@/components/form/KeyValue';
import Tabbed from '@/components/Tabbed';
import { mapGetters } from 'vuex';
import NodeScheduling from '@/components/form/NodeScheduling';
import PodAffinity from '@/components/form/PodAffinity';
import Tolerations from '@/components/form/Tolerations';
import CruResource from '@/components/CruResource';
import Command from '@/components/form/Command';
import Storage from '@/edit/workload/storage';
import Labels from '@/components/form/Labels';
import RadioGroup from '@/components/form/RadioGroup';
import { UI_MANAGED } from '@/config/labels-annotations';
import { removeObject } from '@/utils/array';
export default {
name: 'CruWorkload',
components: {
Loading,
NameNsDescription,
LabeledSelect,
LabeledInput,
KeyValue,
Tabbed,
Tab,
Upgrading,
Networking,
Job,
HealthCheck,
Security,
WorkloadPorts,
ContainerResourceLimit,
PodAffinity,
NodeScheduling,
Tolerations,
CruResource,
Command,
Storage,
VolumeClaimTemplate,
Labels,
RadioGroup,
},
mixins: [CreateEditView],
props: {
value: {
type: Object,
required: true
},
mode: {
type: String,
default: 'create'
}
},
async fetch() {
const requests = {
configMaps: this.$store.dispatch('cluster/findAll', { type: CONFIG_MAP }),
nodes: this.$store.dispatch('cluster/findAll', { type: NODE }),
services: this.$store.dispatch('cluster/findAll', { type: SERVICE }),
pvcs: this.$store.dispatch('cluster/findAll', { type: PVC })
};
if ( this.$store.getters['cluster/schemaFor'](SECRET) ) {
requests.secrets = this.$store.dispatch('cluster/findAll', { type: SECRET });
}
const hash = await allHash(requests);
this.servicesOwned = await this.value.getServicesOwned();
this.allSecrets = hash.secrets || [];
this.allConfigMaps = hash.configMaps;
this.allNodes = hash.nodes.map(node => node.id);
this.allServices = hash.services;
this.pvcs = hash.pvcs;
},
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 = {};
}
const spec = this.value.spec;
let container;
const podTemplateSpec = type === WORKLOAD_TYPES.CRON_JOB ? spec.jobTemplate.spec.template.spec : spec?.template?.spec;
let containers = podTemplateSpec.containers;
if (this.mode === _CREATE || this.mode === _VIEW || (!createSidecar && !this.value.hasSidecars)) {
container = containers[0];
} else {
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) {
container = { imagePullPolicy: 'Always', name: `container-${ allContainers.length }` };
containers.push(container);
} else {
container = containers[0];
}
}
this.selectContainer(container);
return {
allConfigMaps: [],
allNodes: null,
allSecrets: [],
allServices: [],
name: this.value?.metadata?.name || null,
pvcs: [],
showTabs: false,
pullPolicyOptions: ['Always', 'IfNotPresent', 'Never'],
spec,
type,
servicesOwned: [],
servicesToRemove: [],
portsForServices: [],
isInitContainer,
container,
};
},
computed: {
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
);
},
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() {
return [...this.podTemplateSpec.containers, ...(this.podTemplateSpec.initContainers || []).map((each) => {
each._init = true;
return each;
})];
},
flatResources: {
get() {
const { limits = {}, requests = {} } = this.container.resources || {};
const { cpu:limitsCpu, memory:limitsMemory } = limits;
const { cpu:requestsCpu, memory:requestsMemory } = requests;
return {
limitsCpu, limitsMemory, requestsCpu, requestsMemory
};
},
set(neu) {
const {
limitsCpu, limitsMemory, requestsCpu, requestsMemory
} = neu;
const out = {
requests: {
cpu: requestsCpu,
memory: requestsMemory
},
limits: {
cpu: limitsCpu,
memory: limitsMemory
}
};
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;
}
},
namespacedConfigMaps() {
const namespace = this.value?.metadata?.namespace;
if (namespace) {
return this.allConfigMaps.filter(
configMap => configMap.metadata.namespace === namespace
);
} else {
return this.allConfigMaps;
}
},
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;
},
nameNsColumns() {
const out = [];
if (this.isCronJob) {
out.push('schedule');
} else if (this.isReplicable) {
out.push('replicas');
if (this.isStatefulSet) {
out.push('service');
}
}
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;
},
},
created() {
this.registerBeforeHook(this.saveWorkload, 'willSaveWorkload');
this.registerBeforeHook(this.getPorts, 'getPorts');
this.registerAfterHook(this.saveService, 'saveService');
},
methods: {
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() {
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.mode === _CREATE) {
this.spec.selector = { matchLabels: 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) {
if (!template.metadata) {
template.metadata = { labels: this.value.workloadSelector };
} else {
Object.assign(template.metadata.labels, this.value.workloadSelector);
}
}
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);
// delete this.value.kind;
if (this.container && !this.container.name) {
this.$set(this.container, 'name', this.value.metadata.name);
}
if (this.container) {
let existing;
if (this.isInitContainer) {
existing = this.podTemplateSpec.initContainers.find(container => container._active);
} else {
existing = this.podTemplateSpec.containers.find(container => container._active);
}
Object.assign(existing, this.container);
}
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;
},
selectType(type) {
if (!this.type && type) {
this.$router.replace({ params: { resource: type } });
} else {
this.type = type;
}
},
selectContainer(container) {
if (container._add) {
this.addContainer();
return;
}
container._active = true;
this.container = container;
this.isInitContainer = !!container._init;
},
addContainer() {
const container = { imagePullPolicy: 'Always', name: `container-${ this.allContainers.length }` };
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 {
const initContainers = this.podTemplateSpec.initContainers;
removeObject(initContainers, this.container);
containers.push(this.container);
}
this.isInitContainer = neu;
},
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<form v-else>
<CruResource
:validation-passed="true"
:selected-subtype="type"
:resource="value"
:mode="mode"
:errors="errors"
:done-route="doneRoute"
:subtypes="workloadSubTypes"
@finish="save"
@select-type="selectType"
@error="e=>errors = e"
@apply-hooks="applyHooks"
>
<div class="row">
<div class="col span-12">
<NameNsDescription :value="value" :extra-columns="nameNsColumns" :mode="mode" @change="name=value.metadata.name">
<template #schedule>
<LabeledInput
v-model="spec.schedule"
type="cron"
required
:mode="mode"
:label="t('workload.cronSchedule')"
placeholder="0 * * * *"
/>
</template>
<template #replicas>
<LabeledInput v-model.number="spec.replicas" type="number" required :mode="mode" :label="t('workload.replicas')" />
</template>
<template #service>
<LabeledSelect
v-model="spec.serviceName"
option-label="metadata.name"
:reduce="service=>service.metadata.name"
:mode="mode"
:label="t('workload.serviceName')"
:options="headlessServices"
/>
</template>
</NameNsDescription>
</div>
</div>
<div v-if="containerOptions.length > 1" class="container-row">
<div class="col span-4">
<LabeledSelect :value="container" option-label="name" :label="t('workload.container.titles.container')" :options="containerOptions" @input="selectContainer" />
</div>
<div v-if="allContainers.length > 1 && !isView" class="col">
<button type="button" class="btn-sm role-link" @click="removeContainer(container)">
{{ t('workload.container.removeContainer') }}
</button>
</div>
</div>
<Tabbed :key="allContainers.indexOf(container)" :side-tabs="true">
<Tab :label="t('workload.container.titles.general')" name="general">
<div>
<div :style="{'align-items':'center'}" class="row mb-20">
<div class="col span-6">
<LabeledInput v-model="container.name" :mode="mode" :label="t('workload.container.containerName')" />
</div>
<div class="col span-6">
<RadioGroup
:mode="mode"
:value="isInitContainer"
name="initContainer"
:options="[true, false]"
:labels="[t('workload.container.init'), t('workload.container.standard')]"
@input="updateInitContainer"
/>
</div>
</div>
<h3>{{ t('workload.container.titles.image') }}</h3>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model="container.image"
:mode="mode"
:label="t('workload.container.image')"
placeholder="e.g. nginx:latest"
required
/>
</div>
<div class="col span-6">
<LabeledSelect
v-model="container.imagePullPolicy"
:label="t('workload.container.imagePullPolicy')"
:options="pullPolicyOptions"
:mode="mode"
/>
</div>
</div>
<div class="row">
<div class="col span-6">
<LabeledSelect
v-model="imagePullSecrets"
:label="t('workload.container.imagePullSecrets')"
:multiple="true"
:taggable="true"
:options="namespacedSecrets"
:mode="mode"
option-label="metadata.name"
:reduce="service=>service.metadata.name"
/>
</div>
</div>
</div>
<div class="spacer"></div>
<div>
<h3>{{ t('workload.container.titles.ports') }}</h3>
<div class="row">
<WorkloadPorts v-model="container.ports" :name="value.metadata.name" :services="servicesOwned" :mode="mode" />
</div>
</div>
<div class="spacer"></div>
<div>
<h3>{{ t('workload.container.titles.command') }}</h3>
<Command v-model="container" :secrets="namespacedSecrets" :config-maps="namespacedConfigMaps" :mode="mode" />
</div>
</Tab>
<Tab :label="t('workload.storage.title')" name="storage">
<Storage
v-model="podTemplateSpec"
:namespace="value.metadata.namespace"
:register-before-hook="registerBeforeHook"
:mode="mode"
:secrets="namespacedSecrets"
:config-maps="namespacedConfigMaps"
:container="container"
/>
</Tab>
<Tab :label="t('workload.container.titles.resources')" name="resources">
<h3 class="mb-10">
<t k="workload.scheduling.titles.limits" />
</h3>
<ContainerResourceLimit v-model="flatResources" :mode="mode" :show-tip="false" />
<template>
<div class="spacer"></div>
<div>
<h3 class="mb-10">
<t k="workload.scheduling.titles.tolerations" />
</h3>
<div class="row">
<Tolerations v-model="podTemplateSpec.tolerations" :mode="mode" />
</div>
</div>
<div>
<div class="spacer"></div>
<h3 class="mb-10">
<t k="workload.scheduling.titles.priority" />
</h3>
<div class="row">
<div class="col span-6">
<LabeledInput v-model.number="podTemplateSpec.priority" :mode="mode" :label="t('workload.scheduling.priority.priority')" />
</div>
<div class="col span-6">
<LabeledInput v-model="podTemplateSpec.priorityClassname" :mode="mode" :label="t('workload.scheduling.priority.className')" />
</div>
</div>
</div>
</template>
</Tab>
<Tab :label="t('workload.container.titles.podScheduling')" name="podScheduling">
<PodAffinity :mode="mode" :value="podTemplateSpec" />
</Tab>
<Tab :label="t('workload.container.titles.nodeScheduling')" name="nodeScheduling">
<NodeScheduling :mode="mode" :value="podTemplateSpec" :nodes="allNodes" />
</Tab>
<Tab :label="t('workload.container.titles.upgrading')" name="upgrading">
<Job v-if="isJob || isCronJob" v-model="spec" :mode="mode" :type="type" />
<Upgrading v-else v-model="spec" :mode="mode" :type="type" />
</Tab>
<Tab v-if="!isInitContainer" :label="t('workload.container.titles.healthCheck')" name="healthCheck">
<HealthCheck v-model="healthCheck" :mode="mode" />
</Tab>
<Tab :label="t('workload.container.titles.securityContext')" name="securityContext">
<Security v-model="container.securityContext" :mode="mode" />
</Tab>
<Tab :label="t('workload.container.titles.networking')" name="networking">
<Networking v-model="podTemplateSpec" :mode="mode" />
</Tab>
<Tab v-if="isStatefulSet" :label="t('workload.container.titles.volumeClaimTemplates')" name="volumeClaimTemplates">
<VolumeClaimTemplate v-model="spec" :mode="mode" />
</Tab>
<Tab name="labels" :label="t('generic.labelsAndAnnotations')">
<Labels v-model="value" :mode="mode" />
<div class="spacer"></div>
<div>
<h3>{{ t('workload.container.titles.podLabels') }}</h3>
<div class="row mb-20">
<KeyValue
key="labels"
v-model="podLabels"
:add-label="t('labels.addLabel')"
:mode="mode"
:read-allowed="false"
:protip="false"
/>
</div>
<div class="spacer"></div>
<h3>{{ t('workload.container.titles.podAnnotations') }}</h3>
<div class="row">
<KeyValue
key="annotations"
v-model="podAnnotations"
:add-label="t('labels.addAnnotation')"
:mode="mode"
:read-allowed="false"
:protip="false"
/>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</form>
</template>
<style lang='scss'>
.container-row{
display: flex;
align-items: center;
margin-bottom: 20px;
}
.type-placeholder{
color: white;
font-size: 2.5em;
height: 100%;
width: 100%;
background-color: var(--primary);
display: flex;
justify-content: center;
align-items: center;
}
.type-description{
color: var(--input-label)
}
.next-dropdown{
display: inline-block;
}
</style>