cks,ui: allow changing stopped cluster offering, improvements (#7475)

* cks,ui: allow changing stopped cluster offering, improvements

Fixes #7454

- Allows changing compute offering for a stopped cluster
- Allows compute offering change when the cluster has autoscaling enabled

Signed-off-by: Abhishek Kumar <abhishek.mrt22@gmail.com>
This commit is contained in:
Abhishek Kumar 2023-05-29 15:58:06 +05:30 committed by GitHub
parent c3535880d2
commit f636580195
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 258 additions and 81 deletions

View File

@ -803,6 +803,34 @@ public class KubernetesClusterManagerImpl extends ManagerBase implements Kuberne
}
}
protected void validateKubernetesClusterScaleSize(final KubernetesClusterVO kubernetesCluster, final Long clusterSize, final int maxClusterSize, final DataCenter zone) {
if (clusterSize == null) {
return;
}
if (clusterSize == kubernetesCluster.getNodeCount()) {
return;
}
if (kubernetesCluster.getState().equals(KubernetesCluster.State.Stopped)) { // Cannot scale stopped cluster currently for cluster size
throw new PermissionDeniedException(String.format("Kubernetes cluster : %s is in %s state", kubernetesCluster.getName(), kubernetesCluster.getState().toString()));
}
if (clusterSize < 1) {
throw new InvalidParameterValueException(String.format("Kubernetes cluster : %s cannot be scaled for size, %d", kubernetesCluster.getName(), clusterSize));
}
if (clusterSize + kubernetesCluster.getControlNodeCount() > maxClusterSize) {
throw new InvalidParameterValueException(
String.format("Maximum cluster size can not exceed %d. Please contact your administrator", maxClusterSize));
}
if (clusterSize > kubernetesCluster.getNodeCount()) { // Upscale
VMTemplateVO template = templateDao.findById(kubernetesCluster.getTemplateId());
if (template == null) {
throw new InvalidParameterValueException(String.format("Invalid template associated with Kubernetes cluster : %s", kubernetesCluster.getName()));
}
if (CollectionUtils.isEmpty(templateJoinDao.newTemplateView(template, zone.getId(), true))) {
throw new InvalidParameterValueException(String.format("Template : %s associated with Kubernetes cluster : %s is not in Ready state for datacenter : %s", template.getName(), kubernetesCluster.getName(), zone.getName()));
}
}
}
private void validateKubernetesClusterScaleParameters(ScaleKubernetesClusterCmd cmd) {
final Long kubernetesClusterId = cmd.getId();
final Long serviceOfferingId = cmd.getServiceOfferingId();
@ -844,8 +872,8 @@ public class KubernetesClusterManagerImpl extends ManagerBase implements Kuberne
int maxClusterSize = KubernetesMaxClusterSize.valueIn(kubernetesCluster.getAccountId());
if (isAutoscalingEnabled != null && isAutoscalingEnabled) {
if (clusterSize != null || serviceOfferingId != null || nodeIds != null) {
throw new InvalidParameterValueException("Autoscaling can not be passed along with nodeids or clustersize or service offering");
if (clusterSize != null || nodeIds != null) {
throw new InvalidParameterValueException("Autoscaling can not be passed along with nodeids or clustersize");
}
if (!KubernetesVersionManagerImpl.versionSupportsAutoscaling(clusterVersion)) {
@ -914,34 +942,14 @@ public class KubernetesClusterManagerImpl extends ManagerBase implements Kuberne
}
}
final ServiceOffering existingServiceOffering = serviceOfferingDao.findById(kubernetesCluster.getServiceOfferingId());
if (serviceOffering.getRamSize() < existingServiceOffering.getRamSize() ||
serviceOffering.getCpu() * serviceOffering.getSpeed() < existingServiceOffering.getCpu() * existingServiceOffering.getSpeed()) {
if (KubernetesCluster.State.Running.equals(kubernetesCluster.getState()) && (serviceOffering.getRamSize() < existingServiceOffering.getRamSize() ||
serviceOffering.getCpu() * serviceOffering.getSpeed() < existingServiceOffering.getCpu() * existingServiceOffering.getSpeed())) {
logAndThrow(Level.WARN, String.format("Kubernetes cluster cannot be scaled down for service offering. Service offering : %s offers lesser resources as compared to service offering : %s of Kubernetes cluster : %s",
serviceOffering.getName(), existingServiceOffering.getName(), kubernetesCluster.getName()));
}
}
if (clusterSize != null) {
if (kubernetesCluster.getState().equals(KubernetesCluster.State.Stopped)) { // Cannot scale stopped cluster currently for cluster size
throw new PermissionDeniedException(String.format("Kubernetes cluster : %s is in %s state", kubernetesCluster.getName(), kubernetesCluster.getState().toString()));
}
if (clusterSize < 1) {
throw new InvalidParameterValueException(String.format("Kubernetes cluster : %s cannot be scaled for size, %d", kubernetesCluster.getName(), clusterSize));
}
if (clusterSize + kubernetesCluster.getControlNodeCount() > maxClusterSize) {
throw new InvalidParameterValueException(
String.format("Maximum cluster size can not exceed %d. Please contact your administrator", maxClusterSize));
}
if (clusterSize > kubernetesCluster.getNodeCount()) { // Upscale
VMTemplateVO template = templateDao.findById(kubernetesCluster.getTemplateId());
if (template == null) {
throw new InvalidParameterValueException(String.format("Invalid template associated with Kubernetes cluster : %s", kubernetesCluster.getName()));
}
if (CollectionUtils.isEmpty(templateJoinDao.newTemplateView(template, zone.getId(), true))) {
throw new InvalidParameterValueException(String.format("Template : %s associated with Kubernetes cluster : %s is not in Ready state for datacenter : %s", template.getName(), kubernetesCluster.getName(), zone.getName()));
}
}
}
validateKubernetesClusterScaleSize(kubernetesCluster, clusterSize, maxClusterSize, zone);
}
private void validateKubernetesClusterUpgradeParameters(UpgradeKubernetesClusterCmd cmd) {

View File

@ -17,6 +17,29 @@
package com.cloud.kubernetes.cluster.actionworkers;
import static com.cloud.utils.NumbersUtil.toHumanReadableSize;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.inject.Inject;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.BaseCmd;
import org.apache.cloudstack.api.command.user.firewall.CreateFirewallRuleCmd;
import org.apache.cloudstack.api.command.user.vm.StartVMCmd;
import org.apache.cloudstack.api.command.user.volume.ResizeVolumeCmd;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.log4j.Level;
import com.cloud.capacity.CapacityManager;
import com.cloud.dc.ClusterDetailsDao;
import com.cloud.dc.ClusterDetailsVO;
@ -77,28 +100,6 @@ import com.cloud.vm.VirtualMachine;
import com.cloud.vm.VmDetailConstants;
import com.cloud.vm.dao.VMInstanceDao;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.BaseCmd;
import org.apache.cloudstack.api.command.user.firewall.CreateFirewallRuleCmd;
import org.apache.cloudstack.api.command.user.vm.StartVMCmd;
import org.apache.cloudstack.api.command.user.volume.ResizeVolumeCmd;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.log4j.Level;
import javax.inject.Inject;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import static com.cloud.utils.NumbersUtil.toHumanReadableSize;
public class KubernetesClusterResourceModifierActionWorker extends KubernetesClusterActionWorker {
@Inject
@ -669,7 +670,6 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu
} finally {
// Deploying the autoscaler might fail but it can be deployed manually too, so no need to go to an alert state
updateLoginUserDetails(null);
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.OperationSucceeded);
}
}
}

