From f7317b2e4e777f0e02b11c8d15049e77f65147f6 Mon Sep 17 00:00:00 2001 From: Ramgopal Nagaboina Date: Tue, 8 Sep 2026 12:32:52 -0400 Subject: [PATCH] affinity: add soft host-tag placement preference Adds a host-tag affinity processor. An affinity group of type "host tag affinity" takes the group name as a host tag; when a member VM is deployed, routing hosts carrying that tag in the VM's zone get their deployment priority raised. It is a preference and not a constraint: no host is ever added to the avoid set, so deployment still succeeds when no tagged host is available, and check() never fails a planned destination. The raised priority is not consulted by automatic DRS. Unit tests cover the three paths: tagged hosts get priority raised (nothing excluded), an empty match is a no-op, and check() returns true. --- client/pom.xml | 5 + .../host-tag-affinity/pom.xml | 30 ++++ .../affinity/HostTagAffinityProcessor.java | 92 ++++++++++++ .../host-tag-affinity/module.properties | 18 +++ .../spring-host-tag-affinity-context.xml | 37 +++++ .../HostTagAffinityProcessorTest.java | 135 ++++++++++++++++++ plugins/pom.xml | 1 + 7 files changed, 318 insertions(+) create mode 100644 plugins/affinity-group-processors/host-tag-affinity/pom.xml create mode 100644 plugins/affinity-group-processors/host-tag-affinity/src/main/java/org/apache/cloudstack/affinity/HostTagAffinityProcessor.java create mode 100644 plugins/affinity-group-processors/host-tag-affinity/src/main/resources/META-INF/cloudstack/host-tag-affinity/module.properties create mode 100644 plugins/affinity-group-processors/host-tag-affinity/src/main/resources/META-INF/cloudstack/host-tag-affinity/spring-host-tag-affinity-context.xml create mode 100644 plugins/affinity-group-processors/host-tag-affinity/src/test/java/org/apache/cloudstack/affinity/HostTagAffinityProcessorTest.java diff --git a/client/pom.xml b/client/pom.xml index cc031a4912b1..d02504642d0b 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -572,6 +572,11 @@ cloud-plugin-non-strict-host-affinity ${project.version} + + org.apache.cloudstack + cloud-plugin-host-tag-affinity + ${project.version} + org.apache.cloudstack cloud-plugin-api-solidfire-intg-test diff --git a/plugins/affinity-group-processors/host-tag-affinity/pom.xml b/plugins/affinity-group-processors/host-tag-affinity/pom.xml new file mode 100644 index 000000000000..96679b383c08 --- /dev/null +++ b/plugins/affinity-group-processors/host-tag-affinity/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + cloud-plugin-host-tag-affinity + Apache CloudStack Plugin - Host Tag Affinity Processor + + org.apache.cloudstack + cloudstack-plugins + 24.0.0-SNAPSHOT + ../../pom.xml + + diff --git a/plugins/affinity-group-processors/host-tag-affinity/src/main/java/org/apache/cloudstack/affinity/HostTagAffinityProcessor.java b/plugins/affinity-group-processors/host-tag-affinity/src/main/java/org/apache/cloudstack/affinity/HostTagAffinityProcessor.java new file mode 100644 index 000000000000..c1a406bc5424 --- /dev/null +++ b/plugins/affinity-group-processors/host-tag-affinity/src/main/java/org/apache/cloudstack/affinity/HostTagAffinityProcessor.java @@ -0,0 +1,92 @@ +// 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 org.apache.cloudstack.affinity; + +import java.util.List; + +import javax.inject.Inject; + +import org.apache.cloudstack.affinity.dao.AffinityGroupDao; +import org.apache.cloudstack.affinity.dao.AffinityGroupVMMapDao; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.deploy.DeploymentPlan; +import com.cloud.deploy.DeploymentPlanner.ExcludeList; +import com.cloud.exception.AffinityConflictException; +import com.cloud.host.Host; +import com.cloud.host.HostVO; +import com.cloud.host.dao.HostDao; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineProfile; + +/** + * Soft VM-to-host placement preference: the affinity group name is treated as a host tag, and hosts + * carrying that tag in the VM's zone have their deployment priority raised. A preference, not a + * constraint — no host is excluded. Note: the priority channel is not honored by automatic DRS. + */ +public class HostTagAffinityProcessor extends AffinityProcessorBase implements AffinityGroupProcessor { + + @Inject + protected AffinityGroupDao affinityGroupDao; + @Inject + protected AffinityGroupVMMapDao affinityGroupVMMapDao; + @Inject + protected HostDao hostDao; + + @Override + public void process(VirtualMachineProfile vmProfile, DeploymentPlan plan, ExcludeList avoid, List vmList) throws AffinityConflictException { + VirtualMachine vm = vmProfile.getVirtualMachine(); + List vmGroupMappings = affinityGroupVMMapDao.findByVmIdType(vm.getId(), getType()); + + for (AffinityGroupVMMapVO vmGroupMapping : vmGroupMappings) { + if (vmGroupMapping != null) { + processAffinityGroup(vmGroupMapping, plan, vm); + } + } + } + + protected void processAffinityGroup(AffinityGroupVMMapVO vmGroupMapping, DeploymentPlan plan, VirtualMachine vm) { + AffinityGroupVO group = affinityGroupDao.findById(vmGroupMapping.getAffinityGroupId()); + if (group == null || StringUtils.isBlank(group.getName())) { + return; + } + + String hostTag = group.getName(); + List preferredHosts = hostDao.listByHostTag(Host.Type.Routing, null, null, vm.getDataCenterId(), hostTag); + if (CollectionUtils.isEmpty(preferredHosts)) { + if (logger.isDebugEnabled()) { + logger.debug(String.format("No hosts carry tag [%s] in zone %s for VM %s; host-tag affinity is a no-op.", + hostTag, vm.getDataCenterId(), vm)); + } + return; + } + + for (HostVO host : preferredHosts) { + Integer priority = adjustHostPriority(plan, host.getId()); + if (logger.isDebugEnabled()) { + logger.debug(String.format("Raised host %s priority to %s (VM %s prefers hosts tagged [%s]).", + host.getId(), priority, vm, hostTag)); + } + } + } + + protected Integer adjustHostPriority(DeploymentPlan plan, Long hostId) { + plan.adjustHostPriority(hostId, DeploymentPlan.HostPriorityAdjustment.HIGHER); + return plan.getHostPriorities().get(hostId); + } +} diff --git a/plugins/affinity-group-processors/host-tag-affinity/src/main/resources/META-INF/cloudstack/host-tag-affinity/module.properties b/plugins/affinity-group-processors/host-tag-affinity/src/main/resources/META-INF/cloudstack/host-tag-affinity/module.properties new file mode 100644 index 000000000000..14fb848921ba --- /dev/null +++ b/plugins/affinity-group-processors/host-tag-affinity/src/main/resources/META-INF/cloudstack/host-tag-affinity/module.properties @@ -0,0 +1,18 @@ +# 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. +name=host-tag-affinity +parent=planner diff --git a/plugins/affinity-group-processors/host-tag-affinity/src/main/resources/META-INF/cloudstack/host-tag-affinity/spring-host-tag-affinity-context.xml b/plugins/affinity-group-processors/host-tag-affinity/src/main/resources/META-INF/cloudstack/host-tag-affinity/spring-host-tag-affinity-context.xml new file mode 100644 index 000000000000..ba15d502433b --- /dev/null +++ b/plugins/affinity-group-processors/host-tag-affinity/src/main/resources/META-INF/cloudstack/host-tag-affinity/spring-host-tag-affinity-context.xml @@ -0,0 +1,37 @@ + + + + + + + + + + diff --git a/plugins/affinity-group-processors/host-tag-affinity/src/test/java/org/apache/cloudstack/affinity/HostTagAffinityProcessorTest.java b/plugins/affinity-group-processors/host-tag-affinity/src/test/java/org/apache/cloudstack/affinity/HostTagAffinityProcessorTest.java new file mode 100644 index 000000000000..1dbcda38c985 --- /dev/null +++ b/plugins/affinity-group-processors/host-tag-affinity/src/test/java/org/apache/cloudstack/affinity/HostTagAffinityProcessorTest.java @@ -0,0 +1,135 @@ +/* + * 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 org.apache.cloudstack.affinity; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.cloudstack.affinity.dao.AffinityGroupDao; +import org.apache.cloudstack.affinity.dao.AffinityGroupVMMapDao; +import org.junit.Assert; +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.deploy.DataCenterDeployment; +import com.cloud.deploy.DeployDestination; +import com.cloud.deploy.DeploymentPlanner.ExcludeList; +import com.cloud.host.Host; +import com.cloud.host.HostVO; +import com.cloud.host.dao.HostDao; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineProfile; + +@RunWith(MockitoJUnitRunner.class) +public class HostTagAffinityProcessorTest { + + @Spy + @InjectMocks + HostTagAffinityProcessor processor = new HostTagAffinityProcessor(); + + @Mock + AffinityGroupVMMapDao affinityGroupVMMapDao; + @Mock + AffinityGroupDao affinityGroupDao; + @Mock + HostDao hostDao; + + long vmId = 10L; + long affinityGroupId = 20L; + long zoneId = 2L; + long host2Id = 3L; + long host3Id = 4L; + String groupName = "gold"; + + private VirtualMachineProfile mockVmProfile() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + when(vm.getId()).thenReturn(vmId); + when(vm.getDataCenterId()).thenReturn(zoneId); + VirtualMachineProfile vmProfile = Mockito.mock(VirtualMachineProfile.class); + when(vmProfile.getVirtualMachine()).thenReturn(vm); + return vmProfile; + } + + private void stubGroupMembership() { + List vmGroupMappings = new ArrayList<>(); + vmGroupMappings.add(new AffinityGroupVMMapVO(affinityGroupId, vmId)); + when(affinityGroupVMMapDao.findByVmIdType(eq(vmId), nullable(String.class))).thenReturn(vmGroupMappings); + AffinityGroupVO group = Mockito.mock(AffinityGroupVO.class); + when(affinityGroupDao.findById(affinityGroupId)).thenReturn(group); + when(group.getName()).thenReturn(groupName); + } + + @Test + public void testProcessRaisesPriorityForTaggedHosts() { + VirtualMachineProfile vmProfile = mockVmProfile(); + stubGroupMembership(); + + HostVO host2 = Mockito.mock(HostVO.class); + when(host2.getId()).thenReturn(host2Id); + HostVO host3 = Mockito.mock(HostVO.class); + when(host3.getId()).thenReturn(host3Id); + when(hostDao.listByHostTag(eq(Host.Type.Routing), isNull(), isNull(), eq(zoneId), eq(groupName))) + .thenReturn(Arrays.asList(host2, host3)); + + DataCenterDeployment plan = new DataCenterDeployment(zoneId); + ExcludeList avoid = new ExcludeList(); + + processor.process(vmProfile, plan, avoid); + + // Both tagged hosts get raised to priority 1 (DEFAULT 0 -> HIGHER +1); nothing is excluded. + Assert.assertEquals(2, plan.getHostPriorities().size()); + Assert.assertEquals(Integer.valueOf(1), plan.getHostPriorities().get(host2Id)); + Assert.assertEquals(Integer.valueOf(1), plan.getHostPriorities().get(host3Id)); + Assert.assertFalse("soft preference: no host may be excluded", avoid.shouldAvoid(host2)); + } + + @Test + public void testProcessNoMatchingHostsIsNoOp() { + VirtualMachineProfile vmProfile = mockVmProfile(); + stubGroupMembership(); + when(hostDao.listByHostTag(eq(Host.Type.Routing), isNull(), isNull(), eq(zoneId), eq(groupName))) + .thenReturn(Collections.emptyList()); + + DataCenterDeployment plan = new DataCenterDeployment(zoneId); + ExcludeList avoid = new ExcludeList(); + + processor.process(vmProfile, plan, avoid); + + Assert.assertTrue(plan.getHostPriorities().isEmpty()); + } + + @Test + public void testCheckAlwaysTrue() throws Exception { + // A soft preference must never fail a planned destination. + Assert.assertTrue(processor.check(Mockito.mock(VirtualMachineProfile.class), Mockito.mock(DeployDestination.class))); + } +} diff --git a/plugins/pom.xml b/plugins/pom.xml index 92768827f658..ec21f3e17e50 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -52,6 +52,7 @@ affinity-group-processors/host-anti-affinity affinity-group-processors/non-strict-host-affinity affinity-group-processors/non-strict-host-anti-affinity + affinity-group-processors/host-tag-affinity alert-handlers/snmp-alerts alert-handlers/syslog-alerts