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