From f7317b2e4e777f0e02b11c8d15049e77f65147f6 Mon Sep 17 00:00:00 2001 From: Ramgopal Nagaboina Date: Tue, 8 Sep 2026 12:32:52 -0400 Subject: [PATCH 1/4] 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 From f367d2ea5d0ae69145e75eb627e31d99c29783d7 Mon Sep 17 00:00:00 2001 From: Ramgopal Nagaboina Date: Wed, 30 Sep 2026 10:35:05 -0400 Subject: [PATCH 2/4] affinity: make the host-tag affinity group admin-controlled Host tags are set by the operator, so a group whose name is used as a host tag should be admin-controlled too. Override isAdminControlledGroup() to true (as ExplicitDedication does) so a non-admin cannot create a group named after an operator tag to bias their VMs onto those hosts. --- .../cloudstack/affinity/HostTagAffinityProcessor.java | 7 ++++++- .../cloudstack/affinity/HostTagAffinityProcessorTest.java | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) 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 index c1a406bc5424..d4f90c2e707b 100644 --- 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 @@ -37,7 +37,7 @@ /** * 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. + * constraint, so no host is excluded. Note: the priority channel is not honored by automatic DRS. */ public class HostTagAffinityProcessor extends AffinityProcessorBase implements AffinityGroupProcessor { @@ -60,6 +60,11 @@ public void process(VirtualMachineProfile vmProfile, DeploymentPlan plan, Exclud } } + @Override + public boolean isAdminControlledGroup() { + return true; + } + protected void processAffinityGroup(AffinityGroupVMMapVO vmGroupMapping, DeploymentPlan plan, VirtualMachine vm) { AffinityGroupVO group = affinityGroupDao.findById(vmGroupMapping.getAffinityGroupId()); if (group == null || StringUtils.isBlank(group.getName())) { 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 index 1dbcda38c985..1673d469a166 100644 --- 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 @@ -132,4 +132,10 @@ 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))); } + + @Test + public void testGroupIsAdminControlled() { + // Host tags are operator-set, so this group type is admin-controlled. + Assert.assertTrue(processor.isAdminControlledGroup()); + } } From f1f1fa93659ffea81a11044cc3be4f9c3bec03e3 Mon Sep 17 00:00:00 2001 From: Ramgopal Nagaboina Date: Fri, 2 Oct 2026 10:12:50 -0400 Subject: [PATCH 3/4] plugins: keep the host-tag-affinity module in alphabetical order --- plugins/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/pom.xml b/plugins/pom.xml index ec21f3e17e50..d5cf9244445c 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -50,9 +50,9 @@ affinity-group-processors/explicit-dedication affinity-group-processors/host-affinity affinity-group-processors/host-anti-affinity + affinity-group-processors/host-tag-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 From a47f48d18f615b1a02c8dabecf365d5a0e035b36 Mon Sep 17 00:00:00 2001 From: Ramgopal Nagaboina Date: Fri, 2 Oct 2026 13:18:28 -0400 Subject: [PATCH 4/4] affinity: scope the host-tag preference to the selected cluster in the javadoc The priority hint reorders hosts within the cluster the planner selects, it does not change cluster selection, so the doc no longer implies a zone-wide guarantee. --- .../cloudstack/affinity/HostTagAffinityProcessor.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index d4f90c2e707b..14c0cab3c567 100644 --- 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 @@ -35,9 +35,10 @@ 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, so no host is excluded. Note: the priority channel is not honored by automatic DRS. + * Soft VM-to-host placement preference: the affinity group name is treated as a host tag, and within + * the cluster the planner selects, hosts carrying that tag have their deployment priority raised. A + * preference, not a constraint, so no host is excluded, and it does not change which cluster is chosen. + * Note: the priority channel is not honored by automatic DRS. */ public class HostTagAffinityProcessor extends AffinityProcessorBase implements AffinityGroupProcessor {