View File

@ -28,6 +28,7 @@ import javax.inject.Inject;
import org.apache.cloudstack.api.InternalIdentity;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.log4j.Level;
import com.cloud.dc.DataCenter;
@ -57,7 +58,6 @@ import com.cloud.vm.UserVmVO;
import com.cloud.vm.VMInstanceVO;
import com.cloud.vm.VirtualMachine;
import com.cloud.vm.dao.VMInstanceDao;
import org.apache.commons.lang3.StringUtils;
public class KubernetesClusterScaleWorker extends KubernetesClusterResourceModifierActionWorker {
@ -406,6 +406,19 @@ public class KubernetesClusterScaleWorker extends KubernetesClusterResourceModif
kubernetesCluster = updateKubernetesClusterEntry(clusterSize, null);
}
private boolean isAutoscalingChanged() {
if (this.isAutoscalingEnabled == null) {
return false;
}
if (this.isAutoscalingEnabled != kubernetesCluster.getAutoscalingEnabled()) {
return true;
}
if (minSize != null && (!minSize.equals(kubernetesCluster.getMinSize()))) {
return true;
}
return maxSize != null && (!maxSize.equals(kubernetesCluster.getMaxSize()));
}
public boolean scaleCluster() throws CloudRuntimeException {
init();
if (LOGGER.isInfoEnabled()) {
@ -417,11 +430,17 @@ public class KubernetesClusterScaleWorker extends KubernetesClusterResourceModif
if (existingServiceOffering == null) {
logAndThrow(Level.ERROR, String.format("Scaling Kubernetes cluster : %s failed, service offering for the Kubernetes cluster not found!", kubernetesCluster.getName()));
}
if (this.isAutoscalingEnabled != null) {
return autoscaleCluster(this.isAutoscalingEnabled, minSize, maxSize);
}
final boolean autscalingChanged = isAutoscalingChanged();
final boolean serviceOfferingScalingNeeded = serviceOffering != null && serviceOffering.getId() != existingServiceOffering.getId();
if (autscalingChanged) {
boolean autoScaled = autoscaleCluster(this.isAutoscalingEnabled, minSize, maxSize);
if (autoScaled && serviceOfferingScalingNeeded) {
scaleKubernetesClusterOffering();
}
stateTransitTo(kubernetesCluster.getId(), KubernetesCluster.Event.OperationSucceeded);
return autoScaled;
}
final boolean clusterSizeScalingNeeded = clusterSize != null && clusterSize != originalClusterSize;
final long newVMRequired = clusterSize == null ? 0 : clusterSize - originalClusterSize;
if (serviceOfferingScalingNeeded && clusterSizeScalingNeeded) {

View File

@ -0,0 +1,129 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package com.cloud.kubernetes.cluster;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.Spy;
import org.mockito.junit.MockitoJUnitRunner;
import com.cloud.api.query.dao.TemplateJoinDao;
import com.cloud.api.query.vo.TemplateJoinVO;
import com.cloud.dc.DataCenter;
import com.cloud.exception.InvalidParameterValueException;
import com.cloud.exception.PermissionDeniedException;
import com.cloud.storage.VMTemplateVO;
import com.cloud.storage.dao.VMTemplateDao;
@RunWith(MockitoJUnitRunner.class)
public class KubernetesClusterManagerImplTest {
@Mock
VMTemplateDao templateDao;
@Mock
TemplateJoinDao templateJoinDao;
@Spy
@InjectMocks
KubernetesClusterManagerImpl clusterManager;
@Test
public void testValidateKubernetesClusterScaleSizeNullNewSizeNoError() {
clusterManager.validateKubernetesClusterScaleSize(Mockito.mock(KubernetesClusterVO.class), null, 100, Mockito.mock(DataCenter.class));
}
@Test
public void testValidateKubernetesClusterScaleSizeSameNewSizeNoError() {
Long size = 2L;
KubernetesClusterVO clusterVO = Mockito.mock(KubernetesClusterVO.class);
Mockito.when(clusterVO.getNodeCount()).thenReturn(size);
clusterManager.validateKubernetesClusterScaleSize(clusterVO, size, 100, Mockito.mock(DataCenter.class));
}
@Test(expected = PermissionDeniedException.class)
public void testValidateKubernetesClusterScaleSizeStoppedCluster() {
Long size = 2L;
KubernetesClusterVO clusterVO = Mockito.mock(KubernetesClusterVO.class);
Mockito.when(clusterVO.getNodeCount()).thenReturn(size);
Mockito.when(clusterVO.getState()).thenReturn(KubernetesCluster.State.Stopped);
clusterManager.validateKubernetesClusterScaleSize(clusterVO, 3L, 100, Mockito.mock(DataCenter.class));
}
@Test(expected = InvalidParameterValueException.class)
public void testValidateKubernetesClusterScaleSizeZeroNewSize() {
Long size = 2L;
KubernetesClusterVO clusterVO = Mockito.mock(KubernetesClusterVO.class);
Mockito.when(clusterVO.getState()).thenReturn(KubernetesCluster.State.Running);
Mockito.when(clusterVO.getNodeCount()).thenReturn(size);
clusterManager.validateKubernetesClusterScaleSize(clusterVO, 0L, 100, Mockito.mock(DataCenter.class));
}
@Test(expected = InvalidParameterValueException.class)
public void testValidateKubernetesClusterScaleSizeOverMaxSize() {
KubernetesClusterVO clusterVO = Mockito.mock(KubernetesClusterVO.class);
Mockito.when(clusterVO.getState()).thenReturn(KubernetesCluster.State.Running);
Mockito.when(clusterVO.getControlNodeCount()).thenReturn(1L);
clusterManager.validateKubernetesClusterScaleSize(clusterVO, 4L, 4, Mockito.mock(DataCenter.class));
}
@Test
public void testValidateKubernetesClusterScaleSizeDownsacaleNoError() {
KubernetesClusterVO clusterVO = Mockito.mock(KubernetesClusterVO.class);
Mockito.when(clusterVO.getState()).thenReturn(KubernetesCluster.State.Running);
Mockito.when(clusterVO.getControlNodeCount()).thenReturn(1L);
Mockito.when(clusterVO.getNodeCount()).thenReturn(4L);
clusterManager.validateKubernetesClusterScaleSize(clusterVO, 2L, 10, Mockito.mock(DataCenter.class));
}
@Test(expected = InvalidParameterValueException.class)
public void testValidateKubernetesClusterScaleSizeUpscaleDeletedTemplate() {
KubernetesClusterVO clusterVO = Mockito.mock(KubernetesClusterVO.class);
Mockito.when(clusterVO.getState()).thenReturn(KubernetesCluster.State.Running);
Mockito.when(clusterVO.getControlNodeCount()).thenReturn(1L);
Mockito.when(clusterVO.getNodeCount()).thenReturn(2L);
Mockito.when(templateDao.findById(Mockito.anyLong())).thenReturn(null);
clusterManager.validateKubernetesClusterScaleSize(clusterVO, 4L, 10, Mockito.mock(DataCenter.class));
}
@Test(expected = InvalidParameterValueException.class)
public void testValidateKubernetesClusterScaleSizeUpscaleNotInZoneTemplate() {
KubernetesClusterVO clusterVO = Mockito.mock(KubernetesClusterVO.class);
Mockito.when(clusterVO.getState()).thenReturn(KubernetesCluster.State.Running);
Mockito.when(clusterVO.getControlNodeCount()).thenReturn(1L);
Mockito.when(clusterVO.getNodeCount()).thenReturn(2L);
Mockito.when(templateDao.findById(Mockito.anyLong())).thenReturn(Mockito.mock(VMTemplateVO.class));
Mockito.when(templateJoinDao.newTemplateView(Mockito.any(VMTemplateVO.class), Mockito.anyLong(), Mockito.anyBoolean())).thenReturn(null);
clusterManager.validateKubernetesClusterScaleSize(clusterVO, 4L, 10, Mockito.mock(DataCenter.class));
}
@Test
public void testValidateKubernetesClusterScaleSizeUpscaleNoError() {
KubernetesClusterVO clusterVO = Mockito.mock(KubernetesClusterVO.class);
Mockito.when(clusterVO.getState()).thenReturn(KubernetesCluster.State.Running);
Mockito.when(clusterVO.getControlNodeCount()).thenReturn(1L);
Mockito.when(clusterVO.getNodeCount()).thenReturn(2L);
Mockito.when(templateDao.findById(Mockito.anyLong())).thenReturn(Mockito.mock(VMTemplateVO.class));
Mockito.when(templateJoinDao.newTemplateView(Mockito.any(VMTemplateVO.class), Mockito.anyLong(), Mockito.anyBoolean())).thenReturn(List.of(Mockito.mock(TemplateJoinVO.class)));
clusterManager.validateKubernetesClusterScaleSize(clusterVO, 4L, 10, Mockito.mock(DataCenter.class));
}
}

View File

@ -504,7 +504,7 @@ export default {
message: 'message.kubernetes.cluster.scale',
docHelp: 'plugins/cloudstack-kubernetes-service.html#scaling-kubernetes-cluster',
dataView: true,
show: (record) => { return ['Created', 'Running'].includes(record.state) },
show: (record) => { return ['Created', 'Running', 'Stopped'].includes(record.state) },
popup: true,
component: shallowRef(defineAsyncComponent(() => import('@/views/compute/ScaleKubernetesCluster.vue')))
},

View File

@ -28,6 +28,25 @@
:rules="rules"
@finish="handleSubmit"
layout="vertical">
<a-form-item name="serviceofferingid" ref="serviceofferingid">
<template #label>
<tooltip-label :title="$t('label.serviceofferingid')" :tooltip="apiParams.serviceofferingid.description"/>
</template>
<a-select
id="offering-selection"
v-model:value="form.serviceofferingid"
showSearch
optionFilterProp="label"
:filterOption="(input, option) => {
return option.children[0].children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}"
:loading="serviceOfferingLoading"
:placeholder="apiParams.serviceofferingid.description">
<a-select-option v-for="(opt, optIndex) in serviceOfferings" :key="optIndex">
{{ opt.name || opt.description }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item name="autoscalingenabled" ref="autoscalingenabled" v-if="apiParams.autoscalingenabled">
<template #label>
<tooltip-label :title="$t('label.cks.cluster.autoscalingenabled')" :tooltip="apiParams.autoscalingenabled.description"/>
@ -53,26 +72,7 @@
</a-form-item>
</span>
<span v-else>
<a-form-item name="serviceofferingid" ref="serviceofferingid">
<template #label>
<tooltip-label :title="$t('label.serviceofferingid')" :tooltip="apiParams.serviceofferingid.description"/>
</template>
<a-select
id="offering-selection"
v-model:value="form.serviceofferingid"
showSearch
optionFilterProp="label"
:filterOption="(input, option) => {
return option.children[0].children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}"
:loading="serviceOfferingLoading"
:placeholder="apiParams.serviceofferingid.description">
<a-select-option v-for="(opt, optIndex) in serviceOfferings" :key="optIndex">
{{ opt.name || opt.description }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item name="size" ref="size">
<a-form-item name="size" ref="size" v-if="['Created', 'Running'].includes(resource.state)">
<template #label>
<tooltip-label :title="$t('label.cks.cluster.size')" :tooltip="apiParams.size.description"/>
</template>
@ -152,6 +152,10 @@ export default {
})
},
fetchData () {
if (this.resource.state === 'Running') {
this.fetchKubernetesClusterServiceOfferingData()
return
}
this.fetchKubernetesVersionData()
},
isValidValueForKey (obj, key) {
@ -163,13 +167,28 @@ export default {
isObjectEmpty (obj) {
return !(obj !== null && obj !== undefined && Object.keys(obj).length > 0 && obj.constructor === Object)
},
fetchKubernetesClusterServiceOfferingData () {
const params = {}
if (!this.isObjectEmpty(this.resource)) {
params.id = this.resource.serviceofferingid
}
api('listServiceOfferings', params).then(json => {
var items = json?.listserviceofferingsresponse?.serviceoffering || []
if (this.arrayHasItems(items) && !this.isObjectEmpty(items[0])) {
this.minCpu = items[0].cpunumber
this.minMemory = items[0].memory
}
}).finally(() => {
this.fetchServiceOfferingData()
})
},
fetchKubernetesVersionData () {
const params = {}
if (!this.isObjectEmpty(this.resource)) {
params.id = this.resource.kubernetesversionid
}
api('listKubernetesSupportedVersions', params).then(json => {
const versionObjs = json.listkubernetessupportedversionsresponse.kubernetessupportedversion
const versionObjs = json?.listkubernetessupportedversionsresponse?.kubernetessupportedversion || []
if (this.arrayHasItems(versionObjs) && !this.isObjectEmpty(versionObjs[0])) {
this.minCpu = versionObjs[0].mincpunumber
this.minMemory = versionObjs[0].minmemory
@ -180,14 +199,16 @@ export default {
},
fetchServiceOfferingData () {
this.serviceOfferings = []
const params = {}
const params = {
cpunumber: this.minCpu,
memory: this.minMemory
}
this.serviceOfferingLoading = true
api('listServiceOfferings', params).then(json => {
var items = json.listserviceofferingsresponse.serviceoffering
if (items != null) {
var items = json?.listserviceofferingsresponse?.serviceoffering || []
if (this.arrayHasItems(items)) {
for (var i = 0; i < items.length; i++) {
if (items[i].iscustomized === false &&
items[i].cpunumber >= this.minCpu && items[i].memory >= this.minMemory) {
if (items[i].iscustomized === false) {
this.serviceOfferings.push(items[i])
}
}
@ -220,7 +241,7 @@ export default {
if (this.isValidValueForKey(values, 'size') && values.size > 0) {
params.size = values.size
}
if (this.isValidValueForKey(values, 'serviceofferingid') && this.arrayHasItems(this.serviceOfferings) && this.autoscalingenabled == null) {
if (this.isValidValueForKey(values, 'serviceofferingid') && this.arrayHasItems(this.serviceOfferings)) {
params.serviceofferingid = this.serviceOfferings[values.serviceofferingid].id
}
if (this.isValidValueForKey(values, 'minsize')) {